From 4627107abb12ac9140ce3b93b6f73bc0ca543745 Mon Sep 17 00:00:00 2001 From: wangtsiao Date: Sat, 5 Sep 2026 04:10:28 +0800 Subject: [PATCH 01/62] feat: provider/model catalog Connections across Desktop, server, and TUI (#196) * feat(protocol): add provider catalog types and reasoning normalize Introduce providers.json catalog types, drop provider_vendor, and normalize reasoning effort aliases to on/off for adapters. * feat(config): migrate providers.json catalog and connections Add catalog/migration/runtime modules, connection persistence, and legacy model_bindings migration onto provider/model overlays. * feat(core): resolve turns from provider/model catalog Replace model_binding/preset paths with catalog-backed selection, provider request maps, and updated conversation/query fixtures. * feat(provider): catalog-aware routing and wire API updates Update OpenAI/Anthropic adapters and router for provider/model selection; include related safety/mcp clippy cleanups. * feat(server): provider discover/upsert and catalog turn routing Wire provider APIs, discovery, session model resolution, and e2e tests onto provider/model catalog selections. * feat(cli,tui): adopt provider/model selection and onboarding Update prompt/doctor routing, client surfaces, and TUI onboarding to the catalog Connection contract. * feat(desktop): provider settings, logos, and chat polish Ship Desktop Connection settings, provider logos, migration helpers, SDK wiring, and chat streaming/error UX for the catalog model. * docs: refresh provider/model docs and enable CI clippy Update configuration docs/specs/READMEs for providers.json and uncomment workspace clippy in GitHub Actions. * fix(clippy): use ? in bash command parse loop Unblocks workspace clippy on Linux CI after re-enabling -D warnings. Co-authored-by: Cursor * fix(clippy): drop needless return in linux bwrap helper Linux-only clippy path failed CI after re-enabling -D warnings. Co-authored-by: Cursor * fix(clippy): prefer sort_by_key for catalog priority order Matches newer clippy unnecessary_sort_by on CI rustc 1.98. Co-authored-by: Cursor * fix(clippy): silence rustc 1.98 lints across sandbox consumers Box large rollback Errs and apply checked_div / Option::filter / useless_conversion / question_mark cleanups so CI stable clippy passes. Co-authored-by: Cursor * fix(ci): box CommitAttemptFailure and rustfmt tool_state for stable clippy EOF Co-authored-by: Cursor --------- Co-authored-by: Cursor --- .github/workflows/ci.yml | 20 +- Cargo.lock | 1 + README.ja.md | 22 +- README.md | 28 +- README.ru.md | 20 +- README.zh-Hans.md | 24 +- README.zh-Hant.md | 24 +- .../devo-ai-sdk/src/v2/client-compact.test.ts | 152 +- .../src/v2/client-config-options.test.ts | 97 +- .../src/v2/client-native-interactions.test.ts | 112 + .../src/v2/client-session-not-found.test.ts | 184 + .../packages/devo-ai-sdk/src/v2/client.ts | 559 +- .../src/v2/native-client-support.ts | 21 +- .../components/ai-elements/conversation.tsx | 17 +- .../ui/src/components/ai-elements/message.tsx | 20 +- .../src/components/ai-elements/reasoning.tsx | 32 +- .../packages/ui/src/components/command.tsx | 2 +- .../src/main/canonical-provider-migration.ts | 14 + .../claude-code-provider-migration.test.ts | 42 +- .../main/claude-code-provider-migration.ts | 86 +- apps/desktop/src/main/ipc-handlers.ts | 23 +- apps/desktop/src/main/native-stdio-client.ts | 12 +- apps/desktop/src/main/notification-watcher.ts | 20 +- apps/desktop/src/main/notifications.ts | 6 + .../main/opencode-provider-migration.test.ts | 50 +- .../src/main/opencode-provider-migration.ts | 99 +- apps/desktop/src/main/settings-store.test.ts | 2 +- .../assets/provider-logos/alibaba.svg | 1 + .../assets/provider-logos/deepseek.svg | 1 + .../renderer/assets/provider-logos/kimi.svg | 1 + .../assets/provider-logos/minimax.svg | 1 + .../assets/provider-logos/moonshot.svg | 1 + .../assets/provider-logos/moonshotai.svg | 3 + .../renderer/assets/provider-logos/ollama.svg | 1 + .../renderer/assets/provider-logos/openai.svg | 1 + .../assets/provider-logos/poolside.svg | 1 + .../renderer/assets/provider-logos/qwen.svg | 1 + .../assets/provider-logos/tencent.svg | 1 + .../renderer/assets/provider-logos/xiaomi.svg | 1 + .../renderer/assets/provider-logos/zai.svg | 1 + .../renderer/assets/provider-logos/zhipu.svg | 1 + .../renderer/atoms/actions/event-processor.ts | 59 +- .../src/renderer/atoms/session-native.test.ts | 138 +- apps/desktop/src/renderer/atoms/sessions.ts | 85 +- apps/desktop/src/renderer/atoms/streaming.ts | 14 +- .../renderer/components/chat/activity-cue.tsx | 27 + .../components/chat/chat-tool-call.tsx | 47 +- .../components/chat/chat-turn.test.ts | 71 +- .../renderer/components/chat/chat-turn.tsx | 221 +- .../renderer/components/chat/chat-view.tsx | 49 +- .../chat/message-response-style.test.ts | 20 + .../chat/model-selector-variant-label.test.ts | 3 + .../chat/model-selector-variant-label.ts | 6 +- .../components/chat/process-timeline-view.tsx | 174 +- .../components/chat/process-timeline.test.ts | 17 +- .../components/chat/process-timeline.ts | 6 +- .../components/chat/provider-error-row.tsx | 79 + .../components/chat/sub-agent-card.tsx | 29 +- .../renderer/components/chat/thought-row.tsx | 22 +- .../components/context-usage-button.tsx | 21 +- .../components/customize/customize-view.tsx | 25 +- .../onboarding/steps/provider-setup-step.tsx | 14 +- .../components/session-metrics-bar.test.ts | 4 + .../components/settings/about-settings.tsx | 2 +- .../settings/connect-provider-dialog.tsx | 5 +- .../settings/connection-detail-dialog.tsx | 205 + .../settings/custom-provider-dialog.tsx | 351 ++ .../components/settings/general-settings.tsx | 2 +- .../components/settings/mcp-settings.tsx | 6 +- .../components/settings/model-edit-dialog.tsx | 1002 ++++ .../components/settings/model-settings.tsx | 395 ++ .../settings/notification-settings.tsx | 2 +- .../components/settings/provider-icon.tsx | 97 +- .../settings/provider-settings.test.tsx | 249 - .../components/settings/provider-settings.tsx | 448 +- .../settings/provider-vendor-dialog.tsx | 504 -- .../components/settings/rule-settings.tsx | 2 +- .../components/settings/server-settings.tsx | 6 +- .../components/settings/settings-header.tsx | 6 +- .../components/settings/settings-page.tsx | 9 +- .../components/settings/settings-row.tsx | 6 +- .../components/settings/settings-section.tsx | 15 +- .../components/settings/settings-surface.ts | 25 + .../components/settings/setup-settings.tsx | 4 +- .../components/settings/skill-settings.tsx | 2 +- .../settings/template-connect-dialog.tsx | 191 + .../components/settings/worktree-settings.tsx | 24 +- .../src/renderer/hooks/use-devo-data.ts | 139 +- apps/desktop/src/renderer/index.css | 42 + apps/desktop/src/renderer/index.html | 4 +- .../lib/invalidate-provider-queries.test.ts | 33 + .../lib/invalidate-provider-queries.ts | 19 + apps/desktop/src/renderer/lib/providers.ts | 56 +- .../src/renderer/lib/settings-sync.test.ts | 2 +- apps/desktop/src/renderer/router.tsx | 10 +- .../renderer/services/connection-manager.ts | 6 + apps/desktop/src/shared/app-settings.ts | 2 +- .../src/shared/native-ipc-error.test.ts | 29 + apps/desktop/src/shared/native-ipc-error.ts | 52 + .../docs/configuration/config-basics.mdx | 60 +- .../docs/configuration/config-basics.zh.mdx | 56 +- .../configuration/deepseek-official-api.mdx | 115 +- .../deepseek-official-api.zh.mdx | 103 +- apps/web/content/docs/configuration/index.mdx | 8 +- .../content/docs/configuration/index.zh.mdx | 6 +- .../docs/configuration/model-bindings.mdx | 328 +- .../docs/configuration/model-bindings.zh.mdx | 303 +- .../docs/configuration/model-resolution.mdx | 58 +- .../configuration/model-resolution.zh.mdx | 47 +- .../docs/configuration/other-providers.mdx | 191 +- .../docs/configuration/other-providers.zh.mdx | 182 +- .../docs/configuration/provider-setup.mdx | 194 +- .../docs/configuration/provider-setup.zh.mdx | 180 +- .../docs/configuration/provider-wire-apis.mdx | 39 +- .../configuration/provider-wire-apis.zh.mdx | 33 +- .../contributors/architecture-overview.mdx | 20 +- .../contributors/architecture-overview.zh.mdx | 12 +- .../docs/contributors/cli-and-tui-flow.mdx | 14 +- .../docs/contributors/cli-and-tui-flow.zh.mdx | 14 +- .../docs/contributors/provider-runtime.mdx | 133 +- .../docs/contributors/provider-runtime.zh.mdx | 116 +- .../web/content/docs/contributors/testing.mdx | 4 +- .../content/docs/contributors/testing.zh.mdx | 4 +- apps/web/content/docs/core-concepts/index.mdx | 2 +- .../content/docs/core-concepts/index.zh.mdx | 2 +- .../core-concepts/models-and-providers.mdx | 145 +- .../core-concepts/models-and-providers.zh.mdx | 126 +- .../get-started/coming-from-claude-code.mdx | 10 +- .../coming-from-claude-code.zh.mdx | 8 +- .../docs/get-started/coming-from-codex.mdx | 6 +- .../docs/get-started/coming-from-codex.zh.mdx | 8 +- .../docs/get-started/connect-deepseek.mdx | 230 +- .../docs/get-started/connect-deepseek.zh.mdx | 211 +- .../content/docs/reference/cli-reference.mdx | 2 +- .../docs/reference/cli-reference.zh.mdx | 2 +- .../docs/reference/config-reference.mdx | 111 +- .../docs/reference/config-reference.zh.mdx | 72 +- .../content/docs/using-devo/model-picker.mdx | 29 +- .../docs/using-devo/model-picker.zh.mdx | 16 +- .../content/docs/using-devo/onboarding.mdx | 114 +- .../content/docs/using-devo/onboarding.zh.mdx | 73 +- crates/cli/src/agent_command.rs | 541 +- crates/cli/src/doctor_command.rs | 114 +- crates/cli/src/prompt_command.rs | 63 +- .../prompt_command/prompt_routing_tests.rs | 120 +- crates/client/src/client_core.rs | 23 + crates/client/src/stdio.rs | 21 + crates/client/src/websocket.rs | 21 + crates/code-search-mcp/src/matrix.rs | 6 +- crates/config/README.md | 349 +- crates/config/src/app.rs | 407 +- crates/config/src/app/mcp_store.rs | 2 +- crates/config/src/app_store_tests.rs | 371 +- crates/config/src/error.rs | 3 + crates/config/src/provider.rs | 579 +- crates/config/src/provider/auth.rs | 50 +- crates/config/src/provider/catalog.rs | 1164 ++++ crates/config/src/provider/migration.rs | 480 ++ crates/config/src/provider/persistence.rs | 210 +- crates/config/src/provider/request.rs | 147 + crates/config/src/provider/resolve.rs | 292 - crates/config/src/provider/runtime.rs | 121 + .../src/provider/runtime_equivalence.rs | 51 - crates/config/src/provider/schema.rs | 123 +- crates/config/src/provider_connection.rs | 390 ++ crates/config/src/tests.rs | 366 +- crates/config/src/tools.rs | 16 +- crates/core/models.json | 235 - crates/core/providers.json | 336 ++ crates/core/src/config_resolution.rs | 308 -- crates/core/src/context/mod.rs | 24 +- crates/core/src/conversation/history.rs | 26 +- .../core/src/conversation/legacy_projector.rs | 3 + crates/core/src/conversation/records.rs | 6 +- crates/core/src/conversation/v2_inverse.rs | 8 +- crates/core/src/lib.rs | 10 +- crates/core/src/model_binding.rs | 357 -- crates/core/src/model_catalog.rs | 675 ++- crates/core/src/model_preset.rs | 413 -- crates/core/src/provider_request.rs | 60 + crates/core/src/query/event.rs | 8 + crates/core/src/query/mod.rs | 12 + crates/core/src/query/provider_retry.rs | 12 +- crates/core/src/query/stream_consumer.rs | 13 +- crates/core/src/query/tests.rs | 21 +- crates/core/src/session.rs | 63 +- crates/core/src/small_model.rs | 193 + crates/core/src/tools/registry_plan.rs | 30 +- crates/core/tests/context_limit_compaction.rs | 1 + .../fixtures/rollout_v1/internal_lines.jsonl | 6 +- crates/core/tests/legacy_projector.rs | 4 +- crates/core/tests/v2_roundtrip.rs | 9 +- crates/mcp/src/manager.rs | 5 +- crates/protocol/README.md | 16 +- crates/protocol/src/acp/ts.rs | 62 +- crates/protocol/src/conversation.rs | 9 +- crates/protocol/src/lib.rs | 4 +- crates/protocol/src/model.rs | 426 +- crates/protocol/src/native/methods.rs | 24 + crates/protocol/src/native/model.rs | 3 + crates/protocol/src/native/rpc_admin.rs | 289 +- crates/protocol/src/native/session.rs | 4 +- crates/protocol/src/native/wire_projector.rs | 1 + crates/protocol/src/provider_catalog.rs | 189 + crates/protocol/src/provider_vendor.rs | 145 - crates/protocol/src/reasoning_effort.rs | 421 +- crates/provider/src/anthropic/messages.rs | 37 +- crates/provider/src/http.rs | 26 + crates/provider/src/lib.rs | 2 +- crates/provider/src/openai/capabilities.rs | 45 +- .../provider/src/openai/chat_completions.rs | 30 +- .../src/openai/chat_completions/stream.rs | 7 +- crates/provider/src/openai/responses.rs | 37 +- crates/provider/src/request.rs | 21 + crates/provider/src/router.rs | 18 +- crates/safety/src/permission/shell_access.rs | 13 +- crates/sandbox/src/bwrap.rs | 2 +- crates/server/Cargo.toml | 1 + crates/server/src/approval_reviewer.rs | 2 +- crates/server/src/bootstrap.rs | 11 +- crates/server/src/execution.rs | 162 +- crates/server/src/persistence.rs | 27 +- crates/server/src/provider_config.rs | 401 +- crates/server/src/runtime.rs | 5 +- crates/server/src/runtime/agents/lifecycle.rs | 2 - crates/server/src/runtime/connection.rs | 151 +- .../server/src/runtime/context_occupancy.rs | 74 +- crates/server/src/runtime/context_usage.rs | 103 +- .../runtime/handlers/acp_config_options.rs | 115 +- .../server/src/runtime/handlers/compaction.rs | 69 +- crates/server/src/runtime/handlers/queue.rs | 1 + .../server/src/runtime/handlers/rollback.rs | 110 +- .../runtime/handlers/rollback_workspace.rs | 18 +- crates/server/src/runtime/handlers/session.rs | 177 +- .../src/runtime/handlers/session_fork.rs | 1 - crates/server/src/runtime/handlers/turn.rs | 21 +- crates/server/src/runtime/model_api.rs | 58 +- crates/server/src/runtime/provider_api.rs | 692 +++ .../server/src/runtime/provider_discovery.rs | 681 +++ .../server/src/runtime/provider_vendor_api.rs | 466 -- .../src/runtime/session_actor/actor_loop.rs | 24 +- .../server/src/runtime/session_actor/turn.rs | 13 +- crates/server/src/runtime/session_title.rs | 47 +- .../src/runtime/turn_exec/approval_resume.rs | 16 +- .../runtime/turn_exec/context_compaction.rs | 42 +- .../src/runtime/turn_exec/event_stream.rs | 84 +- .../server/src/runtime/turn_exec/finalize.rs | 21 +- crates/server/src/runtime/turn_exec/mod.rs | 3 +- crates/server/src/runtime/turn_exec/tests.rs | 3 + crates/server/src/runtime/turn_exec/trace.rs | 4 + crates/server/src/runtime/turn_reservation.rs | 2 - crates/server/src/session_context.rs | 178 +- crates/server/src/usage_ledger.rs | 2 + crates/server/tests/acp_available_commands.rs | 2 - .../acp_permission_tool_status_contract.rs | 2 - .../server/tests/acp_session_contract_e2e.rs | 12 +- crates/server/tests/acp_session_delete.rs | 3 - crates/server/tests/acp_session_lifecycle.rs | 2 - crates/server/tests/cli_log_failures.rs | 90 +- crates/server/tests/command_exec.rs | 2 - crates/server/tests/end_to_end.rs | 3 - crates/server/tests/goal_title_generation.rs | 2 - crates/server/tests/model_config_e2e.rs | 18 +- .../tests/model_selection_persistence.rs | 58 +- crates/server/tests/persistence_resume.rs | 137 +- .../tests/provider_failure_reporting.rs | 13 +- crates/server/tests/provider_routing.rs | 60 +- .../server/tests/queue_drain_notification.rs | 2 - .../tests/session_compaction_threshold.rs | 58 +- .../server/tests/session_fork_persistence.rs | 2 - .../tests/session_rollback_persistence.rs | 2 - .../server/tests/session_sandbox_profile.rs | 2 - crates/server/tests/settings_mid_turn.rs | 8 +- crates/server/tests/skills_integration.rs | 2 - .../server/tests/support/goal_continuation.rs | 2 - .../tests/support/subagent_lifecycle.rs | 2 - .../server/tests/tool_call_param_refresh.rs | 2 - crates/server/tests/turn_start_persistence.rs | 2 - crates/server/tests/websocket_client_e2e.rs | 2 - crates/tui/README.md | 13 +- crates/tui/src/app_command.rs | 31 +- crates/tui/src/app_event.rs | 3 +- crates/tui/src/bottom_pane/command_popup.rs | 3 +- .../bottom_pane/compaction_threshold_view.rs | 9 +- .../src/bottom_pane/context_occupancy_view.rs | 8 +- crates/tui/src/bottom_pane/mod.rs | 11 - .../tui/src/bottom_pane/settings_hub_view.rs | 30 +- crates/tui/src/chatwidget/configuration.rs | 87 +- crates/tui/src/chatwidget/history_commit.rs | 34 +- crates/tui/src/chatwidget/input.rs | 5 +- crates/tui/src/chatwidget/reasoning_effort.rs | 19 +- crates/tui/src/chatwidget/session_header.rs | 26 +- crates/tui/src/chatwidget/transcript_sync.rs | 2 +- crates/tui/src/chatwidget/worker_events.rs | 91 +- crates/tui/src/chatwidget_tests.rs | 205 +- crates/tui/src/events.rs | 52 +- crates/tui/src/exec_cell/render.rs | 2 +- crates/tui/src/history_cell.rs | 24 +- crates/tui/src/inline_onboarding_tests.rs | 85 +- crates/tui/src/interactive.rs | 294 +- crates/tui/src/onboarding.rs | 1131 +--- crates/tui/src/onboarding_widget.rs | 4801 +++++++++++++++-- crates/tui/src/onboarding_widget_tests.rs | 561 +- crates/tui/src/text_formatting.rs | 2 +- crates/tui/src/transcript/model.rs | 1 + crates/tui/src/transcript/presentation.rs | 30 +- crates/tui/src/transcript/restore_session.rs | 8 +- crates/tui/src/transcript/tool_state.rs | 9 +- crates/tui/src/worker.rs | 243 +- crates/tui/src/worker/typed_events.rs | 1 + crates/tui/src/worker_event_test_helpers.rs | 1 + .../shell-command/src/shell_command/bash.rs | 7 +- crates/windows-sandbox/src/lib.rs | 2 +- docs/configuration.ja.md | 391 +- docs/configuration.md | 693 ++- docs/configuration.ru.md | 401 +- docs/configuration.zh-Hans.md | 590 +- docs/configuration.zh-Hant.md | 380 +- .../app/L2-DES-APP-005-config-toml-schema.md | 15 +- ...L2-DES-APP-008-legacy-canonical-mapping.md | 2 +- ...L2-DES-MODEL-001-model-provider-binding.md | 7 +- ...S-MODEL-002-provider-model-json-catalog.md | 123 + specs/traceability/verification.md | 4 +- 323 files changed, 22771 insertions(+), 12817 deletions(-) create mode 100644 apps/desktop/packages/devo-ai-sdk/src/v2/client-session-not-found.test.ts create mode 100644 apps/desktop/src/main/canonical-provider-migration.ts create mode 100644 apps/desktop/src/renderer/assets/provider-logos/alibaba.svg create mode 100644 apps/desktop/src/renderer/assets/provider-logos/deepseek.svg create mode 100644 apps/desktop/src/renderer/assets/provider-logos/kimi.svg create mode 100644 apps/desktop/src/renderer/assets/provider-logos/minimax.svg create mode 100644 apps/desktop/src/renderer/assets/provider-logos/moonshot.svg create mode 100644 apps/desktop/src/renderer/assets/provider-logos/moonshotai.svg create mode 100644 apps/desktop/src/renderer/assets/provider-logos/ollama.svg create mode 100644 apps/desktop/src/renderer/assets/provider-logos/openai.svg create mode 100644 apps/desktop/src/renderer/assets/provider-logos/poolside.svg create mode 100644 apps/desktop/src/renderer/assets/provider-logos/qwen.svg create mode 100644 apps/desktop/src/renderer/assets/provider-logos/tencent.svg create mode 100644 apps/desktop/src/renderer/assets/provider-logos/xiaomi.svg create mode 100644 apps/desktop/src/renderer/assets/provider-logos/zai.svg create mode 100644 apps/desktop/src/renderer/assets/provider-logos/zhipu.svg create mode 100644 apps/desktop/src/renderer/components/chat/activity-cue.tsx create mode 100644 apps/desktop/src/renderer/components/chat/provider-error-row.tsx create mode 100644 apps/desktop/src/renderer/components/settings/connection-detail-dialog.tsx create mode 100644 apps/desktop/src/renderer/components/settings/custom-provider-dialog.tsx create mode 100644 apps/desktop/src/renderer/components/settings/model-edit-dialog.tsx create mode 100644 apps/desktop/src/renderer/components/settings/model-settings.tsx delete mode 100644 apps/desktop/src/renderer/components/settings/provider-settings.test.tsx delete mode 100644 apps/desktop/src/renderer/components/settings/provider-vendor-dialog.tsx create mode 100644 apps/desktop/src/renderer/components/settings/settings-surface.ts create mode 100644 apps/desktop/src/renderer/components/settings/template-connect-dialog.tsx create mode 100644 apps/desktop/src/renderer/lib/invalidate-provider-queries.test.ts create mode 100644 apps/desktop/src/renderer/lib/invalidate-provider-queries.ts create mode 100644 apps/desktop/src/shared/native-ipc-error.test.ts create mode 100644 apps/desktop/src/shared/native-ipc-error.ts create mode 100644 crates/config/src/provider/catalog.rs create mode 100644 crates/config/src/provider/migration.rs create mode 100644 crates/config/src/provider/request.rs delete mode 100644 crates/config/src/provider/resolve.rs create mode 100644 crates/config/src/provider/runtime.rs delete mode 100644 crates/config/src/provider/runtime_equivalence.rs create mode 100644 crates/config/src/provider_connection.rs delete mode 100644 crates/core/models.json create mode 100644 crates/core/providers.json delete mode 100644 crates/core/src/config_resolution.rs delete mode 100644 crates/core/src/model_binding.rs delete mode 100644 crates/core/src/model_preset.rs create mode 100644 crates/core/src/provider_request.rs create mode 100644 crates/core/src/small_model.rs create mode 100644 crates/protocol/src/provider_catalog.rs delete mode 100644 crates/protocol/src/provider_vendor.rs create mode 100644 crates/server/src/runtime/provider_api.rs create mode 100644 crates/server/src/runtime/provider_discovery.rs delete mode 100644 crates/server/src/runtime/provider_vendor_api.rs create mode 100644 specs/L2/model/L2-DES-MODEL-002-provider-model-json-catalog.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0e2ab3c2..98d5a099 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,16 +39,16 @@ jobs: components: rustfmt - run: cargo fmt --all -- --check - # clippy: - # name: Clippy - # runs-on: ubuntu-latest - # steps: - # - uses: actions/checkout@v4 - # - uses: dtolnay/rust-toolchain@stable - # with: - # components: clippy - # - uses: Swatinem/rust-cache@v2 - # - run: cargo clippy --workspace --all-targets -- -D warnings + clippy: + name: Clippy + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: clippy + - uses: Swatinem/rust-cache@v2 + - run: cargo clippy --workspace --all-targets -- -D warnings doc: name: Doc diff --git a/Cargo.lock b/Cargo.lock index 562bb706..85b2db59 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2131,6 +2131,7 @@ dependencies = [ "jsonrepair", "lru", "pretty_assertions", + "reqwest 0.12.28", "rusqlite", "serde", "serde_json", diff --git a/README.ja.md b/README.ja.md index 7d8891d7..4cc8a37d 100644 --- a/README.ja.md +++ b/README.ja.md @@ -39,7 +39,7 @@ Devo は、単一のホスト型モデルエコシステムに縛られず、モ Desktop 体験、terminal workflow、ランタイムの動作、ワークスペースでの 実行境界を自分たちで制御したいチームのための coding agent です。 -- **任意のモデルを接続** - provider/model binding により、OpenAI 互換 Chat +- **任意のモデルを接続** - provider/model Connection により、OpenAI 互換 Chat Completions、OpenAI 互換 Responses、Anthropic Messages、DeepSeek、 Qwen、Kimi、またはプライベートモデルゲートウェイを接続できます。 - **プライベート環境やイントラネット環境に対応** - 単一のローカル Rust @@ -54,7 +54,7 @@ Desktop 体験、terminal workflow、ランタイムの動作、ワークスペ ## 機能 -- **Model-neutral provider runtime** - provider/model binding により、OpenAI 互換、 +- **Model-neutral provider runtime** - provider/model Connection により、OpenAI 互換、 Anthropic 互換、DeepSeek、Qwen、Kimi、GLM、MiniMax、Xiaomi MiMo、 OpenRouter、またはローカルエンドポイントを利用できます。 - **MCP サポート** - [Model Context Protocol](https://modelcontextprotocol.io/) @@ -91,7 +91,7 @@ Desktop 体験、terminal workflow、ランタイムの動作、ワークスペ

Devo の組み込みモデルカタログには、Qwen、Kimi、MiniMax、GLM、DeepSeek の検証済みモデル定義が含まれています。 -プロバイダーのエンドポイントは provider/model binding で引き続き設定できます。 +プロバイダーのエンドポイントは provider/model Connection で引き続き設定できます。 ## 検証済みプラットフォーム @@ -208,17 +208,17 @@ devo resume ## 設定 -`devo onboard` が推奨されるセットアップ方法です。provider/model binding を -`config.toml` に書き、API key をユーザースコープの `auth.json` に保存します。 +`devo onboard` が推奨されるセットアップ方法です。provider Connection と model +directory を `providers.json` に書き、API key をユーザースコープの `auth.json` に保存します。 自分の API key とカスタムモデルを手動で設定する場合: -1. `config.toml` で `[model.]` パラメータ、`[providers.]`、 - `[model_bindings.]` を定義します。 +1. `providers.json` で `provider..models.` を定義し、既定 model を + `provider/model` に設定します。 2. シークレットを `DEVO_HOME/auth.json` に置き、`[providers.].credential` - からその credential id を参照します — API key 自体を `config.toml` に + からその credential id を参照します — API key 自体を `providers.json` に 書かないでください。 -3. エンドポイントのプロトコルに合わせて `invocation_method` を +3. エンドポイントのプロトコルに合わせて `wire_api` を `openai_chat_completions`、`openai_responses`、`anthropic_messages` の いずれかに設定します。 @@ -241,12 +241,12 @@ Devo は pre-1.0 で、活発に開発されています。ローカル評価、 組み込みモデルメタデータは現在、Qwen、Kimi、MiniMax、GLM、DeepSeek ファミリーをカバーしています。 OpenAI 互換 Chat Completions、OpenAI 互換 Responses、または Anthropic Messages API をサポートするモデルエンドポイントであれば、 -provider/model binding を通じて接続できます。 +provider/model Connection を通じて接続できます。 ### 自分の API key を使うには? `devo onboard` を使うか、ユーザースコープの `auth.json` を編集し、 -`config.toml` の `[providers.].credential` からその credential id を参照します。 +`providers.json` の `provider..credential` からその credential id を参照します。 詳細は [設定](./docs/configuration.ja.md#自分の-api-key-を使う) を参照してください。 ### Desktop app と TUI/CLI のどちらを使うべきですか? diff --git a/README.md b/README.md index ebe1c040..61411f39 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ runtime behavior, and workspace execution under your control. - **Bring your own model** - Connect OpenAI-compatible Chat Completions, OpenAI-compatible Responses, Anthropic Messages, DeepSeek, Qwen, Kimi, or - private model gateways through provider/model bindings. + private model gateways through provider/model Connections. - **Works in private and intranet environments** - Run a single local Rust binary, support offline installation paths, and point Devo at internal endpoints without depending on a hosted agent service. @@ -55,7 +55,7 @@ runtime behavior, and workspace execution under your control. ## Features -- **Model-neutral provider runtime** - Use provider/model bindings for +- **Model-neutral provider runtime** - Use provider/model Connections for OpenAI-compatible, Anthropic-compatible, DeepSeek, Qwen, Kimi, GLM, MiniMax, Xiaomi MiMo, OpenRouter, or local endpoints. - **MCP support** - Connect external tools and context through @@ -98,7 +98,7 @@ runtime behavior, and workspace execution under your control. Devo's built-in model catalog includes tested model definitions for Qwen, Kimi, MiniMax, GLM, and DeepSeek. Provider endpoints remain configurable through -provider/model bindings. +provider/model Connections. ## Tested Platforms @@ -217,17 +217,17 @@ devo resume ## Configuration -`devo onboard` is the recommended setup path. It writes provider and model -bindings to `config.toml` and stores your API key in user-scoped `auth.json`. +`devo onboard` is the recommended setup path. It writes provider Connections +and model directories to `providers.json` and stores your API key in +user-scoped `auth.json`. To bring your own key with a custom model manually: -1. Define `[model.]` parameters, `[providers.]`, and - `[model_bindings.]` in `config.toml`. +1. Define a `provider..models.` entry in `providers.json` and set + the active model as `provider/model`. 2. Put the secret in `DEVO_HOME/auth.json` and reference that credential id from - `[providers.].credential` — never put the API key itself in - `config.toml`. -3. Set `invocation_method` to match the endpoint protocol: + `provider..credential` — never put the API key itself in `providers.json`. +3. Set `wire_api` to match the endpoint protocol: `openai_chat_completions`, `openai_responses`, or `anthropic_messages`. Full worked example (custom model parameters + API key) and protocol details: @@ -258,13 +258,13 @@ change. Built-in model metadata currently covers Qwen, Kimi, MiniMax, GLM, and DeepSeek families. Any model endpoint that supports OpenAI-compatible Chat Completions, OpenAI-compatible Responses, or the Anthropic Messages API can be connected through -provider/model bindings. +provider/model Connections. ### How do I bring my own API key? -Use `devo onboard`, or manually define a custom `[model.]`, provider, and -binding in `config.toml`, store the key in user-scoped `auth.json`, and set -`invocation_method` to `openai_chat_completions`, `openai_responses`, or +Use `devo onboard`, or manually define a custom provider/model in +`providers.json`, store the key in user-scoped `auth.json`, and set +`wire_api` to `openai_chat_completions`, `openai_responses`, or `anthropic_messages`. See [Configuration](./docs/configuration.md#bring-your-own-api-key). diff --git a/README.ru.md b/README.ru.md index 9e19ec9e..1a37d9b9 100644 --- a/README.ru.md +++ b/README.ru.md @@ -39,7 +39,7 @@ Devo предназначен для команд, которым нужен cod экосистемы моделей. Он оставляет Desktop experience, terminal workflow, выбор модели, поведение runtime и выполнение в workspace под вашим контролем. -- **Подключайте свою модель** - Через provider/model bindings можно подключать +- **Подключайте свою модель** - Через provider/model Connections можно подключать OpenAI-compatible Chat Completions, OpenAI-compatible Responses, Anthropic Messages, DeepSeek, Qwen, Kimi или приватные model gateways. - **Подходит для приватных и intranet-сред** - Devo запускается как единый @@ -54,7 +54,7 @@ Devo предназначен для команд, которым нужен cod ## Возможности -- **Модельно-нейтральный provider runtime** - Используйте provider/model bindings +- **Модельно-нейтральный provider runtime** - Используйте provider/model Connections для OpenAI-совместимых, Anthropic-совместимых, DeepSeek, Qwen, Kimi, GLM, MiniMax, Xiaomi MiMo, OpenRouter или локальных endpoint. - **Поддержка MCP** - Подключайте внешние инструменты и контекст через серверы @@ -98,7 +98,7 @@ Devo предназначен для команд, которым нужен cod Встроенный каталог моделей Devo содержит проверенные определения моделей для Qwen, Kimi, MiniMax, GLM и DeepSeek. Endpoint поставщиков остаются настраиваемыми -через provider/model bindings. +через provider/model Connections. ## Проверенные платформы @@ -221,15 +221,15 @@ devo resume ## Конфигурация `devo onboard` - рекомендуемый путь настройки. Он записывает provider и model -bindings в `config.toml` и сохраняет API key в пользовательском `auth.json`. +Connections и каталог моделей в `providers.json`, а API key сохраняет в пользовательском `auth.json`. Чтобы вручную подключить свой ключ и кастомную модель: -1. Определите параметры `[model.]`, `[providers.]` и - `[model_bindings.]` в `config.toml`. +1. Определите `provider..models.` в `providers.json` и задайте модель + по умолчанию в форме `provider/model`. 2. Положите секрет в `DEVO_HOME/auth.json` и ссылайтесь на этот credential id из - `[providers.].credential` — не пишите сам API key в `config.toml`. -3. Установите `invocation_method` в соответствии с протоколом endpoint: + `provider..credential` — не пишите сам API key в `providers.json`. +3. Установите `wire_api` в соответствии с протоколом endpoint: `openai_chat_completions`, `openai_responses` или `anthropic_messages`. Полный пример (параметры кастомной модели + API key) и описание протоколов: @@ -253,12 +253,12 @@ Devo находится на стадии pre-1.0 и активно развив Встроенные метаданные моделей сейчас покрывают семейства Qwen, Kimi, MiniMax, GLM и DeepSeek. Любой endpoint модели, который поддерживает OpenAI-compatible Chat Completions, OpenAI-compatible Responses или Anthropic Messages API, можно -подключить через provider/model bindings. +подключить через provider/model Connections. ### Как подключить свой API key? Используйте `devo onboard` или отредактируйте пользовательский `auth.json` и -укажите этот credential id в `[providers.].credential` в `config.toml`. См. +укажите этот credential id в `provider..credential` в `providers.json`. См. [Конфигурацию](./docs/configuration.ru.md#свой-api-key). ### Что выбрать: Desktop app или TUI/CLI? diff --git a/README.zh-Hans.md b/README.zh-Hans.md index 0962f7c5..a3f15187 100644 --- a/README.zh-Hans.md +++ b/README.zh-Hans.md @@ -38,7 +38,7 @@ Devo 面向那些不想绑定单一托管模型生态、需要掌控模型选择、运行时行为和 Desktop 体验、终端工作流以及工作区执行边界的团队。 -- **接入任意模型** - 通过 provider/model 绑定接入 OpenAI 兼容 Chat +- **接入任意模型** - 通过 provider/model Connection 接入 OpenAI 兼容 Chat Completions、OpenAI 兼容 Responses、Anthropic Messages、DeepSeek、 Qwen、Kimi 或私有模型网关。 - **适合私有化和内网环境** - 以单一本地 Rust 二进制运行,支持离线安装路径, @@ -50,7 +50,7 @@ Desktop 体验、终端工作流以及工作区执行边界的团队。 ## 功能 -- **模型中立的 provider runtime** - 通过 provider/model 绑定接入 OpenAI 兼容、 +- **模型中立的 provider runtime** - 通过 provider/model Connection 接入 OpenAI 兼容、 Anthropic 兼容、DeepSeek、Qwen、Kimi、GLM、MiniMax、Xiaomi MiMo、 OpenRouter 或本地端点。 - **MCP 支持** - 通过 @@ -85,7 +85,7 @@ Desktop 体验、终端工作流以及工作区执行边界的团队。

Devo 的内置模型目录包含 Qwen、Kimi、MiniMax、GLM 和 DeepSeek 的已测试模型定义。 -Provider 端点仍可通过 provider/model 绑定配置。 +Provider 端点仍可通过 provider/model Connection 配置。 ## 已测试平台 @@ -199,16 +199,16 @@ devo resume ## 配置 -`devo onboard` 是推荐的设置路径。它会把 provider 和 model binding 写入 -`config.toml`,并把 API key 保存在用户级 `auth.json`。 +`devo onboard` 是推荐的设置路径。它会把 provider Connection 和模型目录写入 +`providers.json`,并把 API key 保存在用户级 `auth.json`。 如需手动接入自有 API key 与自定义模型: -1. 在 `config.toml` 中定义 `[model.]` 参数、`[providers.]` 和 - `[model_bindings.]`。 +1. 在 `providers.json` 中定义 `provider..models.`,并将默认模型设为 + `provider/model`。 2. 把密钥写入 `DEVO_HOME/auth.json`,并在 `[providers.].credential` 中引用该 - credential id — 不要把 API key 本身写进 `config.toml`。 -3. 将 `invocation_method` 设为与端点协议一致:`openai_chat_completions`、 + credential id — 不要把 API key 本身写进 `providers.json`。 +3. 将 `wire_api` 设为与端点协议一致:`openai_chat_completions`、 `openai_responses` 或 `anthropic_messages`。 完整示例(自定义模型参数 + API key)与协议说明见 @@ -235,12 +235,12 @@ Devo 仍处于 1.0 之前并在积极开发中。它已经适合本地评估、 内置模型元数据目前覆盖 Qwen、Kimi、MiniMax、GLM 和 DeepSeek 系列。 任何支持 OpenAI 兼容 Chat Completions、OpenAI 兼容 Responses 或 -Anthropic Messages API 的模型端点,都可以通过 provider/model 绑定接入。 +Anthropic Messages API 的模型端点,都可以通过 provider/model Connection 接入。 ### 如何接入自有 API key? -使用 `devo onboard`,或在 `config.toml` 中手动定义自定义 `[model.]`、 -provider 与 binding,把 key 存入用户级 `auth.json`,并将 `invocation_method` +使用 `devo onboard`,或在 `providers.json` 中手动定义自定义 provider/model, +把 key 存入用户级 `auth.json`,并将 `wire_api` 设为 `openai_chat_completions`、`openai_responses` 或 `anthropic_messages`。详见 [配置](./docs/configuration.zh-Hans.md#接入自有-api-key)。 diff --git a/README.zh-Hant.md b/README.zh-Hant.md index 244e4d02..9a765a0b 100644 --- a/README.zh-Hant.md +++ b/README.zh-Hant.md @@ -38,7 +38,7 @@ Devo 面向那些不想綁定單一託管模型生態、需要掌控模型選擇、執行階段行為和 Desktop 體驗、終端機工作流以及工作區執行邊界的團隊。 -- **接入任意模型** - 透過 provider/model 綁定接入 OpenAI 相容 Chat +- **接入任意模型** - 透過 provider/model Connection 接入 OpenAI 相容 Chat Completions、OpenAI 相容 Responses、Anthropic Messages、DeepSeek、 Qwen、Kimi 或私有模型閘道。 - **適合私有化和內網環境** - 以單一本地 Rust 二進位檔執行,支援離線安裝路徑, @@ -50,7 +50,7 @@ Desktop 體驗、終端機工作流以及工作區執行邊界的團隊。 ## 功能 -- **模型中立的 provider runtime** - 透過 provider/model 綁定接入 OpenAI 相容、 +- **模型中立的 provider runtime** - 透過 provider/model Connection 接入 OpenAI 相容、 Anthropic 相容、DeepSeek、Qwen、Kimi、GLM、MiniMax、Xiaomi MiMo、 OpenRouter 或本地端點。 - **MCP 支援** - 透過 @@ -85,7 +85,7 @@ Desktop 體驗、終端機工作流以及工作區執行邊界的團隊。

Devo 的內建模型目錄包含 Qwen、Kimi、MiniMax、GLM 和 DeepSeek 的已測試模型定義。 -Provider 端點仍可透過 provider/model 綁定配置。 +Provider 端點仍可透過 provider/model Connection 配置。 ## 已測試平台 @@ -199,16 +199,16 @@ devo resume ## 配置 -`devo onboard` 是推薦的設定路徑。它會把 provider 和 model binding 寫入 -`config.toml`,並把 API key 保存在使用者級 `auth.json`。 +`devo onboard` 是推薦的設定路徑。它會把 provider Connection 和模型目錄寫入 +`providers.json`,並把 API key 保存在使用者級 `auth.json`。 如需手動接入自有 API key 與自訂模型: -1. 在 `config.toml` 中定義 `[model.]` 參數、`[providers.]` 和 - `[model_bindings.]`。 +1. 在 `providers.json` 中定義 `provider..models.`,並將預設模型設為 + `provider/model`。 2. 把密鑰寫入 `DEVO_HOME/auth.json`,並在 `[providers.].credential` 中引用該 - credential id — 不要把 API key 本身寫進 `config.toml`。 -3. 將 `invocation_method` 設為與端點協議一致:`openai_chat_completions`、 + credential id — 不要把 API key 本身寫進 `providers.json`。 +3. 將 `wire_api` 設為與端點協議一致:`openai_chat_completions`、 `openai_responses` 或 `anthropic_messages`。 完整範例(自訂模型參數 + API key)與協議說明見 @@ -230,12 +230,12 @@ Devo 仍處於 1.0 之前並在積極開發中。它已經適合本地評估、 內建模型中繼資料目前覆蓋 Qwen、Kimi、MiniMax、GLM 和 DeepSeek 系列。 任何支援 OpenAI 相容 Chat Completions、OpenAI 相容 Responses 或 -Anthropic Messages API 的模型端點,都可以透過 provider/model 綁定接入。 +Anthropic Messages API 的模型端點,都可以透過 provider/model Connection 接入。 ### 如何接入自有 API key? -使用 `devo onboard`,或在 `config.toml` 中手動定義自訂 `[model.]`、 -provider 與 binding,把 key 存入使用者級 `auth.json`,並將 `invocation_method` +使用 `devo onboard`,或在 `providers.json` 中手動定義自訂 provider/model, +把 key 存入使用者級 `auth.json`,並將 `wire_api` 設為 `openai_chat_completions`、`openai_responses` 或 `anthropic_messages`。詳見 [配置](./docs/configuration.zh-Hant.md#接入自有-api-key)。 diff --git a/apps/desktop/packages/devo-ai-sdk/src/v2/client-compact.test.ts b/apps/desktop/packages/devo-ai-sdk/src/v2/client-compact.test.ts index ddb6ff6b..43e965fe 100644 --- a/apps/desktop/packages/devo-ai-sdk/src/v2/client-compact.test.ts +++ b/apps/desktop/packages/devo-ai-sdk/src/v2/client-compact.test.ts @@ -3,6 +3,7 @@ import { createDevoClient, type DevoNativeTransport, type DevoNativeTransportEve class FakeTransport implements DevoNativeTransport { readonly requests: Array<{ method: string; params: unknown }> = [] + private listeners: Array<(event: DevoNativeTransportEvent) => void> = [] constructor(private readonly handler: (method: string, params: unknown) => unknown) {} @@ -14,13 +15,19 @@ class FakeTransport implements DevoNativeTransport { async respond(): Promise {} subscribe(listener: (event: DevoNativeTransportEvent) => void): () => void { - void listener - return () => {} + this.listeners.push(listener) + return () => { + this.listeners = this.listeners.filter((candidate) => candidate !== listener) + } } connected(): boolean { return true } + + emit(event: DevoNativeTransportEvent): void { + for (const listener of this.listeners) listener(event) + } } describe("session.summarize", () => { @@ -59,4 +66,145 @@ describe("session.summarize", () => { expect(transport.requests.at(-1)?.params).toEqual({ sessionId: "session-1" }) expect(transport.requests.some((request) => request.method === "turn/start")).toBe(false) }) + + test("persists distinct start and complete compaction transcript markers", async () => { + const session = { + id: "session-1", + version: 1, + cwd: "/repo", + title: "Compact session", + parent: null, + createdAt: "2026-08-24T00:00:00Z", + lastActivityAt: "2026-08-24T00:00:00Z", + status: "idle", + flags: [], + archived: false, + ephemeral: false, + model: { provider: "test", model: "test-model" }, + settings: { permissionProfile: "default" }, + preview: "", + queuedCount: 0, + usage: { + total: { + inputTokens: 0, + outputTokens: 0, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + reasoningTokens: 0, + totalTokens: 0, + callCount: 0, + meteredCallCount: 0, + failedCallCount: 0, + cancelledCallCount: 0, + }, + byPurpose: [], + updatedAt: "2026-08-24T00:00:00Z", + }, + } + const transport = new FakeTransport((method) => { + if (method === "initialize") { + return { protocolVersion: 1, agentCapabilities: {}, authMethods: [] } + } + if (method === "session/list") { + return { data: [session], nextCursor: null } + } + if (method === "subscription/create") { + return { subscriptionId: "sub-1", snapshots: [], replay: [], cursors: [] } + } + if (method === "session/resume") { + return { session, lastContextOccupancy: null } + } + if (method === "session/messages/list" || method === "session/items/list") { + return { data: [], nextCursor: null } + } + if (method === "session/queue/list") { + return { entries: [] } + } + if (method === "context/usage/read") { + return { occupancy: null } + } + throw new Error(`unexpected method ${method}`) + }) + + const client = createDevoClient({ directory: "/repo", transport }) + await client.event.subscribe() + + transport.emit({ + type: "notification", + method: "context/compactionStarted", + params: { + sessionId: "session-1", + turnId: "turn-compact", + trigger: "manual", + }, + }) + transport.emit({ + type: "notification", + method: "item/started", + params: { + item: { + id: "item-compact-1", + sessionId: "session-1", + turnId: "turn-compact", + seq: 1, + revision: 1, + createdAt: "2026-08-24T00:00:00.000Z", + updatedAt: "2026-08-24T00:00:00.000Z", + state: "running", + item: { + type: "contextCompaction", + trigger: "manual", + summary: "Compaction started", + }, + }, + }, + }) + transport.emit({ + type: "notification", + method: "item/completed", + params: { + item: { + id: "item-compact-1", + sessionId: "session-1", + turnId: "turn-compact", + seq: 1, + revision: 2, + createdAt: "2026-08-24T00:00:00.000Z", + updatedAt: "2026-08-24T00:00:02.000Z", + state: "completed", + item: { + type: "contextCompaction", + trigger: "manual", + summary: "Context compacted", + }, + }, + }, + }) + + const { data } = await client.session.messages({ sessionID: "session-1" }) + const texts = data.flatMap((entry) => + entry.parts + .filter((part) => part.type === "text") + .map((part) => ({ + id: entry.info.id, + text: part.text, + status: (part as { metadata?: Record }).metadata?.[ + "devo/compactionStatus" + ], + })), + ) + + expect(texts).toEqual([ + { + id: "compaction-item-compact-1-started", + text: "Compacting context", + status: "started", + }, + { + id: "compaction-item-compact-1-completed", + text: "Context compacted", + status: "completed", + }, + ]) + }) }) diff --git a/apps/desktop/packages/devo-ai-sdk/src/v2/client-config-options.test.ts b/apps/desktop/packages/devo-ai-sdk/src/v2/client-config-options.test.ts index ac977323..ebba6610 100644 --- a/apps/desktop/packages/devo-ai-sdk/src/v2/client-config-options.test.ts +++ b/apps/desktop/packages/devo-ai-sdk/src/v2/client-config-options.test.ts @@ -2,9 +2,10 @@ import { describe, expect, test } from "bun:test" import { createDevoClient, type DevoNativeTransport, type DevoNativeTransportEvent } from "./client" import type { SessionConfigOption } from "./native-client-support" import type { + ProviderInfo, + ProviderModelInfo, + ProviderUpsertParams, ProviderValidateParams, - ProviderVendor, - ProviderVendorUpsertParams, } from "./generated/native" class FakeTransport implements DevoNativeTransport { @@ -90,52 +91,31 @@ const configOptions = [ }, ] satisfies SessionConfigOption[] -const providerVendor = { - name: "openai", - base_url: "https://api.openai.com/v1", - credential: "openai_api_key", - headers: null, - wire_apis: ["openai_chat_completions"], - enabled: true, -} satisfies ProviderVendor - -const canonicalProviderVendor = { +const provider = { + id: "openai", name: "openai", baseUrl: "https://api.openai.com/v1", credential: "openai_api_key", wireApis: ["openai_chat_completions"], + models: { + "gpt-4o": { + name: "GPT-4o", + wireApi: "openai_chat_completions", + } satisfies ProviderModelInfo, + }, enabled: true, -} - -const canonicalModelBinding = { - bindingId: "openai-gpt-4o", - modelSlug: "gpt-4o", - provider: "openai", - requestModel: "gpt-4o", - displayName: "GPT-4o", - invocationMethod: "openai_chat_completions", - enabled: true, -} +} satisfies ProviderInfo const providerValidateParams = { - provider_vendor: providerVendor, - model_binding: { - binding_id: "openai-gpt-4o", - model_slug: "gpt-4o", - provider: "openai", - request_model: "gpt-4o", - display_name: "GPT-4o", - invocation_method: "openai_chat_completions", - default_reasoning_effort: null, - enabled: true, - }, - api_key: "secret", + provider, + model: "gpt-4o", + apiKey: "secret", } satisfies ProviderValidateParams const providerUpsertParams = { ...providerValidateParams, - default_model_binding: "openai-gpt-4o", -} satisfies ProviderVendorUpsertParams + defaultModel: "openai/gpt-4o", +} satisfies ProviderUpsertParams describe("Native desktop SDK config option cache", () => { test("loads cold-start config options from model/preferences/read when no session cache exists", async () => { @@ -195,11 +175,17 @@ describe("Native desktop SDK config option cache", () => { ]) }) - test("lists provider vendors through the server provider API", async () => { + test("lists provider Connections and templates through the server provider API", async () => { const transport = new FakeTransport((method, params) => { + if (method === "initialize") return initializeResult if (method === "provider/list") { expect(params).toEqual({}) - return { providers: [canonicalProviderVendor] } + return { + providers: [provider], + templateProviderIds: [], + connectedProviderIds: ["openai"], + connectionModels: { openai: { "gpt-4o": provider.models["gpt-4o"] } }, + } } throw new Error(`unexpected request ${method}`) }) @@ -207,18 +193,20 @@ describe("Native desktop SDK config option cache", () => { const result = await client.provider.list() - expect(result.data).toEqual({ provider_vendors: [providerVendor] }) - expect(transport.requests.map((request) => request.method)).toEqual(["provider/list"]) + expect(result.data).toEqual({ + providers: [provider], + templateProviderIds: [], + connectedProviderIds: ["openai"], + connectionModels: { openai: { "gpt-4o": provider.models["gpt-4o"] } }, + }) + expect(transport.requests.map((request) => request.method)).toEqual(["initialize", "provider/list"]) }) test("validates provider candidates through the server provider API", async () => { const transport = new FakeTransport((method, params) => { + if (method === "initialize") return initializeResult if (method === "provider/validate") { - expect(params).toEqual({ - providerVendor: canonicalProviderVendor, - modelBinding: canonicalModelBinding, - apiKey: "secret", - }) + expect(params).toEqual(providerValidateParams) return { replyPreview: "OK" } } throw new Error(`unexpected request ${method}`) @@ -227,11 +215,11 @@ describe("Native desktop SDK config option cache", () => { const result = await client.provider.validate(providerValidateParams) - expect(result.data).toEqual({ reply_preview: "OK" }) - expect(transport.requests.map((request) => request.method)).toEqual(["provider/validate"]) + expect(result.data).toEqual({ replyPreview: "OK" }) + expect(transport.requests.map((request) => request.method)).toEqual(["initialize", "provider/validate"]) }) - test("upserts provider vendors and clears cached model config", async () => { + test("upserts a provider Connection and clears cached model config", async () => { let modelPreferencesReadCalls = 0 const updatedPreferences = { model: "openai-gpt-4o", @@ -250,15 +238,10 @@ describe("Native desktop SDK config option cache", () => { } } if (method === "provider/upsert") { - expect(params).toEqual({ - providerVendor: canonicalProviderVendor, - modelBinding: canonicalModelBinding, - defaultModelBinding: "openai-gpt-4o", - apiKey: "secret", - }) + expect(params).toEqual(providerUpsertParams) return { - providerVendor: canonicalProviderVendor, - modelBinding: canonicalModelBinding, + provider, + defaultModel: "openai/gpt-4o", } } throw new Error(`unexpected request ${method}`) diff --git a/apps/desktop/packages/devo-ai-sdk/src/v2/client-native-interactions.test.ts b/apps/desktop/packages/devo-ai-sdk/src/v2/client-native-interactions.test.ts index 8d333c70..88450c67 100644 --- a/apps/desktop/packages/devo-ai-sdk/src/v2/client-native-interactions.test.ts +++ b/apps/desktop/packages/devo-ai-sdk/src/v2/client-native-interactions.test.ts @@ -1139,6 +1139,83 @@ describe("Native desktop SDK interactions", () => { expect((await client.session.status()).data["session-1"]).toEqual({ type: "idle" }) }) + test("turn/completed with turn.error emits session.error for Desktop UI", async () => { + const transport = new FakeNativeTransport() + const client = createDevoClient({ directory: "/repo", transport }) + const stream = (await client.global.event()).stream[Symbol.asyncIterator]() + await client.session.create() + + await client.session.promptAsync({ + sessionID: "session-1", + parts: [{ type: "text", text: "hello" }], + }) + + transport.emit({ + type: "notification", + method: "item/assistantMessage/delta", + params: { + sessionId: nativeSession.id, + itemId: "item-assistant-1", + delta: "partial", + }, + }) + await nextPayloadOfType(stream, "message.part.updated") + + transport.emit({ + type: "notification", + method: "turn/completed", + params: { + turn: { + ...nativeTurnInProgress, + status: "failed", + completedAt: "2026-08-24T00:00:08Z", + error: { + errorCode: "PROVIDER_TEMPORARY_FAILURE", + message: "HTTP 429: rate limit exceeded", + retryable: true, + }, + }, + }, + }) + + const errorEvent = await nextPayloadOfType(stream, "session.error") + expect(errorEvent.properties).toEqual({ + sessionID: nativeSession.id, + error: { + name: "PROVIDER_TEMPORARY_FAILURE", + data: { + message: "HTTP 429: rate limit exceeded", + code: "PROVIDER_TEMPORARY_FAILURE", + }, + }, + }) + + const updated = await nextPayloadOfType(stream, "message.updated") + expect(updated.properties.info.role).toBe("assistant") + expect(updated.properties.info.error).toEqual({ + name: "PROVIDER_TEMPORARY_FAILURE", + data: { + message: "HTTP 429: rate limit exceeded", + code: "PROVIDER_TEMPORARY_FAILURE", + }, + }) + expect(updated.properties.info.time?.completed).toEqual(expect.any(Number)) + + // Follow-up completed projection without error must not wipe the failure. + transport.emit({ + type: "notification", + method: "turn/completed", + params: { + turn: { + ...nativeTurnInProgress, + status: "failed", + completedAt: "2026-08-24T00:00:08Z", + }, + }, + }) + expect((await client.session.status()).data["session-1"]).toEqual({ type: "idle" }) + }) + test("queues follow-up input without forcing idle when a turn is already active", async () => { const transport = new FakeNativeTransport() const client = createDevoClient({ directory: "/repo", transport }) @@ -1248,6 +1325,41 @@ describe("Native desktop SDK interactions", () => { }) }) + test("projects last-query display total from turn/usage/updated", async () => { + const transport = new FakeNativeTransport() + const client = createDevoClient({ directory: "/repo", transport }) + const stream = (await client.global.event()).stream[Symbol.asyncIterator]() + + transport.emit({ + type: "notification", + method: "turn/usage/updated", + params: { + sessionId: nativeSession.id, + turnId: "turn-1", + usage: { + query: { + totalTokens: 48_000, + inputTokens: 40_000, + outputTokens: 8_000, + }, + overhead: { totalTokens: 0 }, + }, + lastQueryInputTokens: 40_000, + contextWindow: 190_000, + }, + }) + + expect(await nextPayloadOfType(stream, "session.usage.updated")).toEqual({ + type: "session.usage.updated", + properties: { + sessionID: nativeSession.id, + used: 48_000, + size: 190_000, + cost: 0, + }, + }) + }) + test("reads context occupancy through context/usage/read", async () => { const transport = new FakeNativeTransport() const client = createDevoClient({ directory: "/repo", transport }) diff --git a/apps/desktop/packages/devo-ai-sdk/src/v2/client-session-not-found.test.ts b/apps/desktop/packages/devo-ai-sdk/src/v2/client-session-not-found.test.ts new file mode 100644 index 00000000..8a86f987 --- /dev/null +++ b/apps/desktop/packages/devo-ai-sdk/src/v2/client-session-not-found.test.ts @@ -0,0 +1,184 @@ +import { describe, expect, test } from "bun:test" +import { + createDevoClient, + isSessionNotFoundError, + type DevoNativeTransport, + type DevoNativeTransportEvent, +} from "./client" + +class FakeTransport implements DevoNativeTransport { + readonly requests: Array<{ method: string; params: unknown }> = [] + + constructor(private readonly handler: (method: string, params: unknown) => unknown) {} + + async request(method: string, params?: unknown): Promise { + this.requests.push({ method, params }) + return this.handler(method, params) + } + + async respond(): Promise {} + + subscribe(_listener: (event: DevoNativeTransportEvent) => void): () => void { + return () => {} + } + + connected(): boolean { + return true + } +} + +const nativeSession = { + id: "missing-session", + version: 1, + cwd: "/repo", + title: "Missing", + parent: null, + createdAt: "2026-01-01T00:00:00.000Z", + lastActivityAt: "2026-01-01T00:00:00.000Z", + status: "idle", + flags: [], + archived: false, + ephemeral: false, + model: { provider: "test", model: "test-model" }, + settings: { permissionProfile: "default" }, + preview: "", + queuedCount: 0, + usage: { + total: { + inputTokens: 0, + outputTokens: 0, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + reasoningTokens: 0, + totalTokens: 0, + callCount: 0, + meteredCallCount: 0, + failedCallCount: 0, + cancelledCallCount: 0, + }, + byPurpose: [], + updatedAt: "2026-01-01T00:00:00.000Z", + }, +} + +describe("isSessionNotFoundError", () => { + test("matches server message and SessionNotFound code", () => { + expect(isSessionNotFoundError(new Error("session does not exist"))).toBe(true) + const coded = new Error("gone") as Error & { code?: string } + coded.code = "SessionNotFound" + expect(isSessionNotFoundError(coded)).toBe(true) + expect(isSessionNotFoundError(new Error("timeout"))).toBe(false) + }) +}) + +describe("session.messages soft-handles missing sessions", () => { + test("returns empty messages and emits session.deleted when resume fails", async () => { + const transport = new FakeTransport((method) => { + if (method === "initialize") { + return { protocolVersion: 1, agentCapabilities: {}, authMethods: [] } + } + if (method === "session/list") { + return { data: [nativeSession], nextOffset: null } + } + if (method === "subscription/create") { + return { subscriptionId: "sub-1", snapshots: [], replay: [], cursors: [] } + } + if (method === "session/resume") { + const error = new Error("session does not exist") as Error & { code?: string } + error.code = "SessionNotFound" + throw error + } + throw new Error(`unexpected method ${method}`) + }) + + const client = createDevoClient({ directory: "/repo", transport }) + const deletedIds: string[] = [] + const subscription = await client.event.subscribe() + const consumer = (async () => { + for await (const globalEvent of subscription.stream) { + if (globalEvent.payload?.type === "session.deleted") { + deletedIds.push(String(globalEvent.payload.properties?.info?.id ?? "")) + break + } + } + })() + + const result = await client.session.messages({ sessionID: "missing-session" }) + expect(result.data).toEqual([]) + await consumer + expect(deletedIds).toEqual(["missing-session"]) + expect(transport.requests.some((request) => request.method === "session/resume")).toBe(true) + }) +}) + +describe("session.queue.list resumes cold historical sessions", () => { + test("loads via session/resume before queue/list", async () => { + let resumed = false + const transport = new FakeTransport((method) => { + if (method === "initialize") { + return { protocolVersion: 1, agentCapabilities: {}, authMethods: [] } + } + if (method === "session/list") { + return { data: [nativeSession], nextOffset: null } + } + if (method === "session/resume") { + resumed = true + return { session: nativeSession } + } + if (method === "session/items/list") { + return { data: [], nextCursor: null } + } + if (method === "session/queue/list") { + if (!resumed) { + const error = new Error("session does not exist") as Error & { code?: string } + error.code = "SessionNotFound" + throw error + } + return { + entries: [ + { + queueItemId: "q1", + position: 0, + preview: "hello", + input: [{ type: "text", text: "hello" }], + enqueuedAt: "2026-01-01T00:00:00.000Z", + }, + ], + } + } + if (method === "subscription/create") { + return { subscriptionId: "sub-1", snapshots: [], replay: [], cursors: [] } + } + throw new Error(`unexpected method ${method}`) + }) + + const client = createDevoClient({ directory: "/repo", transport }) + const result = await client.session.queue.list({ sessionID: "missing-session" }) + expect(resumed).toBe(true) + expect(result.data.entries).toHaveLength(1) + expect(result.data.entries[0]?.queueItemId).toBe("q1") + const methods = transport.requests.map((request) => request.method) + expect(methods.indexOf("session/resume")).toBeLessThan(methods.indexOf("session/queue/list")) + }) + + test("returns empty entries when the session is truly gone", async () => { + const transport = new FakeTransport((method) => { + if (method === "initialize") { + return { protocolVersion: 1, agentCapabilities: {}, authMethods: [] } + } + if (method === "session/list") { + return { data: [nativeSession], nextOffset: null } + } + if (method === "session/resume") { + const error = new Error("session does not exist") as Error & { code?: string } + error.code = "SessionNotFound" + throw error + } + throw new Error(`unexpected method ${method}`) + }) + + const client = createDevoClient({ directory: "/repo", transport }) + const result = await client.session.queue.list({ sessionID: "missing-session" }) + expect(result.data.entries).toEqual([]) + }) +}) diff --git a/apps/desktop/packages/devo-ai-sdk/src/v2/client.ts b/apps/desktop/packages/devo-ai-sdk/src/v2/client.ts index f805fe8f..06f1efac 100644 --- a/apps/desktop/packages/devo-ai-sdk/src/v2/client.ts +++ b/apps/desktop/packages/devo-ai-sdk/src/v2/client.ts @@ -17,11 +17,21 @@ import { toolPartFromUpdate, } from "./native-client-support" import type { + ProviderDisconnectParams, + ProviderDisconnectResult, + ProviderDiscoverParams, + ProviderDiscoverResult, + ProviderInfo, + ProviderListResult, + ProviderModelInfo, + ProviderModelRemoveParams, + ProviderModelRemoveResult, + ProviderModelVariant, + ProviderUpsertParams, + ProviderUpsertResult, ProviderValidateParams, ProviderValidateResult, - ProviderVendorListResult, - ProviderVendorUpsertParams, - ProviderVendorUpsertResult, + InputModality, InputItem, TurnStartResult, WorkspaceChangeCoverage, @@ -163,14 +173,25 @@ export type ToolStateCompleted = any export type UserMessage = any export type Worktree = any export type { - ProviderModelBinding, + ProviderDisconnectParams, + ProviderDisconnectResult, + ProviderDiscoverParams, + ProviderDiscoverResult, + ProviderInfo, + ProviderListResult, + ProviderModelInfo, + ProviderModelRemoveParams, + ProviderModelRemoveResult, + ProviderModelVariant, + ProviderUpsertParams, + ProviderUpsertResult, ProviderValidateParams, ProviderValidateResult, - ProviderVendor, - ProviderVendorListResult, - ProviderVendorUpsertParams, - ProviderVendorUpsertResult, ProviderWireApi, + InputModality, + ReasoningCapability, + ReasoningEffort, + ReasoningLevelChoice, WorkspaceChangeAttribution, WorkspaceChangeBase, WorkspaceChangeCoverage, @@ -187,6 +208,24 @@ export type { WorkspaceDiffDetail, } from "./generated/native" +// ── Canonical provider/model catalog types (L2-DES-MODEL-002) ── + +/** Canonical provider/model types generated from the Native protocol schema. */ +export type CatalogWireApi = ProviderWireApi +export type CatalogModelVariant = ProviderModelVariant +export type CatalogModelInfo = ProviderModelInfo +export type CatalogProviderInfo = ProviderInfo +export type ProviderCatalogListResult = ProviderListResult +export type CatalogProviderUpsertParams = ProviderUpsertParams +export type CatalogProviderUpsertResult = ProviderUpsertResult +export type CatalogProviderDisconnectParams = ProviderDisconnectParams +export type CatalogProviderDisconnectResult = ProviderDisconnectResult +export type CatalogProviderModelRemoveParams = ProviderModelRemoveParams +export type CatalogProviderModelRemoveResult = ProviderModelRemoveResult +export type CatalogProviderValidateParams = ProviderValidateParams +export type CatalogProviderValidateResult = ProviderValidateResult +export type CatalogProviderDiscoverParams = ProviderDiscoverParams +export type CatalogProviderDiscoverResult = ProviderDiscoverResult export type WorkspaceChangesReadOptions = { sessionID: string cwd?: string @@ -579,58 +618,6 @@ function workspaceChangeStats(value: unknown): WorkspaceChangeStats { } } -// ── Canonical provider conversions (ratified #11) ── - -function canonicalProviderVendorWire(vendor: ProviderVendor): Record { - return { - name: vendor.name, - ...(vendor.base_url != null ? { baseUrl: vendor.base_url } : {}), - ...(vendor.credential != null ? { credential: vendor.credential } : {}), - ...(vendor.headers != null ? { headers: vendor.headers } : {}), - wireApis: vendor.wire_apis, - enabled: vendor.enabled, - } -} - -function canonicalModelBindingWire(binding: ProviderModelBinding): Record { - return { - bindingId: binding.binding_id, - modelSlug: binding.model_slug, - provider: binding.provider, - requestModel: binding.request_model, - ...(binding.display_name != null ? { displayName: binding.display_name } : {}), - invocationMethod: binding.invocation_method, - ...(binding.default_reasoning_effort != null - ? { defaultReasoningEffort: binding.default_reasoning_effort } - : {}), - enabled: binding.enabled, - } -} - -function legacyProviderVendorFromCanonical(vendor: Record): ProviderVendor { - return { - name: String(vendor.name ?? ""), - base_url: (vendor.baseUrl as string | null) ?? null, - credential: (vendor.credential as string | null) ?? null, - headers: (vendor.headers as string | null) ?? null, - wire_apis: (vendor.wireApis ?? []) as ProviderVendor["wire_apis"], - enabled: Boolean(vendor.enabled), - } -} - -function legacyModelBindingFromCanonical(binding: Record): ProviderModelBinding { - return { - binding_id: String(binding.bindingId ?? ""), - model_slug: String(binding.modelSlug ?? ""), - provider: String(binding.provider ?? ""), - request_model: String(binding.requestModel ?? ""), - display_name: (binding.displayName as string | null) ?? null, - invocation_method: binding.invocationMethod as ProviderModelBinding["invocation_method"], - default_reasoning_effort: (binding.defaultReasoningEffort as string | null) ?? null, - enabled: Boolean(binding.enabled), - } -} - /** Canonical `model/preferences` wire shape (ratified #12). */ type PreferencesOptionWire = { value: string @@ -942,6 +929,39 @@ function errorRecord(error: unknown): Record | undefined { } } +/** Map a native turn failure onto the Desktop session/assistant error shape. */ +function assistantErrorFromTurnFailure( + turnStatus: string, + turnError: Record | undefined, +): { name: string; data: Record } | undefined { + const message = + typeof turnError?.message === "string" && turnError.message.trim() + ? turnError.message.trim() + : undefined + if (!message) { + // Follow-up `turn/completed` after TurnFailed has status failed but no + // error payload — do not invent a generic message that would clobber UI. + return undefined + } + const code = + typeof turnError?.errorCode === "string" + ? turnError.errorCode + : typeof turnError?.error_code === "string" + ? turnError.error_code + : turnStatus === "failed" + ? "TurnFailed" + : "Error" + const details = objectRecord(turnError?.details) + return { + name: code, + data: { + message, + ...(code !== "Error" ? { code } : {}), + ...(details ?? {}), + }, + } +} + function settingsErrorCode(error: unknown): string | undefined { const record = errorRecord(error) if (typeof record?.code === "string") return record.code @@ -956,6 +976,24 @@ function settingsErrorCode(error: unknown): string | undefined { return undefined } +/** True when the Native server reports the session is gone / never existed. */ +export function isSessionNotFoundError(error: unknown): boolean { + const code = settingsErrorCode(error) + const normalizedCode = code?.replace(/([a-z0-9])([A-Z])/g, "$1_$2").toLowerCase() + if (normalizedCode === "session_not_found") return true + const record = errorRecord(error) + if (typeof record?.code === "string") { + const recordCode = record.code.replace(/([a-z0-9])([A-Z])/g, "$1_$2").toLowerCase() + if (recordCode === "session_not_found") return true + } + const message = error instanceof Error ? error.message : String(error) + return ( + /session does not exist/i.test(message) || + /^session .+ not found$/i.test(message) || + /session id is not addressable by this server/i.test(message) + ) +} + function isTransientSessionSettingsError(error: unknown): boolean { const record = errorRecord(error) const code = settingsErrorCode(error) @@ -1111,6 +1149,8 @@ class NativeClient { private sessionSettingsQueues = new Map() private lastEventTime = 0 private referenceSearchSession: ReferenceSearchSession | null = null + /** >0 while applying subscription create replay — must not bump sidebar sort keys. */ + private subscriptionReplayDepth = 0 constructor(private readonly options: CreateDevoClientOptions) {} project = { @@ -1169,13 +1209,24 @@ class NativeClient { }, queue: { list: async (params: { sessionID: string }) => { - await this.ensureSessionSubscription(params.sessionID) - const result = (await this.requestCanonical("session/queue/list", { - sessionId: params.sessionID, - })) as { entries?: unknown } - const entries = parseQueueWireEntries(result.entries) - this.emitQueueSnapshot(params.sessionID, entries, "sync") - return { data: { entries } } + // Historical sessions show up in session/list but are not + // addressable until session/resume. Composer refresh races + // message load on open, so wait for load before queue/list. + try { + await this.loadSession(params.sessionID) + const result = (await this.requestCanonical("session/queue/list", { + sessionId: params.sessionID, + })) as { entries?: unknown } + const entries = parseQueueWireEntries(result.entries) + this.emitQueueSnapshot(params.sessionID, entries, "sync") + return { data: { entries } } + } catch (error) { + if (isSessionNotFoundError(error)) { + this.emitQueueSnapshot(params.sessionID, [], "sync") + return { data: { entries: [] } } + } + throw error + } }, push: async (params: { sessionID: string @@ -1312,17 +1363,25 @@ class NativeClient { data: await this.getSessionById(params.sessionID), }), diff: async (params: { sessionID: string }) => { - const result = (await this.requestCanonical("workspace/changes/read", { - sessionId: params.sessionID, - scopes: ["uncommitted"], - diffDetail: "full", - maxDiffBytes: 2_000_000, - })) as { views?: Array> } - return { - data: (result.views ?? []) - .map((view) => view.unifiedDiff) - .filter((diff): diff is string => typeof diff === "string" && diff.length > 0) - .map((diff) => ({ diff })), + try { + const result = (await this.requestCanonical("workspace/changes/read", { + sessionId: params.sessionID, + scopes: ["uncommitted"], + diffDetail: "full", + maxDiffBytes: 2_000_000, + })) as { views?: Array> } + return { + data: (result.views ?? []) + .map((view) => view.unifiedDiff) + .filter((diff): diff is string => typeof diff === "string" && diff.length > 0) + .map((diff) => ({ diff })), + } + } catch (error) { + if (isSessionNotFoundError(error)) { + this.dropMissingSession(params.sessionID) + return { data: [] } + } + throw error } }, revert: async (params: { sessionID: string }) => ({ @@ -1498,7 +1557,13 @@ class NativeClient { const canonical = (await this.requestCanonical( "workspace/changes/read", wireParams, - )) as { views?: Array> } + ).catch((error) => { + if (isSessionNotFoundError(error)) { + this.dropMissingSession(params.sessionID) + return { views: [] } + } + throw error + })) as { views?: Array> } const data: WorkspaceChangesReadResult = { views: (canonical.views ?? []).map(legacyWorkspaceChangeViewFromCanonical), } @@ -1527,10 +1592,18 @@ class NativeClient { goal = { status: async (params: { sessionID: string }) => { - const result = (await this.requestCanonical("session/goal/read", { - sessionId: params.sessionID, - })) as { goal?: unknown } - return { data: result.goal } + try { + const result = (await this.requestCanonical("session/goal/read", { + sessionId: params.sessionID, + })) as { goal?: unknown } + return { data: result.goal } + } catch (error) { + if (isSessionNotFoundError(error)) { + this.dropMissingSession(params.sessionID) + return { data: null } + } + throw error + } }, pause: async (params: { sessionID: string }) => { const result = (await this.canonicalGoalTransition( @@ -1649,68 +1722,41 @@ class NativeClient { provider = { list: async () => { - // Canonical provider/list (ratified #11): camelCase wire; vendors - // convert back to the generated snake shape for callers. - const result = (await this.requestCanonical("provider/list", {})) as { - providers?: Array> - } - const data: ProviderVendorListResult = { - provider_vendors: (result.providers ?? []).map(legacyProviderVendorFromCanonical), - } + const data = (await this.requestCanonical("provider/list", {})) as ProviderListResult return { data } }, validate: async (params: ProviderValidateParams) => { - const result = (await this.requestCanonical("provider/validate", { - providerVendor: canonicalProviderVendorWire(params.provider_vendor), - modelBinding: canonicalModelBindingWire(params.model_binding), - ...(params.api_key !== undefined && params.api_key !== null - ? { apiKey: params.api_key } - : {}), - })) as { replyPreview?: string } - const data: ProviderValidateResult = { reply_preview: result.replyPreview ?? "" } + const data = (await this.requestCanonical("provider/validate", params)) as ProviderValidateResult return { data } }, - upsert: async (params: ProviderVendorUpsertParams) => { - const result = (await this.requestCanonical("provider/upsert", { - providerVendor: canonicalProviderVendorWire(params.provider_vendor), - ...(params.model_binding - ? { modelBinding: canonicalModelBindingWire(params.model_binding) } - : {}), - ...(params.default_model_binding !== undefined && params.default_model_binding !== null - ? { defaultModelBinding: params.default_model_binding } - : {}), - ...(params.api_key !== undefined && params.api_key !== null - ? { apiKey: params.api_key } - : {}), - })) as { - providerVendor?: Record - modelBinding?: Record - } - const data: ProviderVendorUpsertResult = { - provider_vendor: legacyProviderVendorFromCanonical(result.providerVendor ?? {}), - ...(result.modelBinding - ? { model_binding: legacyModelBindingFromCanonical(result.modelBinding) } - : {}), - } as ProviderVendorUpsertResult + upsert: async (params: ProviderUpsertParams) => { + const data = (await this.requestCanonical("provider/upsert", params)) as ProviderUpsertResult this.invalidateConfigOptionCaches() return { data } }, + disconnect: async (params: ProviderDisconnectParams): Promise => { + const result = (await this.requestCanonical("provider/disconnect", params)) as ProviderDisconnectResult + this.invalidateConfigOptionCaches() + return result + }, + modelRemove: async (params: ProviderModelRemoveParams): Promise => { + const result = (await this.requestCanonical("provider/model/remove", params)) as ProviderModelRemoveResult + this.invalidateConfigOptionCaches() + return result + }, + discover: async (params: ProviderDiscoverParams): Promise => { + const result = (await this.requestCanonical("provider/discover", params)) as ProviderDiscoverResult + // Discover mutates the connection model directory; composer selectors + // read model/preferences which must not keep a pre-discover snapshot. + this.invalidateConfigOptionCaches() + return result + }, auth: async () => ({ data: [] }), oauth: { authorize: async (_params: unknown) => ({ data: null }), callback: async (_params: unknown) => ({ data: null }), }, } - - auth = { - set: async (_params: unknown) => ({ data: null }), - remove: async (_params: unknown) => ({ data: null }), - } - - part = { - delete: async (_params: unknown) => ({ data: null }), - } - private async listProjects(): Promise { const sessions = await this.listSessions() const byDirectory = new Map() @@ -1790,7 +1836,12 @@ class NativeClient { return session } private async sessionMessages(sessionId: string, limit?: number): Promise> { - await this.loadSession(sessionId, limit) + try { + await this.loadSession(sessionId, limit) + } catch (error) { + if (isSessionNotFoundError(error)) return [] + throw error + } const messages = recentMessages(this.messages.get(sessionId) ?? [], limit) return messages.map((info) => ({ info, @@ -1811,46 +1862,61 @@ class NativeClient { } } + private dropMissingSession(sessionId: string): void { + const { directory } = this.forgetSession(sessionId) + this.subscriptions.delete(sessionId) + this.sessionSettingsQueues.delete(sessionId) + this.emitSessionDeleted(sessionId, directory) + } + private async loadSessionOnce(sessionId: string, limit?: number): Promise { await this.ensureInitialized() - const session = await this.getSessionById(sessionId) - const cwd = session?.directory ?? this.sessionDirectories.get(sessionId) - if (!cwd) throw new Error(`session ${sessionId} not found`) - const resumed = (await this.requestCanonical("session/resume", { - sessionId, - })) as { session: Record; lastContextOccupancy?: unknown; last_context_occupancy?: unknown } - const enriched = this.rememberNativeSession(resumed.session) - // The resume response carries the authoritative persisted model / - // settings for the session; cold `session/list` snapshots may lack - // them. Surface the enrichment so renderer session stores re-seed the - // composer — without this, the enriched snapshot stays buried in this - // client's internal cache and restored sessions fall back to defaults. - this.emit(enriched.directory ?? cwd, { - type: "session.updated", - properties: { info: enriched, session: enriched }, - }) - this.emitContextUsage( - sessionId, - resumed.lastContextOccupancy ?? resumed.last_context_occupancy, - ) - let cursor: string | undefined - do { - const page = (await this.requestCanonical("session/items/list", { + try { + const session = await this.getSessionById(sessionId) + const cwd = session?.directory ?? this.sessionDirectories.get(sessionId) + if (!cwd) throw new Error(`session ${sessionId} not found`) + const resumed = (await this.requestCanonical("session/resume", { sessionId, - ...(cursor ? { cursor } : {}), - limit: 500, - })) as { data?: Array>; nextCursor?: string | null } - for (const item of page.data ?? []) { - this.handleNativeItemEnvelope(item, nativeItemNotificationMethod(item)) + })) as { session: Record; lastContextOccupancy?: unknown; last_context_occupancy?: unknown } + const enriched = this.rememberNativeSession(resumed.session) + // The resume response carries the authoritative persisted model / + // settings for the session; cold `session/list` snapshots may lack + // them. Surface the enrichment so renderer session stores re-seed the + // composer — without this, the enriched snapshot stays buried in this + // client's internal cache and restored sessions fall back to defaults. + this.emit(enriched.directory ?? cwd, { + type: "session.updated", + properties: { info: enriched, session: enriched }, + }) + this.emitContextUsage( + sessionId, + resumed.lastContextOccupancy ?? resumed.last_context_occupancy, + ) + let cursor: string | undefined + do { + const page = (await this.requestCanonical("session/items/list", { + sessionId, + ...(cursor ? { cursor } : {}), + limit: 500, + })) as { data?: Array>; nextCursor?: string | null } + for (const item of page.data ?? []) { + this.handleNativeItemEnvelope(item, nativeItemNotificationMethod(item)) + } + cursor = page.nextCursor ?? undefined + } while (cursor) + const queueResult = (await this.requestCanonical("session/queue/list", { + sessionId, + })) as { entries?: unknown } + this.emitQueueSnapshot(sessionId, parseQueueWireEntries(queueResult.entries), "sync") + await this.ensureSessionSubscription(sessionId) + this.loadedSessionLimits.set(sessionId, null) + } catch (error) { + if (isSessionNotFoundError(error)) { + this.dropMissingSession(sessionId) + throw error } - cursor = page.nextCursor ?? undefined - } while (cursor) - const queueResult = (await this.requestCanonical("session/queue/list", { - sessionId, - })) as { entries?: unknown } - this.emitQueueSnapshot(sessionId, parseQueueWireEntries(queueResult.entries), "sync") - await this.ensureSessionSubscription(sessionId) - this.loadedSessionLimits.set(sessionId, null) + throw error + } } private async getSessionById(sessionId: string): Promise { @@ -1878,7 +1944,16 @@ class NativeClient { const usage = objectRecord(info.usage) const total = objectRecord(usage?.total) const created = parseTimestampMs(info.createdAt) ?? existing?.time.created ?? Date.now() - const updated = parseTimestampMs(info.lastActivityAt) ?? existing?.time.updated ?? created + const wireActivity = parseTimestampMs(info.lastActivityAt) + const existingActivity = Math.max(existing?.time.lastActivity ?? 0, existing?.time.updated ?? 0) + // Never let resume/snapshot lower a known activity timestamp — that alone + // can reshuffle the sidebar. Prefer the newer of wire vs in-memory. + const updated = + wireActivity != null + ? Math.max(wireActivity, existingActivity) + : existingActivity > 0 + ? existingActivity + : created const parent = objectRecord(info.parent) const forkFromId = typeof info.forkFromId === "string" @@ -1966,8 +2041,12 @@ class NativeClient { * (agentsAtom / sidebar) recompute without a session/list refresh. * Native turn/item traffic does not carry session/metadataUpdated for * activity-only bumps — only title updates do — so the client owns live sync. + * + * Skipped during subscription replay: historical turn/item envelopes must + * not reshuffle the sidebar when the user merely opens a session. */ private touchNativeSessionActivity(sessionId: string, at = Date.now()): void { + if (this.subscriptionReplayDepth > 0) return const session = this.sessions.get(sessionId) if (!session) return const previous = Math.max(session.time.lastActivity ?? 0, session.time.updated ?? 0) @@ -2259,7 +2338,18 @@ class NativeClient { }) const startedAt = this.promptStartedAtBySession.get(sessionId) ?? 0 this.promptStartedAtBySession.delete(sessionId) - this.completeOpenAssistantMessages(sessionId, directory, startedAt) + // Native `TurnFailed` projects as `turn/completed` with `turn.error` + // (often followed by a completed notification without error). Surface + // the payload so Desktop can render session/assistant failure UI. + const turnError = objectRecord(turn?.error) + const assistantError = assistantErrorFromTurnFailure(turnStatus, turnError) + if (assistantError) { + this.emit(directory, { + type: "session.error", + properties: { sessionID: sessionId, error: assistantError }, + }) + } + this.completeOpenAssistantMessages(sessionId, directory, startedAt, assistantError) this.pendingQuestions.forEach((pending, requestId) => { if (pending.sessionId === sessionId) this.pendingQuestions.delete(requestId) }) @@ -2287,6 +2377,36 @@ class NativeClient { } return true } + if ( + method === "context/compactionStarted" || + method === "context/compactionCompleted" || + method === "context/compactionFailed" + ) { + const sessionId = String(value.sessionId ?? "") + if (!sessionId) return true + const directory = this.sessionDirectories.get(sessionId) ?? this.options.directory ?? defaultCwd() + const status = + method === "context/compactionFailed" + ? "failed" + : method === "context/compactionCompleted" + ? "completed" + : "started" + this.emit(directory, { + type: `session.compaction.${status}`, + properties: { sessionID: sessionId }, + }) + // Prefer item/started|completed for durable transcript markers. + // context/compactionStarted has no itemId — only update session atom. + const itemId = String(value.itemId ?? "") + if (itemId && status !== "failed") { + this.upsertCompaction(sessionId, directory, { + itemId, + status, + turnId: String(value.turnId ?? ""), + }) + } + return true + } if (method === "item/assistantMessage/delta" || method === "item/reasoning/delta") { const sessionId = String(value.sessionId ?? "") const itemId = String(value.itemId ?? "") @@ -2354,14 +2474,20 @@ class NativeClient { if (method === "turn/usage/updated" || method === "session/usage/updated") { const sessionId = String(value.sessionId ?? "") const usage = objectRecord(value.usage) ?? {} - const total = objectRecord(usage.total) ?? usage + // Native wire shape matches TUI: usage.query.totalTokens is the + // last-query display total. Fall back to older/flat shapes. + const query = objectRecord(usage.query) ?? objectRecord(usage.total) ?? usage + const used = Number( + query.totalTokens ?? query.total_tokens ?? value.lastQueryTotalTokens ?? 0, + ) + const size = Number(value.contextWindow ?? value.context_window ?? 0) if (sessionId) { this.emit(this.sessionDirectories.get(sessionId) ?? this.options.directory ?? defaultCwd(), { type: "session.usage.updated", properties: { sessionID: sessionId, - used: Number(total.totalTokens ?? value.lastQueryInputTokens ?? 0), - size: Number(value.contextWindow ?? 0), + used, + size, cost: 0, }, }) @@ -2677,32 +2803,46 @@ class NativeClient { private async ensureSessionSubscription(sessionId: string): Promise { if (this.subscriptions.has(sessionId)) return const after = this.subscriptionCursors.get(sessionId) ?? [] - const result = (await this.requestCanonical("subscription/create", { - selectors: [{ kind: "session", sessionId }], - includeSnapshot: true, - after, - })) as { + let result: { subscriptionId: string snapshots?: Array> replay?: Array> cursors?: Array<{ streamId: string; seq: number }> pendingControlRequests?: Array> } + try { + result = (await this.requestCanonical("subscription/create", { + selectors: [{ kind: "session", sessionId }], + includeSnapshot: true, + after, + })) as typeof result + } catch (error) { + if (isSessionNotFoundError(error)) { + this.dropMissingSession(sessionId) + return + } + throw error + } const cursors = result.cursors ?? [] this.subscriptions.set(sessionId, { subscriptionId: result.subscriptionId, cursors }) this.subscriptionCursors.set(sessionId, cursors) for (const snapshot of result.snapshots ?? []) { this.applySubscriptionSessionSnapshot(snapshot) } - for (const envelope of result.replay ?? []) { - const notification = objectRecord(envelope.notification) - if (notification && typeof notification.method === "string") { - this.handleNativeNotification(notification.method, notification.params) + this.subscriptionReplayDepth += 1 + try { + for (const envelope of result.replay ?? []) { + const notification = objectRecord(envelope.notification) + if (notification && typeof notification.method === "string") { + this.handleNativeNotification(notification.method, notification.params) + } } - } - for (const pending of result.pendingControlRequests ?? []) { - const item = objectRecord(pending.item) - if (item) this.handleNativeItemEnvelope(item, "item/started") + for (const pending of result.pendingControlRequests ?? []) { + const item = objectRecord(pending.item) + if (item) this.handleNativeItemEnvelope(item, "item/started") + } + } finally { + this.subscriptionReplayDepth -= 1 } if (cursors.length > 0) { await this.requestCanonical("subscription/ack", { subscriptionId: result.subscriptionId, cursors }) @@ -2711,7 +2851,14 @@ class NativeClient { private async ensureKnownSessionSubscriptions(): Promise { const sessions = await this.listSessions() - for (const session of sessions) await this.ensureSessionSubscription(session.id) + for (const session of sessions) { + try { + await this.ensureSessionSubscription(session.id) + } catch (error) { + if (isSessionNotFoundError(error)) continue + throw error + } + } } private emitPermissionAsked( @@ -3042,15 +3189,30 @@ class NativeClient { }, ): void { if (update.status === "failed") return - const messageId = `compaction-${update.itemId}` + const metaBase = { + [DEVO_ITEM_KIND_META]: "context_compaction", + ...(update.turnId ? { [DEVO_TURN_ID_META]: update.turnId } : {}), + } + // Keep started and completed as distinct transcript markers so both + // remain visible after the lifecycle finishes. + if (update.status === "completed") { + this.replaceText(sessionId, directory, "assistant", { + messageId: `compaction-${update.itemId}-started`, + content: { text: COMPACTION_STARTED_LABEL }, + _meta: { + ...metaBase, + [DEVO_COMPACTION_STATUS_META]: "started", + }, + }) + } + const messageId = `compaction-${update.itemId}-${update.status}` const label = update.status === "completed" ? COMPACTION_COMPLETED_LABEL : COMPACTION_STARTED_LABEL this.replaceText(sessionId, directory, "assistant", { messageId, content: { text: label }, _meta: { - [DEVO_ITEM_KIND_META]: "context_compaction", + ...metaBase, [DEVO_COMPACTION_STATUS_META]: update.status, - ...(update.turnId ? { [DEVO_TURN_ID_META]: update.turnId } : {}), }, }) } @@ -3283,22 +3445,29 @@ class NativeClient { sessionId: string, directory: string, promptStartedAt: number, + error?: { name: string; data: Record }, ): void { const messages = this.messages.get(sessionId) if (!messages) return let completedAt: number | null = null for (let index = 0; index < messages.length; index++) { const message = messages[index] - if (message.role !== "assistant" || message.time.completed != null) continue + if (message.role !== "assistant") continue if (message.time.created < promptStartedAt) continue - completedAt ??= this.nextEventTime() + const needsComplete = message.time.completed == null + const needsError = error != null && message.error == null + if (!needsComplete && !needsError) continue + completedAt ??= message.time.completed ?? this.nextEventTime() const updated = { ...message, time: { ...message.time, completed: completedAt }, + ...(needsError ? { error } : {}), } as Message messages[index] = updated this.emit(directory, { type: "message.updated", properties: { info: updated, message: updated } }) - this.completeInFlightToolParts(sessionId, directory, updated.id, completedAt) + if (needsComplete) { + this.completeInFlightToolParts(sessionId, directory, updated.id, completedAt) + } } } @@ -3553,6 +3722,10 @@ class NativeClient { if (!result.session) throw new Error("session/metadata/update returned no session") return this.rememberNativeSession(result.session) } catch (error) { + if (isSessionNotFoundError(error)) { + this.dropMissingSession(sessionId) + throw error + } const delay = SESSION_SETTINGS_RETRY_DELAYS_MS[retry] if (delay === undefined || !isTransientSessionSettingsError(error)) throw error await waitForSessionSettingsRetry(delay) diff --git a/apps/desktop/packages/devo-ai-sdk/src/v2/native-client-support.ts b/apps/desktop/packages/devo-ai-sdk/src/v2/native-client-support.ts index 8adff505..9804cc06 100644 --- a/apps/desktop/packages/devo-ai-sdk/src/v2/native-client-support.ts +++ b/apps/desktop/packages/devo-ai-sdk/src/v2/native-client-support.ts @@ -58,13 +58,32 @@ export function defaultCwd(): string { let sharedIpcTransport: DevoNativeTransport | null = null +/** Must match `DEVO_NATIVE_IPC_ERROR` in apps/desktop/src/shared/native-ipc-error.ts */ +const DEVO_NATIVE_IPC_ERROR = "$devoNativeError" + +function throwIfNativeIpcError(result: unknown): void { + if (!result || typeof result !== "object") return + const envelope = (result as Record)[DEVO_NATIVE_IPC_ERROR] + if (!envelope || typeof envelope !== "object") return + const record = envelope as Record + const error = new Error( + typeof record.message === "string" ? record.message : "Devo Native request failed", + ) as Error & { code?: string } + if (typeof record.code === "string") error.code = record.code + throw error +} + export function createIpcTransport(): DevoNativeTransport { if (sharedIpcTransport) return sharedIpcTransport const api = globalThis.window?.devo?.native if (!api) throw new Error("window.devo.native is not available") sharedIpcTransport = { - request: (method, params, directory) => api.request({ method, params, directory }), + request: async (method, params, directory) => { + const result = await api.request({ method, params, directory }) + throwIfNativeIpcError(result) + return result + }, notify: (method, params, directory) => api.notify({ method, params, directory }), respond: (id, result) => api.respond({ id, result }), subscribe: (listener) => api.subscribe(listener), diff --git a/apps/desktop/packages/ui/src/components/ai-elements/conversation.tsx b/apps/desktop/packages/ui/src/components/ai-elements/conversation.tsx index 72b75377..4a045aef 100644 --- a/apps/desktop/packages/ui/src/components/ai-elements/conversation.tsx +++ b/apps/desktop/packages/ui/src/components/ai-elements/conversation.tsx @@ -9,13 +9,24 @@ import { StickToBottom, useStickToBottomContext } from "use-stick-to-bottom" export { useStickToBottomContext } -export type ConversationProps = ComponentProps +export type ConversationProps = ComponentProps & { + /** + * While true, use spring resize so continuous stream height growth + * coalesces instead of fighting `instant` scroll every layout tick. + */ + streaming?: boolean +} -export const Conversation = ({ className, ...props }: ConversationProps) => ( +export const Conversation = ({ + className, + streaming = false, + resize, + ...props +}: ConversationProps) => ( diff --git a/apps/desktop/packages/ui/src/components/ai-elements/message.tsx b/apps/desktop/packages/ui/src/components/ai-elements/message.tsx index ca22f314..4322df7a 100644 --- a/apps/desktop/packages/ui/src/components/ai-elements/message.tsx +++ b/apps/desktop/packages/ui/src/components/ai-elements/message.tsx @@ -270,9 +270,17 @@ export const MessageBranchPage = ({ className, ...props }: MessageBranchPageProp ) } -export type MessageResponseProps = ComponentProps +export type MessageResponseProps = Omit, "plugins"> & { + plugins?: ComponentProps["plugins"] + /** + * Live streaming surface: skip Streamdown enter animation and heavy plugins + * (math / mermaid) until the turn is idle. + */ + streaming?: boolean +} const streamdownPlugins = { cjk, code, math, mermaid } +const streamdownPluginsStreaming = { cjk, code } // Product requirement: regular transcript Markdown tables should keep copy and // download controls, but not show a fullscreen control. Code blocks keep copy @@ -325,20 +333,24 @@ const transcriptMarkdownComponents: NonNullable ( + ({ className, streaming = false, animated, plugins, ...props }: MessageResponseProps) => ( *:first-child]:mt-0 [&>*:last-child]:mb-0", + streaming && "devo-message-response--streaming", className, )} + animated={streaming ? false : animated} components={transcriptMarkdownComponents} controls={transcriptMarkdownControls} - plugins={streamdownPlugins} + plugins={plugins ?? (streaming ? streamdownPluginsStreaming : streamdownPlugins)} {...props} /> ), (prevProps, nextProps) => - prevProps.children === nextProps.children && prevProps.animated === nextProps.animated, + prevProps.children === nextProps.children && + prevProps.animated === nextProps.animated && + prevProps.streaming === nextProps.streaming, ) MessageResponse.displayName = "MessageResponse" diff --git a/apps/desktop/packages/ui/src/components/ai-elements/reasoning.tsx b/apps/desktop/packages/ui/src/components/ai-elements/reasoning.tsx index 09af2293..199a2172 100644 --- a/apps/desktop/packages/ui/src/components/ai-elements/reasoning.tsx +++ b/apps/desktop/packages/ui/src/components/ai-elements/reasoning.tsx @@ -191,18 +191,34 @@ export const ReasoningTrigger = memo( export type ReasoningContentProps = ComponentProps & { children: ReactNode animated?: boolean + streaming?: boolean } const streamdownPlugins = { cjk, code, math, mermaid } +const streamdownPluginsStreaming = { cjk, code } -export const ReasoningText = memo(({ children, animated }: { children: string; animated?: boolean }) => ( - - {children} - -)) +export const ReasoningText = memo( + ({ + children, + animated, + streaming = false, + }: { + children: string + animated?: boolean + streaming?: boolean + }) => ( + + {children} + + ), +) export const ReasoningContent = memo( - ({ className, children, animated, ...props }: ReasoningContentProps) => ( + ({ className, children, animated, streaming, ...props }: ReasoningContentProps) => ( {typeof children === "string" ? ( - {children} + + {children} + ) : ( children )} diff --git a/apps/desktop/packages/ui/src/components/command.tsx b/apps/desktop/packages/ui/src/components/command.tsx index 42f9bf65..ebcc6f05 100644 --- a/apps/desktop/packages/ui/src/components/command.tsx +++ b/apps/desktop/packages/ui/src/components/command.tsx @@ -42,7 +42,7 @@ function CommandDialog({ return ( diff --git a/apps/desktop/src/main/canonical-provider-migration.ts b/apps/desktop/src/main/canonical-provider-migration.ts new file mode 100644 index 00000000..0cb6c4a0 --- /dev/null +++ b/apps/desktop/src/main/canonical-provider-migration.ts @@ -0,0 +1,14 @@ +/** Canonical Native provider/upsert payload shared by external config importers. */ +export interface CanonicalProviderUpsertParams { + provider: { + id: string + name: string + baseUrl?: string + wireApis: string[] + models: Record + enabled: true + } + defaultModel?: string + smallModel?: string + apiKey?: string +} diff --git a/apps/desktop/src/main/claude-code-provider-migration.test.ts b/apps/desktop/src/main/claude-code-provider-migration.test.ts index dcc348af..d8c07ca6 100644 --- a/apps/desktop/src/main/claude-code-provider-migration.test.ts +++ b/apps/desktop/src/main/claude-code-provider-migration.test.ts @@ -43,7 +43,7 @@ describe("Claude Code provider migration", () => { expect(result.category?.files[0].content).not.toContain("sk-xxxxxxxx") }) - test("executes provider upserts for each unique imported model", async () => { + test("executes one canonical provider upsert for the imported Connection", async () => { const calls: Array<{ method: string; params: Record }> = [] const result = await executeClaudeCodeProviderMigration( @@ -56,36 +56,24 @@ describe("Claude Code provider migration", () => { expect(result.errors).toEqual([]) expect(result.filesWritten).toEqual([ - "provider/upsert:claude-code/deepseek-v4-pro-1m-claude-code", - "provider/upsert:claude-code/deepseek-v4-flash-claude-code", + "provider/upsert:claude-code", ]) - expect(calls).toHaveLength(2) + expect(calls).toHaveLength(1) expect(calls[0].method).toBe("provider/upsert") expect(calls[0].params).toEqual({ - provider_vendor: { + provider: { + id: "claude-code", name: "claude-code", - base_url: "https://api.deepseek.com/anthropic", - credential: null, - headers: null, - wire_apis: ["anthropic_messages"], - enabled: true, - }, - model_binding: { - binding_id: "deepseek-v4-pro-1m-claude-code", - model_slug: "deepseek-v4-pro[1m]", - provider: "claude-code", - request_model: "deepseek-v4-pro[1m]", - display_name: "deepseek-v4-pro[1m]", - invocation_method: "anthropic_messages", - default_reasoning_effort: null, + baseUrl: "https://api.deepseek.com/anthropic", + wireApis: ["anthropic_messages"], + models: { + "deepseek-v4-pro[1m]": { name: "deepseek-v4-pro[1m]" }, + "deepseek-v4-flash": { name: "deepseek-v4-flash" }, + }, enabled: true, }, - default_model_binding: "deepseek-v4-pro-1m-claude-code", - api_key: "sk-xxxxxxxx", - }) - expect(calls[1].params).toMatchObject({ - default_model_binding: undefined, - api_key: "sk-xxxxxxxx", + defaultModel: "claude-code/deepseek-v4-pro[1m]", + apiKey: "sk-xxxxxxxx", }) }) @@ -104,7 +92,7 @@ describe("Claude Code provider migration", () => { expect(calls).toEqual([]) }) - test("imports model bindings without an API key and reports manual action", async () => { + test("imports models without an API key and reports manual action", async () => { const calls: Array> = [] const result = await executeClaudeCodeProviderMigration( @@ -121,6 +109,6 @@ describe("Claude Code provider migration", () => { expect(result.errors).toEqual([]) expect(result.manualActions.some((item) => item.includes("ANTHROPIC_AUTH_TOKEN"))).toBe(true) expect(calls).toHaveLength(1) - expect(calls[0].api_key).toBeUndefined() + expect(calls[0].apiKey).toBeUndefined() }) }) diff --git a/apps/desktop/src/main/claude-code-provider-migration.ts b/apps/desktop/src/main/claude-code-provider-migration.ts index 66d6033d..2fc706af 100644 --- a/apps/desktop/src/main/claude-code-provider-migration.ts +++ b/apps/desktop/src/main/claude-code-provider-migration.ts @@ -3,6 +3,7 @@ import { formatClaudeCodeProviderSettingsPreview, } from "@devo/configconv" import type { ClaudeCodeProviderSettings, ClaudeSettings } from "@devo/configconv" +import type { CanonicalProviderUpsertParams } from "./canonical-provider-migration" interface MigrationFilePreview { path: string @@ -73,13 +74,12 @@ export async function executeClaudeCodeProviderMigration( const errors: string[] = [...diagnostics.errors] for (const params of buildProviderUpsertParams(settings)) { - const bindingId = params.model_binding.binding_id try { await requestProviderUpsert("provider/upsert", params) - filesWritten.push(`provider/upsert:${settings.providerId}/${bindingId}`) + filesWritten.push(`provider/upsert:${settings.providerId}`) } catch (error) { errors.push( - `Claude Code provider migration failed for ${params.model_binding.request_model}: ${error instanceof Error ? error.message : String(error)}`, + `Claude Code provider migration failed for ${settings.providerId}: ${error instanceof Error ? error.message : String(error)}`, ) } } @@ -92,54 +92,27 @@ export async function executeClaudeCodeProviderMigration( } } -function buildProviderUpsertParams(settings: ClaudeCodeProviderSettings): Array<{ - provider_vendor: { - name: string - base_url: string | null - credential: null - headers: null - wire_apis: string[] - enabled: true - } - model_binding: { - binding_id: string - model_slug: string - provider: string - request_model: string - display_name: string - invocation_method: string - default_reasoning_effort: null - enabled: true - } - default_model_binding?: string - api_key?: string -}> { - return settings.models.map((model) => { - const bindingId = `${slugComponent(model)}-${settings.providerId}` - const params = { - provider_vendor: { +function buildProviderUpsertParams( + settings: ClaudeCodeProviderSettings, +): CanonicalProviderUpsertParams[] { + if (settings.models.length === 0) return [] + + return [ + { + provider: { + id: settings.providerId, name: settings.providerId, - base_url: settings.baseUrl ?? null, - credential: null, - headers: null, - wire_apis: [settings.wireApi], - enabled: true as const, + ...(settings.baseUrl ? { baseUrl: settings.baseUrl } : {}), + wireApis: [settings.wireApi], + models: Object.fromEntries(settings.models.map((model) => [model, { name: model }])), + enabled: true, }, - model_binding: { - binding_id: bindingId, - model_slug: model, - provider: settings.providerId, - request_model: model, - display_name: model, - invocation_method: settings.wireApi, - default_reasoning_effort: null, - enabled: true as const, - }, - default_model_binding: model === settings.defaultModel ? bindingId : undefined, - api_key: settings.apiKey, - } - return params - }) + ...(settings.defaultModel + ? { defaultModel: `${settings.providerId}/${settings.defaultModel}` } + : {}), + ...(settings.apiKey ? { apiKey: settings.apiKey } : {}), + }, + ] } function diagnosticsFor(settings: ClaudeCodeProviderSettings): { @@ -152,7 +125,7 @@ function diagnosticsFor(settings: ClaudeCodeProviderSettings): { if (settings.models.length === 0) { warnings.push( - "Claude Code settings did not include ANTHROPIC_MODEL or default Anthropic model env vars; no provider model bindings were imported.", + "Claude Code settings did not include ANTHROPIC_MODEL or default Anthropic model env vars; no provider Connection was imported.", ) } if (!settings.apiKey) { @@ -173,19 +146,6 @@ function readClaudeCodeSettings(scanResult: unknown): ClaudeSettings | undefined return isRecord(global.settings) ? (global.settings as ClaudeSettings) : undefined } -function slugComponent(value: string): string { - let out = "" - for (const ch of value) { - if (/[a-zA-Z0-9]/.test(ch)) { - out += ch.toLowerCase() - } else if (!out.endsWith("-")) { - out += "-" - } - } - const slug = out.replace(/^-+|-+$/g, "") - return slug || "model" -} - function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value) } diff --git a/apps/desktop/src/main/ipc-handlers.ts b/apps/desktop/src/main/ipc-handlers.ts index 6641d3d8..eb4071bf 100644 --- a/apps/desktop/src/main/ipc-handlers.ts +++ b/apps/desktop/src/main/ipc-handlers.ts @@ -59,6 +59,10 @@ import { stopServer, subscribeNative, } from "./devo-manager" +import { + isSessionNotFoundError, + nativeIpcErrorEnvelope, +} from "../shared/native-ipc-error" import { getOpaqueWindows, getSettings, onSettingsChanged, updateSettings } from "./settings-store" import { desktopTerminalManager } from "./terminal-manager" import { @@ -209,8 +213,23 @@ export function registerIpcHandlers(): void { "native:request", withLogging( "native:request", - async (_, request: { method: string; params?: unknown; directory?: string }) => - await requestNative(request.method, request.params, request.directory), + async (_, request: { method: string; params?: unknown; directory?: string }) => { + try { + return await requestNative(request.method, request.params, request.directory) + } catch (err) { + // SessionNotFound is an expected race (stale route / deleted session). + // Returning an envelope avoids Electron treating the handler rejection + // as an unhandled promise rejection; the renderer transport rethrows. + if (isSessionNotFoundError(err)) { + log.debug("native:request session not found", { + method: request.method, + message: err instanceof Error ? err.message : String(err), + }) + return nativeIpcErrorEnvelope(err) + } + throw err + } + }, ), ) diff --git a/apps/desktop/src/main/native-stdio-client.ts b/apps/desktop/src/main/native-stdio-client.ts index a0e730db..2513752f 100644 --- a/apps/desktop/src/main/native-stdio-client.ts +++ b/apps/desktop/src/main/native-stdio-client.ts @@ -316,6 +316,10 @@ export class StdioNativeClient implements NativeTransport { }, timeoutMs) this.pending.set(id, { resolve, reject, timer }) }) + // Server may reply (and reject) before the caller awaits — attach a + // no-op handler so Node does not log UnhandledPromiseRejectionWarning. + // Real callers still observe the rejection via await / .catch. + void response.catch(() => {}) this.pendingMethods.set(id, method) try { await this.writeJson(child, payload) @@ -414,9 +418,13 @@ export class StdioNativeClient implements NativeTransport { if (!pending) return this.pending.delete(routed.id) if (pending.timer !== undefined) clearTimeout(pending.timer) - const error = routed.message.error as { message?: string } | undefined + const error = routed.message.error as { message?: string; code?: string } | undefined if (error) { - pending.reject(new Error(error.message ?? "Devo Native request failed")) + const rejected = new Error(error.message ?? "Devo Native request failed") as Error & { + code?: string + } + if (typeof error.code === "string") rejected.code = error.code + pending.reject(rejected) } else { pending.resolve(routed.message.result) } diff --git a/apps/desktop/src/main/notification-watcher.ts b/apps/desktop/src/main/notification-watcher.ts index a060540d..bf83b4bd 100644 --- a/apps/desktop/src/main/notification-watcher.ts +++ b/apps/desktop/src/main/notification-watcher.ts @@ -57,15 +57,23 @@ export function startNotificationWatcher(transport: DevoNativeTransport): void { const client = createDevoClient({ transport }) setPermissionResponder(async ({ sessionId, permissionId, response }) => { - await client.permission.respond({ - sessionID: sessionId, - permissionID: permissionId, - response, - }) + try { + await client.permission.respond({ + sessionID: sessionId, + permissionID: permissionId, + response, + }) + } catch (err) { + log.error("Permission reply failed", { sessionId, permissionId, response }, err) + } }) log.info("Starting notification watcher") - connectWithRetry(client, abortController.signal) + void connectWithRetry(client, abortController.signal).catch((err) => { + if (!abortController?.signal.aborted) { + log.error("Notification watcher stopped unexpectedly", {}, err) + } + }) } /** diff --git a/apps/desktop/src/main/notifications.ts b/apps/desktop/src/main/notifications.ts index 1e663d05..cb73c712 100644 --- a/apps/desktop/src/main/notifications.ts +++ b/apps/desktop/src/main/notifications.ts @@ -193,6 +193,12 @@ function fireNotification(request: NotificationRequest): void { permissionId, response, directory: request.directory, + }).catch((err) => { + log.error( + "Permission notification action failed", + { sessionId: request.sessionId, permissionId, response }, + err, + ) }) }) } diff --git a/apps/desktop/src/main/opencode-provider-migration.test.ts b/apps/desktop/src/main/opencode-provider-migration.test.ts index f454ff96..afde16bc 100644 --- a/apps/desktop/src/main/opencode-provider-migration.test.ts +++ b/apps/desktop/src/main/opencode-provider-migration.test.ts @@ -23,6 +23,7 @@ function scanResultWithConfig( const exampleConfig = { model: "deepseek/deepseek-v4-pro", + small_model: "deepseek/deepseek-v4-flash", provider: { deepseek: { npm: "@ai-sdk/openai-compatible", @@ -78,7 +79,7 @@ describe("OpenCode provider migration", () => { expect(result.category?.files[1].content).not.toContain("tp-xxxxxx") }) - test("executes provider upserts for each imported model and sets explicit default only", async () => { + test("executes one canonical provider upsert per imported provider", async () => { const calls: Array<{ method: string; params: Record }> = [] const result = await executeOpenCodeProviderMigration( @@ -90,43 +91,30 @@ describe("OpenCode provider migration", () => { ) expect(result.errors).toEqual([]) - expect(result.filesWritten).toEqual([ - "provider/upsert:deepseek/deepseek-v4-flash-deepseek", - "provider/upsert:deepseek/deepseek-v4-pro-deepseek", - "provider/upsert:mimo/mimo-v2-5-mimo", - "provider/upsert:mimo/mimo-v2-5-pro-mimo", - ]) + expect(result.filesWritten).toEqual(["provider/upsert:deepseek", "provider/upsert:mimo"]) expect(calls.map((call) => call.method)).toEqual([ "provider/upsert", "provider/upsert", - "provider/upsert", - "provider/upsert", ]) expect(calls[1].params).toEqual({ - provider_vendor: { - name: "deepseek", - base_url: "https://api.deepseek.com/v1", - credential: null, - headers: null, - wire_apis: ["openai_chat_completions"], - enabled: true, - }, - model_binding: { - binding_id: "deepseek-v4-pro-deepseek", - model_slug: "deepseek-v4-pro", - provider: "deepseek", - request_model: "deepseek-v4-pro", - display_name: "DeepSeek V4 Pro", - invocation_method: "openai_chat_completions", - default_reasoning_effort: null, + provider: { + id: "mimo", + name: "Xiaomi MiMo", + baseUrl: "https://token-plan-cn.xiaomimimo.com/v1", + wireApis: ["openai_chat_completions"], + models: { + "mimo-v2.5": { name: "Mimo v2.5" }, + "mimo-v2.5-pro": { name: "Mimo v2.5 Pro" }, + }, enabled: true, }, - default_model_binding: "deepseek-v4-pro-deepseek", - api_key: "sk-xxxx", + apiKey: "tp-xxxxxx", }) - expect(calls[0].params.default_model_binding).toBeUndefined() - expect(calls[2].params.default_model_binding).toBeUndefined() - expect(calls[2].params.api_key).toBe("tp-xxxxxx") + expect(calls[0].params.defaultModel).toBe("deepseek/deepseek-v4-pro") + expect(calls[0].params.smallModel).toBe("deepseek/deepseek-v4-flash") + expect(calls[0].params.apiKey).toBe("sk-xxxx") + expect(calls[1].params.defaultModel).toBeUndefined() + expect(calls[1].params.smallModel).toBeUndefined() }) test("does not guess a default model when OpenCode does not define one", async () => { @@ -152,7 +140,7 @@ describe("OpenCode provider migration", () => { ) expect(calls).toHaveLength(1) - expect(calls[0].default_model_binding).toBeUndefined() + expect(calls[0].defaultModel).toBeUndefined() }) test("reports missing config, parse errors, unsupported providers, and missing key/base URL without crashing", async () => { diff --git a/apps/desktop/src/main/opencode-provider-migration.ts b/apps/desktop/src/main/opencode-provider-migration.ts index cfe5a5b0..5693b494 100644 --- a/apps/desktop/src/main/opencode-provider-migration.ts +++ b/apps/desktop/src/main/opencode-provider-migration.ts @@ -7,6 +7,7 @@ import type { OpenCodeProviderSettings, OpenCodeScanResult, } from "@devo/configconv" +import type { CanonicalProviderUpsertParams } from "./canonical-provider-migration" interface MigrationFilePreview { path: string @@ -84,15 +85,13 @@ export async function executeOpenCodeProviderMigration( const errors: string[] = [...diagnostics.errors] for (const params of buildProviderUpsertParams(settings)) { - const providerName = params.model_binding.provider - const requestModel = params.model_binding.request_model - const bindingId = params.model_binding.binding_id + const providerName = params.provider.id try { await requestProviderUpsert("provider/upsert", params) - filesWritten.push(`provider/upsert:${providerName}/${bindingId}`) + filesWritten.push(`provider/upsert:${providerName}`) } catch (error) { errors.push( - `OpenCode provider migration failed for ${providerName}/${requestModel}: ${error instanceof Error ? error.message : String(error)}`, + `OpenCode provider migration failed for ${providerName}: ${error instanceof Error ? error.message : String(error)}`, ) } } @@ -105,59 +104,28 @@ export async function executeOpenCodeProviderMigration( } } -function buildProviderUpsertParams(settings: OpenCodeProviderSettings): Array<{ - provider_vendor: { - name: string - base_url: string | null - credential: null - headers: null - wire_apis: string[] - enabled: true - } - model_binding: { - binding_id: string - model_slug: string - provider: string - request_model: string - display_name: string - invocation_method: string - default_reasoning_effort: null - enabled: true - } - default_model_binding?: string - api_key?: string -}> { - const params = [] - - for (const provider of settings.providers) { - for (const model of provider.models) { - const bindingId = `${slugComponent(model.modelId)}-${slugComponent(provider.providerId)}` - params.push({ - provider_vendor: { - name: provider.providerId, - base_url: provider.baseUrl ?? null, - credential: null, - headers: null, - wire_apis: [provider.wireApi], - enabled: true as const, - }, - model_binding: { - binding_id: bindingId, - model_slug: model.modelId, - provider: provider.providerId, - request_model: model.modelId, - display_name: model.displayName, - invocation_method: provider.wireApi, - default_reasoning_effort: null, - enabled: true as const, - }, - default_model_binding: model.isDefault ? bindingId : undefined, - api_key: provider.apiKey, - }) +function buildProviderUpsertParams( + settings: OpenCodeProviderSettings, +): CanonicalProviderUpsertParams[] { + return settings.providers.map((provider) => { + const defaultModel = provider.models.find((model) => model.isDefault)?.modelId + const smallModel = provider.models.find((model) => model.isSmall)?.modelId + return { + provider: { + id: provider.providerId, + name: provider.displayName, + ...(provider.baseUrl ? { baseUrl: provider.baseUrl } : {}), + wireApis: [provider.wireApi], + models: Object.fromEntries( + provider.models.map((model) => [model.modelId, { name: model.displayName }]), + ), + enabled: true, + }, + ...(defaultModel ? { defaultModel: `${provider.providerId}/${defaultModel}` } : {}), + ...(smallModel ? { smallModel: `${provider.providerId}/${smallModel}` } : {}), + ...(provider.apiKey ? { apiKey: provider.apiKey } : {}), } - } - - return params + }) } function diagnosticsFor(settings: OpenCodeProviderSettings): { @@ -186,7 +154,7 @@ function diagnosticsFor(settings: OpenCodeProviderSettings): { if (settings.providers.reduce((sum, provider) => sum + provider.models.length, 0) === 0) { warnings.push( - "OpenCode settings did not include any importable OpenAI-compatible provider models; no provider model bindings were imported.", + "OpenCode settings did not include any importable OpenAI-compatible provider models; no provider Connections were imported.", ) } @@ -205,7 +173,7 @@ function pushProviderDiagnostics( } if (provider.models.length === 0) { warnings.push( - `OpenCode provider ${provider.providerId} did not include model definitions; no model bindings were imported for this provider.`, + `OpenCode provider ${provider.providerId} did not include model definitions; no models were imported for this provider.`, ) } if (!provider.apiKey) { @@ -234,19 +202,6 @@ function readOpenCodeScanResult(scanResult: unknown): OpenCodeScanResult | undef } } -function slugComponent(value: string): string { - let out = "" - for (const ch of value) { - if (/[a-zA-Z0-9]/.test(ch)) { - out += ch.toLowerCase() - } else if (!out.endsWith("-")) { - out += "-" - } - } - const slug = out.replace(/^-+|-+$/g, "") - return slug || "model" -} - function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value) } diff --git a/apps/desktop/src/main/settings-store.test.ts b/apps/desktop/src/main/settings-store.test.ts index c189e97d..7d9e6487 100644 --- a/apps/desktop/src/main/settings-store.test.ts +++ b/apps/desktop/src/main/settings-store.test.ts @@ -28,7 +28,7 @@ function expectedSettings() { }, opaqueWindows: false, appearance: { - colorScheme: "dark", + colorScheme: "light", themeId: "default", displayMode: "default", hideThinkingWhileWorking: true, diff --git a/apps/desktop/src/renderer/assets/provider-logos/alibaba.svg b/apps/desktop/src/renderer/assets/provider-logos/alibaba.svg new file mode 100644 index 00000000..56c20781 --- /dev/null +++ b/apps/desktop/src/renderer/assets/provider-logos/alibaba.svg @@ -0,0 +1 @@ +AlibabaCloud \ No newline at end of file diff --git a/apps/desktop/src/renderer/assets/provider-logos/deepseek.svg b/apps/desktop/src/renderer/assets/provider-logos/deepseek.svg new file mode 100644 index 00000000..3fc23024 --- /dev/null +++ b/apps/desktop/src/renderer/assets/provider-logos/deepseek.svg @@ -0,0 +1 @@ +DeepSeek \ No newline at end of file diff --git a/apps/desktop/src/renderer/assets/provider-logos/kimi.svg b/apps/desktop/src/renderer/assets/provider-logos/kimi.svg new file mode 100644 index 00000000..83878fa2 --- /dev/null +++ b/apps/desktop/src/renderer/assets/provider-logos/kimi.svg @@ -0,0 +1 @@ +Kimi \ No newline at end of file diff --git a/apps/desktop/src/renderer/assets/provider-logos/minimax.svg b/apps/desktop/src/renderer/assets/provider-logos/minimax.svg new file mode 100644 index 00000000..beb7adb8 --- /dev/null +++ b/apps/desktop/src/renderer/assets/provider-logos/minimax.svg @@ -0,0 +1 @@ +Minimax \ No newline at end of file diff --git a/apps/desktop/src/renderer/assets/provider-logos/moonshot.svg b/apps/desktop/src/renderer/assets/provider-logos/moonshot.svg new file mode 100644 index 00000000..fb56ac10 --- /dev/null +++ b/apps/desktop/src/renderer/assets/provider-logos/moonshot.svg @@ -0,0 +1 @@ +MoonshotAI \ No newline at end of file diff --git a/apps/desktop/src/renderer/assets/provider-logos/moonshotai.svg b/apps/desktop/src/renderer/assets/provider-logos/moonshotai.svg new file mode 100644 index 00000000..3cdf7c86 --- /dev/null +++ b/apps/desktop/src/renderer/assets/provider-logos/moonshotai.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/desktop/src/renderer/assets/provider-logos/ollama.svg b/apps/desktop/src/renderer/assets/provider-logos/ollama.svg new file mode 100644 index 00000000..cc887e3d --- /dev/null +++ b/apps/desktop/src/renderer/assets/provider-logos/ollama.svg @@ -0,0 +1 @@ +Ollama \ No newline at end of file diff --git a/apps/desktop/src/renderer/assets/provider-logos/openai.svg b/apps/desktop/src/renderer/assets/provider-logos/openai.svg new file mode 100644 index 00000000..78caf4fa --- /dev/null +++ b/apps/desktop/src/renderer/assets/provider-logos/openai.svg @@ -0,0 +1 @@ +OpenAI \ No newline at end of file diff --git a/apps/desktop/src/renderer/assets/provider-logos/poolside.svg b/apps/desktop/src/renderer/assets/provider-logos/poolside.svg new file mode 100644 index 00000000..7969f594 --- /dev/null +++ b/apps/desktop/src/renderer/assets/provider-logos/poolside.svg @@ -0,0 +1 @@ +Poolside \ No newline at end of file diff --git a/apps/desktop/src/renderer/assets/provider-logos/qwen.svg b/apps/desktop/src/renderer/assets/provider-logos/qwen.svg new file mode 100644 index 00000000..f2d0ada1 --- /dev/null +++ b/apps/desktop/src/renderer/assets/provider-logos/qwen.svg @@ -0,0 +1 @@ +Qwen \ No newline at end of file diff --git a/apps/desktop/src/renderer/assets/provider-logos/tencent.svg b/apps/desktop/src/renderer/assets/provider-logos/tencent.svg new file mode 100644 index 00000000..42edd6cd --- /dev/null +++ b/apps/desktop/src/renderer/assets/provider-logos/tencent.svg @@ -0,0 +1 @@ +Hunyuan \ No newline at end of file diff --git a/apps/desktop/src/renderer/assets/provider-logos/xiaomi.svg b/apps/desktop/src/renderer/assets/provider-logos/xiaomi.svg new file mode 100644 index 00000000..eb97d8e1 --- /dev/null +++ b/apps/desktop/src/renderer/assets/provider-logos/xiaomi.svg @@ -0,0 +1 @@ +Xiaomi \ No newline at end of file diff --git a/apps/desktop/src/renderer/assets/provider-logos/zai.svg b/apps/desktop/src/renderer/assets/provider-logos/zai.svg new file mode 100644 index 00000000..b03d6018 --- /dev/null +++ b/apps/desktop/src/renderer/assets/provider-logos/zai.svg @@ -0,0 +1 @@ +ChatGLM \ No newline at end of file diff --git a/apps/desktop/src/renderer/assets/provider-logos/zhipu.svg b/apps/desktop/src/renderer/assets/provider-logos/zhipu.svg new file mode 100644 index 00000000..0c6e61ce --- /dev/null +++ b/apps/desktop/src/renderer/assets/provider-logos/zhipu.svg @@ -0,0 +1 @@ +Zhipu \ No newline at end of file diff --git a/apps/desktop/src/renderer/atoms/actions/event-processor.ts b/apps/desktop/src/renderer/atoms/actions/event-processor.ts index 1b6a472e..b6a78cf4 100644 --- a/apps/desktop/src/renderer/atoms/actions/event-processor.ts +++ b/apps/desktop/src/renderer/atoms/actions/event-processor.ts @@ -20,7 +20,7 @@ import { import { setSessionActiveTurnAtom, setSessionQueueAtom } from "../queue" import { sessionNativeFamily } from "../session-native" import { appStore } from "../store" -import { isStreamingField, streamingVersionFamily } from "../streaming" +import { isStreamingField, getStreamingPart, streamingVersionFamily } from "../streaming" import { todosFamily } from "../todos" import { setSessionDiffAtom } from "../ui" import { applyWorkspaceChangesUpdatedAtom } from "../workspace-changes" @@ -181,7 +181,9 @@ export function processEvent(event: Event): void { case "session/compaction/completed": { const sessionID = event.properties.sessionID ?? event.properties.session_id if (sessionID) { - set(compactionStatusFamily(sessionID), "completed") + // Transcript markers carry the durable "completed" row; clear the + // live atom so a later compaction can show "started" again. + set(compactionStatusFamily(sessionID), null) } break } @@ -245,11 +247,15 @@ export function processEvent(event: Event): void { const part = event.properties.part set(upsertPartAtom, part) // useSessionChat reads partsFamily imperatively through appStore.get, - // so every visible part update must bump the per-session version. - // Streaming text/reasoning parts may also be present in the streaming - // overlay, but the main-store write still needs to invalidate renders - // when it is the event that reaches the UI after a tool call. - set(streamingVersionFamily(part.sessionID), (v) => v + 1) + // so visible part updates must bump the per-session version — except + // when the streaming buffer already owns this text/reasoning part and + // has scheduled a throttled notify (avoids ~RAF double bumps). + const bufferedStreaming = + (part.type === "text" || part.type === "reasoning") && + Boolean(getStreamingPart(part.sessionID, part.messageID, part.id)) + if (!bufferedStreaming) { + set(streamingVersionFamily(part.sessionID), (v) => v + 1) + } break } @@ -314,11 +320,24 @@ export function processEvent(event: Event): void { const sessionID = event.properties.sessionID if (!sessionID) break const current = appStore.get(sessionNativeFamily(sessionID)) + const nextUsed = Number(event.properties.used ?? 0) + const nextSize = Number(event.properties.size ?? 0) + const previousSize = Number(current.usage?.size ?? 0) + const occupancyWindow = Number(current.occupancy?.contextWindowTokens ?? 0) + // Server size is already the model effective window. Keep the + // denominator in sync with live turn updates (including increases + // after the user raises usable context). + const stableSize = nextSize > 0 ? nextSize : occupancyWindow > 0 ? occupancyWindow : previousSize + const nextOccupancy = + current.occupancy && stableSize > 0 && current.occupancy.contextWindowTokens !== stableSize + ? { ...current.occupancy, contextWindowTokens: stableSize } + : current.occupancy set(sessionNativeFamily(sessionID), { ...current, + occupancy: nextOccupancy, usage: { - used: event.properties.used, - size: event.properties.size, + used: nextUsed, + size: stableSize, cost: event.properties.cost, }, }) @@ -328,10 +347,30 @@ export function processEvent(event: Event): void { case "context.usage.updated": { const sessionID = event.properties.sessionID if (!sessionID) break + const occupancy = event.properties.occupancy as + | { + totalTokens?: number + contextWindowTokens?: number + categories?: unknown + } + | undefined const current = appStore.get(sessionNativeFamily(sessionID)) + const occupancyTotal = Number(occupancy?.totalTokens ?? 0) + const occupancyWindow = Number(occupancy?.contextWindowTokens ?? 0) + const previousUsed = Number(current.usage?.used ?? 0) + const previousOccupancyWindow = Number(current.occupancy?.contextWindowTokens ?? 0) + // Trust the server window (model effective). Shrinks and increases + // both apply immediately so the Context usage popover denominator + // stays current. + const nextWindow = occupancyWindow > 0 ? occupancyWindow : previousOccupancyWindow set(sessionNativeFamily(sessionID), { ...current, - occupancy: event.properties.occupancy, + occupancy: occupancy, + usage: { + used: occupancyTotal > 0 ? occupancyTotal : previousUsed, + size: nextWindow > 0 ? nextWindow : Number(current.usage?.size ?? 0), + cost: current.usage?.cost, + }, }) break } diff --git a/apps/desktop/src/renderer/atoms/session-native.test.ts b/apps/desktop/src/renderer/atoms/session-native.test.ts index 435ff2c3..6a86ad44 100644 --- a/apps/desktop/src/renderer/atoms/session-native.test.ts +++ b/apps/desktop/src/renderer/atoms/session-native.test.ts @@ -4,7 +4,7 @@ import { partsFamily, partStorageKey } from "./parts" import { sessionNativeFamily } from "./session-native" import { sessionFamily, upsertSessionAtom } from "./sessions" import { appStore } from "./store" -import { streamingVersionFamily } from "./streaming" +import { streamingVersionFamily, updateStreamingPart, flushStreamingParts } from "./streaming" describe("Native session renderer state", () => { test("deduplicates replayed Native approvals by approval id", () => { @@ -82,8 +82,8 @@ describe("Native session renderer state", () => { configOptions: [{ id: "model", currentValue: "test-model" }], modeID: "plan", usage: { - used: 42, - size: 100, + used: 48_000, + size: 190_000, cost: { amount: 1, currency: "USD" }, }, occupancy: { @@ -97,6 +97,70 @@ describe("Native session renderer state", () => { }) }) + test("live turn usage advances the fill amount and syncs the window", () => { + const sessionID = "session-native-usage-ahead" + processEvent({ + type: "context.usage.updated", + properties: { + sessionID, + occupancy: { + totalTokens: 16_700, + contextWindowTokens: 190_000, + categories: [ + { id: "base", tokens: 10_000, shareBps: 5988 }, + { id: "conversation", tokens: 6_700, shareBps: 4012 }, + ], + }, + }, + }) + processEvent({ + type: "session.usage.updated", + properties: { + sessionID, + used: 48_000, + size: 250_000, + }, + }) + + const native = appStore.get(sessionNativeFamily(sessionID)) + expect(native.usage?.used).toBe(48_000) + // Denominator follows the live effective window from the server. + expect(native.usage?.size).toBe(250_000) + expect(native.occupancy?.contextWindowTokens).toBe(250_000) + expect(native.occupancy?.totalTokens).toBe(16_700) + }) + + test("applies occupancy window increases immediately", () => { + const sessionID = "session-native-window-increase" + processEvent({ + type: "context.usage.updated", + properties: { + sessionID, + occupancy: { + totalTokens: 50_000, + contextWindowTokens: 190_000, + categories: [], + }, + }, + }) + processEvent({ + type: "context.usage.updated", + properties: { + sessionID, + occupancy: { + totalTokens: 52_000, + contextWindowTokens: 1_000_000, + categories: [], + }, + }, + }) + + const native = appStore.get(sessionNativeFamily(sessionID)) + expect(native.occupancy?.contextWindowTokens).toBe(1_000_000) + expect(native.usage?.used).toBe(52_000) + expect(native.usage?.size).toBe(1_000_000) + }) + test("notifies session chat renders when text parts update", () => { const sessionID = "session-text-part-update" const messageID = "message-text-part-update" @@ -129,6 +193,32 @@ describe("Native session renderer state", () => { expect(appStore.get(streamingVersionFamily(sessionID))).toBe(initialVersion + 1) }) + test("skips version bump when text part is already in the streaming buffer", () => { + const sessionID = "session-buffered-text-part" + const messageID = "message-buffered-text-part" + const part = { + id: "buffered-text", + sessionID, + messageID, + type: "text" as const, + text: "hello", + time: { start: 1 }, + } + updateStreamingPart(part) + const versionAfterBuffer = appStore.get(streamingVersionFamily(sessionID)) + + processEvent({ + type: "message.part.updated", + properties: { part: { ...part, text: "hello world" } }, + }) + + expect(appStore.get(streamingVersionFamily(sessionID))).toBe(versionAfterBuffer) + expect(appStore.get(partsFamily(partStorageKey(sessionID, messageID)))).toEqual([ + { ...part, text: "hello world" }, + ]) + flushStreamingParts() + }) + test("stores scheduled retries, clears resumed retries, and reports transient failures", () => { const sessionID = "session-provider-retry" appStore.set(upsertSessionAtom, { @@ -146,19 +236,32 @@ describe("Native session renderer state", () => { provider: "openai", model: "test-model", phase: "scheduled", - message: "Retrying provider request in 1.0s", + message: "Internal server error", }, }) - expect(appStore.get(sessionFamily(sessionID))?.retryStatus).toEqual({ + const scheduled = appStore.get(sessionFamily(sessionID)) + expect(scheduled?.retryStatus).toEqual({ turnId: "turn-1", attempt: 2, backoffMs: 1000, provider: "openai", model: "test-model", phase: "scheduled", - message: "Retrying provider request in 1.0s", + message: "Internal server error", }) + expect(scheduled?.providerErrors).toEqual([ + { + id: "retry-turn-1-2", + turnId: "turn-1", + message: "Internal server error", + phase: "scheduled", + attempt: 2, + backoffMs: 1000, + scheduledAtMs: scheduled?.providerErrors?.[0]?.scheduledAtMs, + }, + ]) + expect(typeof scheduled?.providerErrors?.[0]?.scheduledAtMs).toBe("number") processEvent({ type: "turn.provider_retry_status", @@ -170,7 +273,7 @@ describe("Native session renderer state", () => { provider: "openai", model: "test-model", phase: "resumed", - message: "Retrying provider request now", + message: "Internal server error", }, }) processEvent({ @@ -184,13 +287,32 @@ describe("Native session renderer state", () => { }, }) - expect(appStore.get(sessionFamily(sessionID))).toEqual({ + const failed = appStore.get(sessionFamily(sessionID)) + expect(failed).toEqual({ session: { id: sessionID, title: "Retry test" }, directory: "/repo", status: { type: "idle" }, permissions: [], questions: [], retryStatus: undefined, + providerErrors: [ + { + id: "retry-turn-1-2", + turnId: "turn-1", + message: "Internal server error", + phase: "scheduled", + attempt: 2, + backoffMs: 1000, + scheduledAtMs: failed?.providerErrors?.[0]?.scheduledAtMs, + }, + { + id: "failed-turn-1-PROVIDER_SERVER_ERROR-Internal server error", + turnId: "turn-1", + message: "Internal server error", + phase: "failed", + code: "PROVIDER_SERVER_ERROR", + }, + ], error: { name: "PROVIDER_SERVER_ERROR", data: { message: "Internal server error" }, diff --git a/apps/desktop/src/renderer/atoms/sessions.ts b/apps/desktop/src/renderer/atoms/sessions.ts index fef86584..d9b5061b 100644 --- a/apps/desktop/src/renderer/atoms/sessions.ts +++ b/apps/desktop/src/renderer/atoms/sessions.ts @@ -32,6 +32,20 @@ export type ProviderRetryStatus = { message: string } +/** Expandable provider/LLM failure rows for the chat process timeline. */ +export type ProviderErrorEntry = { + id: string + turnId: string + message: string + phase: "scheduled" | "failed" | string + attempt?: number + code?: string + /** Backoff budget when this retry was scheduled (ms). */ + backoffMs?: number + /** Client clock when the scheduled retry was observed. */ + scheduledAtMs?: number +} + /** Phases of worktree setup shown in the chat view's empty state */ export type SessionSetupPhase = "creating-worktree" | "starting-session" | null @@ -70,6 +84,8 @@ export interface SessionEntry { hasUnreadCompletion?: boolean /** Active provider retry status for the current turn. */ retryStatus?: ProviderRetryStatus + /** Accumulated provider retry / failure rows for expandable display. */ + providerErrors?: ProviderErrorEntry[] } // ============================================================ @@ -114,6 +130,7 @@ export const upsertSessionAtom = atom( setupPhase: existing?.setupPhase, hasUnreadCompletion: existing?.hasUnreadCompletion, retryStatus: existing?.retryStatus, + providerErrors: existing?.providerErrors, }) // Add to index @@ -169,11 +186,15 @@ export const setSessionStatusAtom = atom( : completedTurn ? get(viewedSessionIdAtom) !== args.sessionId : entry.hasUnreadCompletion + // New turn: drop prior expandable error rows so they stay scoped to one attempt. + const startingFreshTurn = + isWorking && (entry.status.type === "idle" || entry.status.type === "error") set(sessionFamily(args.sessionId), { ...entry, status: args.status, hasUnreadCompletion, retryStatus: args.status.type === "idle" || args.status.type === "error" ? undefined : entry.retryStatus, + providerErrors: startingFreshTurn ? [] : entry.providerErrors, }) }, ) @@ -191,7 +212,37 @@ export const setProviderRetryStatusAtom = atom( ) => { const entry = get(sessionFamily(args.sessionId)) if (!entry) return - set(sessionFamily(args.sessionId), { ...entry, retryStatus: args.status }) + const status = args.status + if (!status) { + set(sessionFamily(args.sessionId), { ...entry, retryStatus: undefined }) + return + } + if (status.phase === "resumed") { + set(sessionFamily(args.sessionId), { ...entry, retryStatus: undefined }) + return + } + const message = status.message.trim() + const nextErrors = [...(entry.providerErrors ?? [])] + if (message || status.phase === "scheduled") { + const id = `retry-${status.turnId}-${status.attempt}` + const nextEntry: ProviderErrorEntry = { + id, + turnId: status.turnId, + message: message || "Provider request failed", + phase: status.phase || "scheduled", + attempt: status.attempt, + backoffMs: status.backoffMs, + scheduledAtMs: Date.now(), + } + const existingIndex = nextErrors.findIndex((item) => item.id === id) + if (existingIndex >= 0) nextErrors[existingIndex] = nextEntry + else nextErrors.push(nextEntry) + } + set(sessionFamily(args.sessionId), { + ...entry, + retryStatus: status, + providerErrors: nextErrors, + }) }, ) @@ -213,7 +264,34 @@ export const setSessionErrorAtom = atom( ) => { const entry = get(sessionFamily(args.sessionId)) if (!entry) return - set(sessionFamily(args.sessionId), { ...entry, error: args.error }) + if (!args.error) { + set(sessionFamily(args.sessionId), { ...entry, error: undefined }) + return + } + const message = + typeof args.error.data.message === "string" && args.error.data.message.trim() + ? args.error.data.message.trim() + : `${args.error.name}: ${JSON.stringify(args.error.data)}` + const turnId = + entry.retryStatus?.turnId || + [...(entry.providerErrors ?? [])].reverse().find((item) => item.turnId)?.turnId || + "" + const id = `failed-${turnId || "session"}-${args.error.name}-${message.slice(0, 48)}` + const nextErrors = [...(entry.providerErrors ?? [])] + if (!nextErrors.some((item) => item.message === message && item.phase === "failed")) { + nextErrors.push({ + id, + turnId, + message, + phase: "failed", + code: args.error.name, + }) + } + set(sessionFamily(args.sessionId), { + ...entry, + error: args.error, + providerErrors: nextErrors, + }) }, ) @@ -395,7 +473,8 @@ export const setSessionsAtom = atom( error: existing?.error, setupPhase: existing?.setupPhase, hasUnreadCompletion: existing?.hasUnreadCompletion, - retryStatus: existing?.retryStatus, + retryStatus: existing?.retryStatus, + providerErrors: existing?.providerErrors, }) nextIds.add(session.id) } diff --git a/apps/desktop/src/renderer/atoms/streaming.ts b/apps/desktop/src/renderer/atoms/streaming.ts index 7eae036e..56eb1ae2 100644 --- a/apps/desktop/src/renderer/atoms/streaming.ts +++ b/apps/desktop/src/renderer/atoms/streaming.ts @@ -11,8 +11,8 @@ import { appStore } from "./store" // session's components re-render. // ============================================================ -/** Throttle interval for React notifications — ~20 updates/sec */ -const FLUSH_THROTTLE_MS = 50 +/** Throttle interval for React notifications — ~12.5 updates/sec */ +const FLUSH_THROTTLE_MS = 80 /** Parts keyed by sessionID + messageID -> Part object, only for actively-streaming parts */ let buffer: Record> = {} @@ -194,10 +194,14 @@ export function hasStreamingParts(): boolean { } /** - * Get the current streaming part for a given message + part ID. + * Get the current streaming part for a given session + message + part ID. */ -export function getStreamingPart(messageId: string, partId: string): Part | undefined { - return buffer[messageId]?.[partId] +export function getStreamingPart( + sessionId: string, + messageId: string, + partId: string, +): Part | undefined { + return buffer[partStorageKey(sessionId, messageId)]?.[partId] } /** diff --git a/apps/desktop/src/renderer/components/chat/activity-cue.tsx b/apps/desktop/src/renderer/components/chat/activity-cue.tsx new file mode 100644 index 00000000..d442bb52 --- /dev/null +++ b/apps/desktop/src/renderer/components/chat/activity-cue.tsx @@ -0,0 +1,27 @@ +import { cn } from "@devo/ui/lib/utils" +import type { ReactNode } from "react" + +/** + * Quiet live status: muted typography only. + * Matches Working for / transcript row type: 13px / leading-5. + */ +export function ActivityCue({ + children, + className, +}: { + children: ReactNode + /** Kept for call-site clarity; live vs idle is expressed by surrounding copy. */ + active?: boolean + className?: string +}) { + return ( + + {children} + + ) +} diff --git a/apps/desktop/src/renderer/components/chat/chat-tool-call.tsx b/apps/desktop/src/renderer/components/chat/chat-tool-call.tsx index 4059a87b..6a2c3832 100644 --- a/apps/desktop/src/renderer/components/chat/chat-tool-call.tsx +++ b/apps/desktop/src/renderer/components/chat/chat-tool-call.tsx @@ -18,7 +18,6 @@ import { FileCodeIcon, FileIcon, GlobeIcon, - Loader2Icon, MessageCircleQuestionIcon, PlugIcon, SearchIcon, @@ -702,7 +701,10 @@ function TodoContent({ part }: { part: ToolPart }) { {todo.status === "completed" ? ( ) : todo.status === "in_progress" ? ( - + } - trailing={ - running ? ( - - ) : undefined - } - /> + {description}} /> {tools.map((tool) => ( {children} +}) + +const ProcessTimelineThoughtRow = memo(function ProcessTimelineThoughtRow({ + rowId, + part, + isStreaming, + defaultExpandAll, + expanded, + onToggleRow, +}: { + rowId: string + part: Extract["part"] + isStreaming: boolean + defaultExpandAll: boolean + expanded?: boolean + onToggleRow?: (rowId: string, open: boolean) => void +}) { + const handleOpenChange = useCallback( + (open: boolean) => { + onToggleRow?.(rowId, open) + }, + [onToggleRow, rowId], + ) + + return ( + + ) +}) + +const ProcessTimelineToolRow = memo(function ProcessTimelineToolRow({ + rowId, + part, + defaultExpandAll, + expanded, + onToggleRow, + onDeleteToolPart, + projectRoot, + turnHasError, + working, +}: { + rowId: string + part: ToolPart + defaultExpandAll: boolean + expanded?: boolean + onToggleRow?: (rowId: string, open: boolean) => void + onDeleteToolPart?: (part: ToolPart) => Promise + projectRoot?: string | null + turnHasError?: boolean + working: boolean +}) { + const handleOpenChange = useCallback( + (open: boolean) => { + onToggleRow?.(rowId, open) + }, + [onToggleRow, rowId], + ) + + return ( + + ) +}) + +const ProcessTimelineToolGroupRow = memo(function ProcessTimelineToolGroupRow({ + rowId, + category, + tools, + defaultOpen, + expanded, + onToggleRow, + projectRoot, + working, +}: { + rowId: string + category: ToolCategory + tools: ToolPart[] + defaultOpen: boolean + expanded?: boolean + onToggleRow?: (rowId: string, open: boolean) => void + projectRoot?: string | null + working: boolean +}) { + const handleOpenChange = useCallback( + (open: boolean) => { + onToggleRow?.(rowId, open) + }, + [onToggleRow, rowId], + ) + + return ( + + ) +}) + export interface ProcessTimelineViewProps { items: ProcessTimelineItem[] orderedParts: ProcessTimelineInput[] @@ -104,53 +217,54 @@ export const ProcessTimelineView = memo(function ProcessTimelineView({ const rowId = processTimelineRowId(item, index) if (item.kind === "text") { - return
{renderText(item)}
+ return ( + {renderText(item)} + ) } if (item.kind === "thought") { const isStreaming = working && isReasoningPartActivelyStreaming(orderedParts, item.part) return ( - onToggleRow(rowId, open) : undefined - } - open={expandedRowIds ? expandedRowIds.has(rowId) : undefined} + onToggleRow={onToggleRow} part={item.part} + rowId={rowId} /> ) } if (item.kind === "tool") { return ( - onToggleRow(rowId, open) : undefined - } + defaultExpandAll={defaultExpandAll} + expanded={expandedRowIds ? expandedRowIds.has(rowId) : undefined} + onDeleteToolPart={onDeleteToolPart} + onToggleRow={onToggleRow} part={item.part} projectRoot={projectRoot} + rowId={rowId} turnHasError={turnHasError} - turnWorking={working} + working={working} /> ) } return ( - onToggleRow(rowId, open) : undefined} - open={expandedRowIds ? expandedRowIds.has(rowId) : undefined} + expanded={expandedRowIds ? expandedRowIds.has(rowId) : undefined} + onToggleRow={onToggleRow} projectRoot={projectRoot} + rowId={rowId} tools={item.tools} - turnWorking={working} + working={working} /> ) })} diff --git a/apps/desktop/src/renderer/components/chat/process-timeline.test.ts b/apps/desktop/src/renderer/components/chat/process-timeline.test.ts index 6ff45702..df13f7b6 100644 --- a/apps/desktop/src/renderer/components/chat/process-timeline.test.ts +++ b/apps/desktop/src/renderer/components/chat/process-timeline.test.ts @@ -1,6 +1,10 @@ import { describe, expect, test } from "bun:test" import type { ReasoningPart, ToolPart } from "../../lib/types" -import { buildProcessTimeline, isReasoningPartActivelyStreaming } from "./process-timeline" +import { + buildProcessTimeline, + isReasoningPartActivelyStreaming, + processTimelineRowId, +} from "./process-timeline" function reasoning(id: string): { kind: "reasoning"; part: ReasoningPart } { return { @@ -82,3 +86,14 @@ describe("isReasoningPartActivelyStreaming", () => { expect(isReasoningPartActivelyStreaming(parts, reasoningWithoutEnd("r1").part)).toBe(false) }) }) + +describe("processTimelineRowId", () => { + test("uses stable tool-group ids from category and tool ids", () => { + const items = buildProcessTimeline([tool("t1"), tool("t2")]) + const group = items.find((item) => item.kind === "tool-group") + expect(group).toBeTruthy() + if (!group || group.kind !== "tool-group") return + expect(processTimelineRowId(group, 0)).toBe(`group-${group.category}-t1+t2`) + expect(processTimelineRowId(group, 99)).toBe(`group-${group.category}-t1+t2`) + }) +}) diff --git a/apps/desktop/src/renderer/components/chat/process-timeline.ts b/apps/desktop/src/renderer/components/chat/process-timeline.ts index 21e41818..6ea86165 100644 --- a/apps/desktop/src/renderer/components/chat/process-timeline.ts +++ b/apps/desktop/src/renderer/components/chat/process-timeline.ts @@ -94,7 +94,9 @@ export function processTimelineRowId(item: ProcessTimelineItem, index: number): return item.part.id case "tool": return item.part.id - case "tool-group": - return `group-${index}-${item.tools[0]?.id ?? index}` + case "tool-group": { + const toolIds = item.tools.map((tool) => tool.id).join("+") + return `group-${item.category}-${toolIds || index}` + } } } diff --git a/apps/desktop/src/renderer/components/chat/provider-error-row.tsx b/apps/desktop/src/renderer/components/chat/provider-error-row.tsx new file mode 100644 index 00000000..c383b28a --- /dev/null +++ b/apps/desktop/src/renderer/components/chat/provider-error-row.tsx @@ -0,0 +1,79 @@ +/** + * Expandable provider/LLM error row — same disclosure chrome as tool calls. + * Scheduled retries show a live countdown; expand reveals the failure reason. + */ +import { memo, useEffect, useState } from "react" +import type { ProviderErrorEntry } from "../../atoms/sessions" +import { + TranscriptDisclosure, + TranscriptDisclosureContent, + TranscriptDisclosureTrigger, +} from "./transcript-disclosure" + +export type { ProviderErrorEntry } + +function remainingBackoffMs(entry: ProviderErrorEntry, nowMs: number): number { + if (entry.phase !== "scheduled") return 0 + const backoff = entry.backoffMs ?? 0 + if (backoff <= 0) return 0 + const started = entry.scheduledAtMs ?? nowMs + return Math.max(0, backoff - (nowMs - started)) +} + +function formatCountdown(ms: number): string { + const totalSeconds = ms / 1000 + if (totalSeconds >= 10) return `${Math.ceil(totalSeconds)}s` + return `${totalSeconds.toFixed(1)}s` +} + +function summaryLabel(entry: ProviderErrorEntry, remainingMs: number, pending: boolean): string { + if (entry.phase === "scheduled") { + const attempt = + entry.attempt != null && entry.attempt > 0 ? ` (attempt ${entry.attempt})` : "" + if (pending && remainingMs > 0) { + return `Provider retry${attempt} · ${formatCountdown(remainingMs)}` + } + return `Provider retry${attempt}` + } + if (entry.code && entry.code !== "Error" && entry.code !== "TurnFailed") { + return entry.code + } + return "Request failed" +} + +export const ProviderErrorRow = memo(function ProviderErrorRow({ + entry, + pending = false, +}: { + entry: ProviderErrorEntry + pending?: boolean +}) { + const [nowMs, setNowMs] = useState(() => Date.now()) + const remainingMs = remainingBackoffMs(entry, nowMs) + const liveCountdown = pending && entry.phase === "scheduled" && (entry.backoffMs ?? 0) > 0 + + useEffect(() => { + if (!liveCountdown) return + setNowMs(Date.now()) + const id = window.setInterval(() => setNowMs(Date.now()), 100) + return () => window.clearInterval(id) + }, [liveCountdown, entry.id, entry.scheduledAtMs, entry.backoffMs]) + + const label = summaryLabel(entry, remainingMs, pending) + return ( + + {label}} + aria-label={`${label}: expand to view details`} + /> + + {entry.code ? ( +

{entry.code}

+ ) : null} +
+					{entry.message}
+				
+
+
+ ) +}) diff --git a/apps/desktop/src/renderer/components/chat/sub-agent-card.tsx b/apps/desktop/src/renderer/components/chat/sub-agent-card.tsx index 9e05f428..199994e8 100644 --- a/apps/desktop/src/renderer/components/chat/sub-agent-card.tsx +++ b/apps/desktop/src/renderer/components/chat/sub-agent-card.tsx @@ -7,7 +7,6 @@ import { ChevronDownIcon, ChevronRightIcon, ChevronUpIcon, - Loader2Icon, MessageCircleQuestionIcon, ShieldAlertIcon, ZapIcon, @@ -232,27 +231,27 @@ export const SubAgentCard = memo(function SubAgentCard({ if (p.type === "tool") { switch (p.tool) { case "task": - lastStatus = "Delegating..." + lastStatus = "Delegating" break case "todowrite": case "todoread": - lastStatus = "Planning..." + lastStatus = "Planning next moves" break case "read": - lastStatus = "Reading files..." + lastStatus = "Reading files" break case "list": case "grep": case "glob": - lastStatus = "Searching codebase..." + lastStatus = "Searching the codebase" break case "webfetch": - lastStatus = "Fetching web content..." + lastStatus = "Fetching from the web" break case "edit": case "write": case "apply_patch": - lastStatus = "Making edits..." + lastStatus = "Editing files" break case "bash": case "shell_command": @@ -260,13 +259,13 @@ export const SubAgentCard = memo(function SubAgentCard({ lastStatus = "" break default: - lastStatus = `Running ${p.tool}...` + lastStatus = `Running ${p.tool}` break } } else if (p.type === "reasoning") { - lastStatus = "Thinking..." + lastStatus = "Planning next moves" } else if (p.type === "text") { - lastStatus = "Composing response..." + lastStatus = "Writing response" } } } @@ -274,7 +273,7 @@ export const SubAgentCard = memo(function SubAgentCard({ return { latestToolParts: toolParts.slice(-3), latestText, - childStatus: lastStatus ?? "Working...", + childStatus: lastStatus ?? "Planning next moves", } }, [childMessages, streamingVersion, sessionId]) @@ -313,7 +312,7 @@ export const SubAgentCard = memo(function SubAgentCard({ @@ -344,8 +343,6 @@ export const SubAgentCard = memo(function SubAgentCard({ {elapsedTime} )} - {isRunning && !childIsWaiting && } - {childIsWaiting && } {sessionId && ( diff --git a/apps/desktop/src/renderer/components/onboarding/steps/provider-setup-step.tsx b/apps/desktop/src/renderer/components/onboarding/steps/provider-setup-step.tsx index bfb05245..d219bbc5 100644 --- a/apps/desktop/src/renderer/components/onboarding/steps/provider-setup-step.tsx +++ b/apps/desktop/src/renderer/components/onboarding/steps/provider-setup-step.tsx @@ -71,7 +71,8 @@ export function ProviderSetupStep({ onComplete, onSkip }: ProviderSetupStepProps }, [allProviders, connectedIds]) const zenIsConnected = connectedIds.has(ZEN_PROVIDER_ID) - const zenHasApiKey = zenIsConnected && zenProvider !== null && !isZenFreeTier(zenProvider.models) + const zenHasApiKey = + zenIsConnected && zenProvider !== null && !isZenFreeTier(zenProvider.models ?? {}) const reload = useCallback(() => { reloadCatalog() @@ -163,8 +164,13 @@ export function ProviderSetupStep({ onComplete, onSkip }: ProviderSetupStepProps className="group flex items-center gap-3 rounded-xl border border-border bg-background px-4 py-3 text-left transition-all hover:border-primary/50 hover:bg-accent" > -
+
{provider.name} + {typeof provider.description === "string" && provider.description.trim() ? ( + + {provider.description} + + ) : null} {isConnected && ( Connected @@ -221,10 +227,10 @@ function ZenFeaturedCard({ hasApiKey: boolean onConnect: () => void }) { - const freeModelCount = Object.values(provider.models).filter( + const freeModelCount = Object.values(provider.models ?? {}).filter( (m) => (m as { cost?: { input?: number } }).cost?.input === 0, ).length - const totalModelCount = Object.keys(provider.models).length + const totalModelCount = Object.keys(provider.models ?? {}).length return ( { replacesOverviewButtonInHeader: !agentDetailSource.includes(" 0 ? usageUsed") && + contextUsageSource.includes("occupancy?.totalTokens"), alignsToConversationSurface: contextUsageSource.includes("data-conversation-surface=") && chatViewSource.includes("data-conversation-surface={agent.sessionId}") && @@ -95,6 +98,7 @@ describe("SessionMetricsBar top timer wiring", () => { replacesOverviewButtonInHeader: true, opensPromptBreakdown: true, usesOccupancyCategories: true, + prefersLiveUsageForFill: true, alignsToConversationSurface: true, sitsFlushToTop: true, matchesAppPopoverChrome: true, diff --git a/apps/desktop/src/renderer/components/settings/about-settings.tsx b/apps/desktop/src/renderer/components/settings/about-settings.tsx index be048b16..086ac723 100644 --- a/apps/desktop/src/renderer/components/settings/about-settings.tsx +++ b/apps/desktop/src/renderer/components/settings/about-settings.tsx @@ -23,7 +23,7 @@ export function AboutSettings() { }, []) return ( -
+
diff --git a/apps/desktop/src/renderer/components/settings/connect-provider-dialog.tsx b/apps/desktop/src/renderer/components/settings/connect-provider-dialog.tsx index 3e4f3b45..195a3a61 100644 --- a/apps/desktop/src/renderer/components/settings/connect-provider-dialog.tsx +++ b/apps/desktop/src/renderer/components/settings/connect-provider-dialog.tsx @@ -1083,10 +1083,11 @@ function ZenSetupView({ const [apiKey, setApiKey] = useState("") const isLoading = state.status === "loading" - const freeModelCount = Object.values(provider.models).filter( + const models = provider.models ?? {} + const freeModelCount = Object.values(models).filter( (m) => (m as { cost?: { input?: number } }).cost?.input === 0, ).length - const totalModelCount = Object.keys(provider.models).length + const totalModelCount = Object.keys(models).length const handleSubmit = useCallback( (e: React.FormEvent) => { diff --git a/apps/desktop/src/renderer/components/settings/connection-detail-dialog.tsx b/apps/desktop/src/renderer/components/settings/connection-detail-dialog.tsx new file mode 100644 index 00000000..40472b49 --- /dev/null +++ b/apps/desktop/src/renderer/components/settings/connection-detail-dialog.tsx @@ -0,0 +1,205 @@ +/** + * Dialog showing a connected provider's model list. + * + * Allows adding models, removing individual models, and triggering + * model discovery from the provider's /models endpoint. + */ + +import type { CatalogModelInfo, CatalogProviderInfo } from "@devo-ai/sdk/v2/client" +import { Badge } from "@devo/ui/components/badge" +import { Button } from "@devo/ui/components/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@devo/ui/components/dialog" +import { Input } from "@devo/ui/components/input" +import { Spinner } from "@devo/ui/components/spinner" +import { PlusIcon, RefreshCwIcon, SearchIcon, Trash2Icon } from "lucide-react" +import { useCallback, useMemo, useState } from "react" +import { getBaseClient } from "../../services/connection-manager" +import { + effectiveContextWindowTokens, + formatContextWindowLabel, +} from "../../lib/providers" +import { ModelEditDialog } from "./model-edit-dialog" +import { ProviderIcon } from "./provider-icon" + +interface ConnectionDetailDialogProps { + provider: CatalogProviderInfo + connectionModels: Record + open: boolean + onOpenChange: (open: boolean) => void + onChanged: () => void +} + +export function ConnectionDetailDialog({ + provider, + connectionModels, + open, + onOpenChange, + onChanged, +}: ConnectionDetailDialogProps) { + const [search, setSearch] = useState("") + const [discovering, setDiscovering] = useState(false) + const [removingModel, setRemovingModel] = useState(null) + const [addingModel, setAddingModel] = useState(false) + + const models = useMemo(() => { + const entries = Object.entries(connectionModels) + if (!search.trim()) return entries + const lower = search.toLowerCase() + return entries.filter( + ([id, m]) => + id.toLowerCase().includes(lower) || + (m.name ?? "").toLowerCase().includes(lower), + ) + }, [connectionModels, search]) + + const handleDiscover = useCallback(async () => { + setDiscovering(true) + try { + const client = getBaseClient() + if (!client) return + await client.provider.discover({ + providerId: provider.id, + forceRefresh: true, + }) + onChanged() + } finally { + setDiscovering(false) + } + }, [provider.id, onChanged]) + + const handleRemoveModel = useCallback( + async (modelId: string) => { + setRemovingModel(modelId) + try { + const client = getBaseClient() + if (!client) return + await client.provider.modelRemove({ + providerId: provider.id, + modelId, + }) + onChanged() + } finally { + setRemovingModel(null) + } + }, + [provider.id, onChanged], + ) + + return ( + <> + + + +
+ + {provider.name} + Connected +
+ + {Object.keys(connectionModels).length} model + {Object.keys(connectionModels).length !== 1 ? "s" : ""} in this Connection. + {provider.baseUrl ? ` Endpoint: ${provider.baseUrl}` : ""} + +
+ + {/* Toolbar */} +
+
+ + setSearch(e.target.value)} + className="h-8 pl-8 text-[13px]" + /> +
+ + +
+ + {/* Model list */} +
+ {models.length === 0 ? ( +
+ {search.trim() ? `No models match "${search}"` : "No models in this Connection."} +
+ ) : ( + models.map(([modelId, model]) => { + const contextLabel = formatContextWindowLabel( + effectiveContextWindowTokens(model), + ) + return ( +
+
+

{model.name ?? modelId}

+

+ {provider.id}/{modelId} +

+
+ {contextLabel && ( + + {contextLabel} + + )} + +
+ ) + }) + )} +
+ + + + +
+
+ + {addingModel && ( + { if (!open) setAddingModel(false) }} + onSaved={onChanged} + /> + )} + + ) +} diff --git a/apps/desktop/src/renderer/components/settings/custom-provider-dialog.tsx b/apps/desktop/src/renderer/components/settings/custom-provider-dialog.tsx new file mode 100644 index 00000000..d3155bc9 --- /dev/null +++ b/apps/desktop/src/renderer/components/settings/custom-provider-dialog.tsx @@ -0,0 +1,351 @@ +/** + * Dialog for creating or editing a custom provider Connection. + * + * All fields are user-editable: id, name, base URL, wire API, API key, + * models, and optional request headers. + */ + +import type { CatalogModelInfo, CatalogProviderInfo, CatalogWireApi } from "@devo-ai/sdk/v2/client" +import { Button } from "@devo/ui/components/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@devo/ui/components/dialog" +import { Input } from "@devo/ui/components/input" +import { Label } from "@devo/ui/components/label" +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectTrigger, + SelectValue, +} from "@devo/ui/components/select" +import { Spinner } from "@devo/ui/components/spinner" +import { PlusIcon, Trash2Icon } from "lucide-react" +import { useCallback, useState } from "react" +import { getBaseClient } from "../../services/connection-manager" + +const WIRE_API_OPTIONS: Array<{ value: CatalogWireApi; label: string }> = [ + { value: "openai_chat_completions", label: "OpenAI Chat Completions" }, + { value: "openai_responses", label: "OpenAI Responses" }, + { value: "anthropic_messages", label: "Anthropic Messages" }, +] + +interface CustomProviderDialogProps { + /** Pass an existing provider to edit; omit for new. */ + provider?: CatalogProviderInfo + open: boolean + onOpenChange: (open: boolean) => void + onSaved: () => void +} + +interface ModelEntry { + id: string + name: string +} + +interface HeaderEntry { + key: string + value: string +} + +export function CustomProviderDialog({ + provider: existing, + open, + onOpenChange, + onSaved, +}: CustomProviderDialogProps) { + const isEdit = !!existing + + const [providerId, setProviderId] = useState(existing?.id ?? "") + const [displayName, setDisplayName] = useState(existing?.name ?? "") + const [baseUrl, setBaseUrl] = useState(existing?.baseUrl ?? "") + const [wireApi, setWireApi] = useState( + existing?.wireApis[0] ?? "openai_chat_completions", + ) + const [apiKey, setApiKey] = useState("") + const [models, setModels] = useState(() => { + if (!existing) return [{ id: "", name: "" }] + return Object.entries(existing.models ?? {}).map(([id, m]) => ({ + id, + name: m.name ?? "", + })) + }) + const [headers, setHeaders] = useState(() => { + if (!existing?.headers) return [] + return Object.entries(existing.headers).map(([key, value]) => ({ key, value })) + }) + const [saving, setSaving] = useState(false) + const [error, setError] = useState(null) + + const addModel = useCallback(() => { + setModels((prev) => [...prev, { id: "", name: "" }]) + }, []) + + const removeModel = useCallback((index: number) => { + setModels((prev) => prev.filter((_, i) => i !== index)) + }, []) + + const updateModel = useCallback((index: number, field: "id" | "name", value: string) => { + setModels((prev) => prev.map((m, i) => (i === index ? { ...m, [field]: value } : m))) + }, []) + + const addHeader = useCallback(() => { + setHeaders((prev) => [...prev, { key: "", value: "" }]) + }, []) + + const removeHeader = useCallback((index: number) => { + setHeaders((prev) => prev.filter((_, i) => i !== index)) + }, []) + + const updateHeader = useCallback((index: number, field: "key" | "value", value: string) => { + setHeaders((prev) => prev.map((h, i) => (i === index ? { ...h, [field]: value } : h))) + }, []) + + const handleSave = useCallback(async () => { + const id = providerId.trim() + if (!id) { + setError("Provider ID is required") + return + } + if (!baseUrl.trim()) { + setError("Base URL is required") + return + } + + setSaving(true) + setError(null) + try { + const client = getBaseClient() + if (!client) throw new Error("Not connected to server") + + const catalogModels: Record = {} + for (const m of models) { + const mid = m.id.trim() + if (mid) { + catalogModels[mid] = { name: m.name.trim() || undefined } + } + } + + const catalogHeaders: Record = {} + for (const h of headers) { + if (h.key.trim()) { + catalogHeaders[h.key.trim()] = h.value + } + } + + const provider: CatalogProviderInfo = { + id, + name: displayName.trim() || id, + baseUrl: baseUrl.trim(), + wireApis: [wireApi], + models: catalogModels, + enabled: true, + ...(Object.keys(catalogHeaders).length > 0 ? { headers: catalogHeaders } : {}), + } + + await client.provider.upsert({ + provider, + ...(apiKey.trim() ? { apiKey: apiKey.trim() } : {}), + }) + + onOpenChange(false) + onSaved() + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to save provider") + } finally { + setSaving(false) + } + }, [providerId, displayName, baseUrl, wireApi, apiKey, models, headers, onOpenChange, onSaved]) + + return ( + + + + {isEdit ? "Edit Custom Provider" : "Add Custom Provider"} + + Configure an OpenAI-compatible or Anthropic-compatible provider. + + + +
+ {/* Provider ID */} +
+ + setProviderId(e.target.value)} + disabled={isEdit || saving} + /> +

+ Lowercase letters, numbers, hyphens, or underscores. +

+
+ + {/* Display name */} +
+ + setDisplayName(e.target.value)} + disabled={saving} + /> +
+ + {/* Base URL */} +
+ + setBaseUrl(e.target.value)} + disabled={saving} + /> +
+ + {/* Wire API */} +
+ + +
+ + {/* API key */} +
+ + setApiKey(e.target.value)} + disabled={saving} + /> +

+ Optional if you authenticate via request headers. +

+
+ + {/* Models */} +
+ + {models.map((m, i) => ( +
+ updateModel(i, "id", e.target.value)} + className="flex-1" + disabled={saving} + /> + updateModel(i, "name", e.target.value)} + className="flex-1" + disabled={saving} + /> + +
+ ))} + +
+ + {/* Headers */} +
+ + {headers.map((h, i) => ( +
+ updateHeader(i, "key", e.target.value)} + className="flex-1" + disabled={saving} + /> + updateHeader(i, "value", e.target.value)} + className="flex-1" + disabled={saving} + /> + +
+ ))} + +
+ + {error &&

{error}

} +
+ + + + + +
+
+ ) +} diff --git a/apps/desktop/src/renderer/components/settings/general-settings.tsx b/apps/desktop/src/renderer/components/settings/general-settings.tsx index e2890550..c41dc5f4 100644 --- a/apps/desktop/src/renderer/components/settings/general-settings.tsx +++ b/apps/desktop/src/renderer/components/settings/general-settings.tsx @@ -22,7 +22,7 @@ const isElectron = typeof window !== "undefined" && "devo" in window export function GeneralSettings() { return ( -
+
diff --git a/apps/desktop/src/renderer/components/settings/mcp-settings.tsx b/apps/desktop/src/renderer/components/settings/mcp-settings.tsx index f2e6a262..40685488 100644 --- a/apps/desktop/src/renderer/components/settings/mcp-settings.tsx +++ b/apps/desktop/src/renderer/components/settings/mcp-settings.tsx @@ -129,7 +129,7 @@ export function McpSettings({ type="button" size="sm" variant="secondary" - className="h-8 rounded-full px-3" + className="h-7" disabled={openingConfig} onClick={() => void openMcpConfig()} aria-label="Add MCP" @@ -140,7 +140,7 @@ export function McpSettings({ ) return ( -
+
{!embedded && ( {enabled && expandedThis && ( -
+
{toolsError ? (

Failed to load tools: {String(toolsError)}

) : toolsLoading ? ( diff --git a/apps/desktop/src/renderer/components/settings/model-edit-dialog.tsx b/apps/desktop/src/renderer/components/settings/model-edit-dialog.tsx new file mode 100644 index 00000000..e4bddbc0 --- /dev/null +++ b/apps/desktop/src/renderer/components/settings/model-edit-dialog.tsx @@ -0,0 +1,1002 @@ +/** + * Model editor dialog — basic fields + collapsible advanced settings. + * + * Wire API is a model-level property (overrides provider default). + * Model ID is editable; renaming rewrites the nested models map key. + * Persists via provider/upsert. + */ + +import type { + CatalogModelInfo, + CatalogProviderInfo, + CatalogWireApi, + InputModality, + ProviderModelVariant, + ReasoningCapability, + ReasoningEffort, + ReasoningLevelChoice, +} from "@devo-ai/sdk/v2/client" +import { Button } from "@devo/ui/components/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@devo/ui/components/dialog" +import { Input } from "@devo/ui/components/input" +import { Label } from "@devo/ui/components/label" +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectTrigger, + SelectValue, +} from "@devo/ui/components/select" +import { Spinner } from "@devo/ui/components/spinner" +import { Switch } from "@devo/ui/components/switch" +import { Textarea } from "@devo/ui/components/textarea" +import { cn } from "@devo/ui/lib/utils" +import { ChevronDownIcon, PlusIcon, Trash2Icon } from "lucide-react" +import { useCallback, useMemo, useState } from "react" +import { getBaseClient } from "../../services/connection-manager" +import { effectiveContextWindowTokens, contextWindowPercentFromAbsolute } from "../../lib/providers" + +const WIRE_API_OPTIONS: Array<{ value: CatalogWireApi; label: string }> = [ + { value: "openai_chat_completions", label: "OpenAI Chat Completions" }, + { value: "openai_responses", label: "OpenAI Responses" }, + { value: "anthropic_messages", label: "Anthropic Messages" }, +] + +const MODALITY_OPTIONS: InputModality[] = ["text", "image"] +const MODEL_ID_PATTERN = /^[a-z0-9][a-z0-9._:/-]*$/i +const EFFORT_LEVELS: ReasoningEffort[] = ["none", "minimal", "low", "medium", "high", "xhigh", "max"] +const LEVEL_CHOICES: ReasoningLevelChoice[] = ["off", ...EFFORT_LEVELS] + +interface HeaderEntry { + key: string + value: string +} + +type ReasoningMode = "unsupported" | "toggle" | "levels" + +interface EffortEncodingDraft { + requestModel: string + requestBody: string + headers: HeaderEntry[] +} + +function isReasoningLevelChoice(value: string): value is ReasoningLevelChoice { + return value === "off" || EFFORT_LEVELS.includes(value as ReasoningEffort) +} + +function capabilityMode(capability: ReasoningCapability | null | undefined): ReasoningMode { + if (capability == null || capability === "unsupported") return "unsupported" + if (capability === "toggle") return "toggle" + if (typeof capability === "object" && "levels" in capability) return "levels" + // Legacy wire form before server/protocol migration. + if (typeof capability === "object" && "toggle_with_levels" in (capability as object)) { + return "levels" + } + return "unsupported" +} + +function capabilityLevels( + capability: ReasoningCapability | null | undefined, +): ReasoningLevelChoice[] { + if (capability == null || typeof capability !== "object") return [] + if ("levels" in capability) { + return capability.levels.filter(isReasoningLevelChoice) + } + const legacy = (capability as { toggle_with_levels?: ReasoningEffort[] }).toggle_with_levels + if (Array.isArray(legacy)) { + const levels: ReasoningLevelChoice[] = ["off"] + for (const effort of legacy) { + if (!levels.includes(effort)) levels.push(effort) + } + return levels + } + return [] +} + +function buildReasoningCapability( + mode: ReasoningMode, + levels: ReasoningLevelChoice[], +): ReasoningCapability | undefined { + switch (mode) { + case "unsupported": + return "unsupported" + case "toggle": + return "toggle" + case "levels": + return { levels: levels.length > 0 ? levels : ["medium"] } + } +} + +function effortOptionValues(mode: ReasoningMode, levels: ReasoningLevelChoice[]): string[] { + switch (mode) { + case "unsupported": + return [] + case "toggle": + return ["off", "on"] + case "levels": + return levels + } +} + +function emptyEffortEncoding(): EffortEncodingDraft { + return { requestModel: "", requestBody: "", headers: [] } +} + +function encodingFromVariant(variant: ProviderModelVariant | undefined): EffortEncodingDraft { + if (!variant) return emptyEffortEncoding() + return { + requestModel: variant.requestModel ?? "", + requestBody: formatJsonValue(variant.request), + headers: Object.entries(variant.headers ?? {}).map(([key, value]) => ({ key, value })), + } +} + +function encodingIsEmpty(encoding: EffortEncodingDraft): boolean { + return ( + !encoding.requestModel.trim() && + !encoding.requestBody.trim() && + !encoding.headers.some((header) => header.key.trim()) + ) +} + +function parseEffortEncoding(encoding: EffortEncodingDraft): ProviderModelVariant | undefined { + if (encodingIsEmpty(encoding)) return undefined + const nextHeaders: Record = {} + for (const header of encoding.headers) { + const key = header.key.trim() + if (!key) continue + nextHeaders[key] = header.value + } + return { + requestModel: encoding.requestModel.trim() || undefined, + request: parseJsonObject(encoding.requestBody), + headers: Object.keys(nextHeaders).length > 0 ? nextHeaders : undefined, + } +} + +function wireLabel(api: CatalogWireApi | undefined, fallback?: CatalogWireApi): string { + const value = api ?? fallback + if (!value) return "—" + return WIRE_API_OPTIONS.find((o) => o.value === value)?.label ?? value.replace(/_/g, " ") +} + +function parseOptionalNumber(raw: string): number | undefined { + const trimmed = raw.trim() + if (!trimmed) return undefined + const n = Number(trimmed) + return Number.isFinite(n) ? n : undefined +} + +function formatJsonValue(value: unknown): string { + if (value == null) return "" + try { + return JSON.stringify(value, null, 2) + } catch { + return "" + } +} + +/** Empty string clears the field; otherwise require a JSON object. */ +function parseJsonObject(raw: string): unknown | undefined { + const trimmed = raw.trim() + if (!trimmed) return undefined + const parsed: unknown = JSON.parse(trimmed) + if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error("Request body must be a JSON object") + } + return parsed +} + +export function effectiveWireApi( + model: CatalogModelInfo, + provider: CatalogProviderInfo, +): CatalogWireApi { + return model.wireApi ?? provider.wireApis[0] ?? "openai_chat_completions" +} + +interface ModelEditDialogProps { + provider: CatalogProviderInfo + /** Omit or leave empty when creating a new model. */ + modelId?: string + model?: CatalogModelInfo + mode?: "edit" | "create" + open: boolean + onOpenChange: (open: boolean) => void + onSaved: () => void +} + +export function ModelEditDialog({ + provider, + modelId: originalModelId = "", + model = {}, + mode = "edit", + open, + onOpenChange, + onSaved, +}: ModelEditDialogProps) { + const isCreate = mode === "create" + const providerDefaultWire = provider.wireApis[0] ?? "openai_chat_completions" + const existingModels = provider.models ?? {} + + const [modelId, setModelId] = useState(originalModelId) + const [displayName, setDisplayName] = useState(model.name ?? "") + const [wireApi, setWireApi] = useState( + isCreate ? providerDefaultWire : effectiveWireApi(model, provider), + ) + const [enabled, setEnabled] = useState(model.enabled !== false) + const [advancedOpen, setAdvancedOpen] = useState(false) + + const [contextWindow, setContextWindow] = useState(() => { + const effective = effectiveContextWindowTokens(model) + return effective != null ? String(effective) : "" + }) + const [maxTokens, setMaxTokens] = useState(model.maxTokens != null ? String(model.maxTokens) : "") + const [temperature, setTemperature] = useState( + model.temperature != null ? String(model.temperature) : "", + ) + const [topP, setTopP] = useState(model.topP != null ? String(model.topP) : "") + const [topK, setTopK] = useState(model.topK != null ? String(model.topK) : "") + const [modalities, setModalities] = useState( + model.inputModalities?.length ? [...model.inputModalities] : ["text"], + ) + const [headers, setHeaders] = useState(() => + Object.entries(model.headers ?? {}).map(([key, value]) => ({ key, value })), + ) + const [requestBody, setRequestBody] = useState(() => formatJsonValue(model.request)) + const [optionsBody, setOptionsBody] = useState(() => formatJsonValue(model.options)) + const [reasoningMode, setReasoningMode] = useState(() => + capabilityMode(model.reasoningCapability), + ) + const [reasoningLevels, setReasoningLevels] = useState(() => + capabilityLevels(model.reasoningCapability), + ) + const [defaultReasoningSelection, setDefaultReasoningSelection] = useState( + () => model.defaultReasoningSelection ?? "", + ) + const [effortEncodings, setEffortEncodings] = useState>( + () => { + const next: Record = {} + for (const [key, variant] of Object.entries(model.variants ?? {})) { + next[key] = encodingFromVariant(variant) + } + return next + }, + ) + + const [saving, setSaving] = useState(false) + const [error, setError] = useState(null) + + const addHeader = useCallback(() => { + setHeaders((prev) => [...prev, { key: "", value: "" }]) + }, []) + + const removeHeader = useCallback((index: number) => { + setHeaders((prev) => prev.filter((_, i) => i !== index)) + }, []) + + const updateHeader = useCallback((index: number, field: "key" | "value", value: string) => { + setHeaders((prev) => prev.map((h, i) => (i === index ? { ...h, [field]: value } : h))) + }, []) + + const toggleModality = useCallback((mod: InputModality) => { + setModalities((prev) => { + if (prev.includes(mod)) { + const next = prev.filter((m) => m !== mod) + return next.length > 0 ? next : prev + } + return [...prev, mod] + }) + }, []) + + const toggleReasoningLevel = useCallback((level: ReasoningLevelChoice) => { + setReasoningLevels((prev) => { + if (prev.includes(level)) return prev.filter((item) => item !== level) + return [...prev, level] + }) + }, []) + + const effortOptions = useMemo( + () => effortOptionValues(reasoningMode, reasoningLevels), + [reasoningMode, reasoningLevels], + ) + + const updateEffortEncoding = useCallback( + (selection: string, patch: Partial) => { + setEffortEncodings((prev) => ({ + ...prev, + [selection]: { + ...(prev[selection] ?? emptyEffortEncoding()), + ...patch, + }, + })) + }, + [], + ) + + const handleSave = useCallback(async () => { + const nextId = modelId.trim() + if (!nextId) { + setError("Model ID is required") + return + } + if (!MODEL_ID_PATTERN.test(nextId)) { + setError("Model ID may use letters, numbers, and . _ : / -") + return + } + if (nextId !== originalModelId && existingModels[nextId]) { + setError(`Model ID "${nextId}" already exists on this provider`) + return + } + if (isCreate && existingModels[nextId]) { + setError(`Model ID "${nextId}" already exists on this provider`) + return + } + + let parsedRequest: unknown | undefined + let parsedOptions: unknown | undefined + try { + parsedRequest = parseJsonObject(requestBody) + parsedOptions = parseJsonObject(optionsBody) + } catch (err) { + setError(err instanceof Error ? err.message : "Invalid JSON") + return + } + + const nextHeaders: Record = {} + for (const header of headers) { + const key = header.key.trim() + if (!key) continue + nextHeaders[key] = header.value + } + + const nextVariants: Record = { + ...(model.variants ?? {}), + } + for (const selection of effortOptions) { + try { + const parsed = parseEffortEncoding(effortEncodings[selection] ?? emptyEffortEncoding()) + if (parsed) nextVariants[selection] = parsed + else delete nextVariants[selection] + } catch (err) { + setError( + err instanceof Error + ? `Effort encoding "${selection}": ${err.message}` + : `Invalid effort encoding for ${selection}`, + ) + return + } + } + + const capability = buildReasoningCapability(reasoningMode, reasoningLevels) + const normalizedDefault = defaultReasoningSelection.trim().toLowerCase() + if ( + normalizedDefault && + effortOptions.length > 0 && + !effortOptions.includes(normalizedDefault) && + normalizedDefault !== "enabled" && + normalizedDefault !== "disabled" + ) { + setError(`Default reasoning must be one of: ${effortOptions.join(", ")}`) + return + } + + setSaving(true) + setError(null) + try { + const client = getBaseClient() + if (!client) throw new Error("Not connected to server") + + const userContextTokens = parseOptionalNumber(contextWindow) + const hardWindow = model.contextWindow + let nextContextWindow: number | undefined + let nextPercent: number | undefined + if (userContextTokens == null) { + // Clear percent overlay → default 95% of hard capacity. + nextContextWindow = hardWindow ?? undefined + nextPercent = undefined + } else if (hardWindow != null && hardWindow > 0) { + nextContextWindow = hardWindow + nextPercent = contextWindowPercentFromAbsolute(hardWindow, userContextTokens) + } else { + // Custom model with no hard window yet: treat entry as hard @ 100%. + nextContextWindow = userContextTokens + nextPercent = 100 + } + const nextModel: CatalogModelInfo = { + ...model, + name: displayName.trim() || undefined, + wireApi, + enabled, + contextWindow: nextContextWindow, + effectiveContextWindowPercent: nextPercent, + maxTokens: parseOptionalNumber(maxTokens), + temperature: parseOptionalNumber(temperature), + topP: parseOptionalNumber(topP), + topK: parseOptionalNumber(topK), + inputModalities: modalities.length > 0 ? modalities : undefined, + headers: Object.keys(nextHeaders).length > 0 ? nextHeaders : undefined, + request: parsedRequest, + options: parsedOptions, + reasoningCapability: capability, + defaultReasoningSelection: normalizedDefault + ? normalizedDefault === "enabled" + ? "on" + : normalizedDefault === "disabled" + ? "off" + : normalizedDefault + : undefined, + defaultReasoningEffort: + normalizedDefault && EFFORT_LEVELS.includes(normalizedDefault as ReasoningEffort) + ? (normalizedDefault as ReasoningEffort) + : model.defaultReasoningEffort, + variants: Object.keys(nextVariants).length > 0 ? nextVariants : undefined, + } + + const wireApis = provider.wireApis.includes(wireApi) + ? provider.wireApis + : [...provider.wireApis, wireApi] + + const updatedModels: Record = { + [nextId]: nextModel, + } + + await client.provider.upsert({ + provider: { + ...provider, + wireApis: wireApis.length > 0 ? wireApis : [wireApi], + // Server merges models by insert; send only this model so we + // do not copy the full template+connection catalog into the overlay. + models: updatedModels, + }, + }) + + // Upsert never deletes missing keys — rename must remove the old id. + if (!isCreate && nextId !== originalModelId) { + await client.provider.modelRemove({ + providerId: provider.id, + modelId: originalModelId, + }) + } + + onOpenChange(false) + onSaved() + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to save model") + } finally { + setSaving(false) + } + }, [ + modelId, + originalModelId, + existingModels, + isCreate, + model, + displayName, + wireApi, + enabled, + contextWindow, + maxTokens, + temperature, + topP, + topK, + modalities, + headers, + requestBody, + optionsBody, + reasoningMode, + reasoningLevels, + defaultReasoningSelection, + effortEncodings, + effortOptions, + provider, + onOpenChange, + onSaved, + ]) + + const advancedSummary = useMemo(() => { + const parts: string[] = [] + if (reasoningMode !== "unsupported") parts.push(`reasoning:${reasoningMode}`) + if (contextWindow.trim()) parts.push(`${Math.round(Number(contextWindow) / 1000)}k ctx`) + if (modalities.length) parts.push(modalities.join("+")) + const headerCount = headers.filter((h) => h.key.trim()).length + if (headerCount > 0) parts.push(`${headerCount} header${headerCount === 1 ? "" : "s"}`) + if (requestBody.trim()) parts.push("request body") + if (optionsBody.trim()) parts.push("options") + const encodingCount = effortOptions.filter( + (selection) => !encodingIsEmpty(effortEncodings[selection] ?? emptyEffortEncoding()), + ).length + if (encodingCount > 0) parts.push(`${encodingCount} effort encoding${encodingCount === 1 ? "" : "s"}`) + return parts.join(" · ") + }, [ + reasoningMode, + contextWindow, + modalities, + headers, + requestBody, + optionsBody, + effortOptions, + effortEncodings, + ]) + + return ( + + +
+ + + {isCreate ? "Add model" : "Edit model"} + + + {provider.name} + {!isCreate && + (originalModelId !== modelId.trim() && modelId.trim() ? ( + <> + {" "} + · renaming{" "} + {originalModelId} + {" → "} + {modelId.trim()} + + ) : ( + <> + {" "} + · {originalModelId} + + ))} + + +
+ +
+
+
+ + setModelId(e.target.value)} + placeholder="provider-facing-model-id" + disabled={saving} + className="h-9 font-mono text-[13px]" + /> +

+ {isCreate + ? "Sent to the provider as the request model." + : "Sent to the provider as the request model. Changing it renames this entry."} +

+
+ +
+ + setDisplayName(e.target.value)} + placeholder={modelId.trim() || originalModelId} + disabled={saving} + className="h-9" + /> +
+ +
+ + +

+ Per-model override. Provider default is {wireLabel(undefined, providerDefaultWire)}. +

+
+ +
+
+

Enabled

+

Show this model in the picker

+
+ +
+ +
+ + +

+ Controls the effort chips in chat. Include off in levels to allow + disabling. Encoding for custom gateways is configured under Advanced. +

+
+ + {reasoningMode === "levels" && ( +
+ +
+ {LEVEL_CHOICES.map((level) => { + const active = reasoningLevels.includes(level) + return ( + + ) + })} +
+
+ )} + + {reasoningMode !== "unsupported" && ( +
+ + +
+ )} +
+ +
+ + + {advancedOpen && ( +
+ + + + + +
+ +
+ {MODALITY_OPTIONS.map((mod) => { + const active = modalities.includes(mod) + return ( + + ) + })} +
+
+ +
+ + {headers.length === 0 ? ( +

+ No custom headers. Merged into HTTP requests for this model. +

+ ) : ( + headers.map((header, index) => ( +
+ updateHeader(index, "key", e.target.value)} + disabled={saving} + className="h-9 flex-1 font-mono text-[13px]" + /> + updateHeader(index, "value", e.target.value)} + disabled={saving} + className="h-9 flex-1 font-mono text-[13px]" + /> + +
+ )) + )} + +
+ +
+ +