From b3637b8ceecfdb383487e4ad346e2caea6ed8a60 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 06:14:50 +0000 Subject: [PATCH] Performance optimization Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_011EnnAGQZcpiAWmUao737hL --- .github/workflows/release.yml | 8 +- build/generate-tray-icon.js | 5 +- docs/architecture.md | 53 + package-lock.json | 93 +- package.json | 7 +- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/src/antigravity.rs | 725 ---- src-tauri/src/antigravity/content.rs | 65 + src-tauri/src/antigravity/mod.rs | 40 + src-tauri/src/antigravity/roots.rs | 99 + src-tauri/src/antigravity/session.rs | 196 + src-tauri/src/antigravity/steps.rs | 127 + src-tauri/src/antigravity/tests.rs | 136 + src-tauri/src/antigravity/wire.rs | 106 + src-tauri/src/codex.rs | 1624 -------- src-tauri/src/codex/exec.rs | 189 + src-tauri/src/codex/items.rs | 219 ++ src-tauri/src/codex/meta.rs | 197 + src-tauri/src/codex/mod.rs | 45 + src-tauri/src/codex/normalize.rs | 160 + src-tauri/src/codex/records.rs | 174 + src-tauri/src/codex/roots.rs | 148 + src-tauri/src/codex/tests.rs | 171 + src-tauri/src/codex/tests_more.rs | 93 + src-tauri/src/codex/titles.rs | 173 + src-tauri/src/codex/tools.rs | 130 + src-tauri/src/codexconnect.rs | 232 -- src-tauri/src/codexconnect/config.rs | 54 + src-tauri/src/codexconnect/connect.rs | 113 + src-tauri/src/codexconnect/mod.rs | 22 + src-tauri/src/codexconnect/tests.rs | 58 + src-tauri/src/commands/autoupdate.rs | 153 + src-tauri/src/commands/config.rs | 157 + src-tauri/src/commands/connect.rs | 195 + src-tauri/src/commands/fmt_tests.rs | 64 + src-tauri/src/commands/gateway.rs | 105 + src-tauri/src/commands/history.rs | 122 + src-tauri/src/commands/history_export.rs | 101 + src-tauri/src/commands/mod.rs | 65 + src-tauri/src/commands/plugins.rs | 105 + src-tauri/src/commands/provider_test.rs | 142 + src-tauri/src/commands/replay.rs | 89 + src-tauri/src/commands/selfcheck.rs | 138 + src-tauri/src/commands/selfcheck_js.rs | 77 + src-tauri/src/commands/update.rs | 132 + src-tauri/src/commands/util.rs | 41 + src-tauri/src/commands/window.rs | 47 + src-tauri/src/copilot.rs | 437 --- src-tauri/src/copilot/meta.rs | 57 + src-tauri/src/copilot/mod.rs | 35 + src-tauri/src/copilot/normalize.rs | 139 + src-tauri/src/copilot/roots.rs | 62 + src-tauri/src/copilot/session.rs | 110 + src-tauri/src/copilot/tests.rs | 64 + src-tauri/src/exporthtml.rs | 952 ----- src-tauri/src/exporthtml/assets.rs | 24 + src-tauri/src/exporthtml/build.rs | 178 + src-tauri/src/exporthtml/html.rs | 56 + src-tauri/src/exporthtml/mod.rs | 22 + src-tauri/src/exporthtml/name.rs | 69 + src-tauri/src/exporthtml/parse.rs | 143 + src-tauri/src/exporthtml/session.rs | 146 + src-tauri/src/exporthtml/shape.rs | 102 + src-tauri/src/exporthtml/tests.rs | 129 + src-tauri/src/exporthtml/tests_more.rs | 152 + src-tauri/src/gateway.rs | 3286 ----------------- src-tauri/src/gateway/capture.rs | 128 + src-tauri/src/gateway/finish.rs | 166 + src-tauri/src/gateway/finish_buffered.rs | 98 + src-tauri/src/gateway/finish_translated.rs | 159 + src-tauri/src/gateway/forward.rs | 191 + src-tauri/src/gateway/handler.rs | 213 ++ src-tauri/src/gateway/history_args.rs | 63 + src-tauri/src/gateway/history_prep.rs | 152 + src-tauri/src/gateway/mock.rs | 104 + src-tauri/src/gateway/mod.rs | 51 + src-tauri/src/gateway/models.rs | 88 + src-tauri/src/gateway/monitor.rs | 66 + src-tauri/src/gateway/prepare.rs | 194 + src-tauri/src/gateway/redact.rs | 43 + src-tauri/src/gateway/responses_history.rs | 112 + src-tauri/src/gateway/retry.rs | 122 + src-tauri/src/gateway/routing.rs | 178 + src-tauri/src/gateway/selftest.rs | 118 + src-tauri/src/gateway/selftest_routing.rs | 57 + src-tauri/src/gateway/selftest_xlate.rs | 154 + src-tauri/src/gateway/session.rs | 61 + src-tauri/src/gateway/signatures.rs | 145 + src-tauri/src/gateway/sse.rs | 102 + src-tauri/src/gateway/state.rs | 148 + src-tauri/src/gateway/stream_passthrough.rs | 166 + src-tauri/src/gateway/stream_transcode.rs | 206 ++ src-tauri/src/gateway/targets.rs | 110 + src-tauri/src/gateway/tests.rs | 124 + src-tauri/src/gateway/tests_history.rs | 189 + src-tauri/src/gateway/tests_routing.rs | 102 + src-tauri/src/gateway/tests_signatures.rs | 196 + src-tauri/src/grok.rs | 537 --- src-tauri/src/grok/meta.rs | 60 + src-tauri/src/grok/mod.rs | 32 + src-tauri/src/grok/normalize.rs | 211 ++ src-tauri/src/grok/roots.rs | 73 + src-tauri/src/grok/session.rs | 124 + src-tauri/src/grok/tests.rs | 69 + src-tauri/src/history.rs | 3112 ---------------- src-tauri/src/history/codexdedupe.rs | 146 + src-tauri/src/history/edit.rs | 189 + src-tauri/src/history/foreign.rs | 103 + src-tauri/src/history/foreign_probe.rs | 68 + src-tauri/src/history/import.rs | 151 + src-tauri/src/history/importpaths.rs | 103 + src-tauri/src/history/jsonl.rs | 128 + src-tauri/src/history/list.rs | 180 + src-tauri/src/history/meta.rs | 168 + src-tauri/src/history/mod.rs | 62 + src-tauri/src/history/norm.rs | 182 + src-tauri/src/history/paths.rs | 132 + src-tauri/src/history/search.rs | 72 + src-tauri/src/history/searchfmt.rs | 136 + src-tauri/src/history/searchscan.rs | 157 + src-tauri/src/history/searchtext.rs | 130 + src-tauri/src/history/selftest.rs | 97 + src-tauri/src/history/session.rs | 148 + src-tauri/src/history/skills.rs | 68 + src-tauri/src/history/subagents.rs | 181 + src-tauri/src/history/tests_foreign.rs | 150 + src-tauri/src/history/tests_misc.rs | 171 + src-tauri/src/history/tests_qoder.rs | 160 + src-tauri/src/history/tests_search.rs | 122 + src-tauri/src/history/text.rs | 87 + src-tauri/src/legacybundle.rs | 51 + src-tauri/src/lib.rs | 2177 +---------- src-tauri/src/plugin.rs | 959 ----- src-tauri/src/plugin/git.rs | 163 + src-tauri/src/plugin/install.rs | 45 + src-tauri/src/plugin/lifecycle.rs | 122 + src-tauri/src/plugin/manager.rs | 114 + src-tauri/src/plugin/manifest.rs | 127 + src-tauri/src/plugin/manifest_meta.rs | 94 + src-tauri/src/plugin/mod.rs | 25 + src-tauri/src/plugin/providers.rs | 79 + src-tauri/src/plugin/status.rs | 95 + src-tauri/src/plugin/util.rs | 183 + src-tauri/src/popover.rs | 116 + src-tauri/src/protocol/anthropic.rs | 479 --- src-tauri/src/protocol/anthropic/blocks.rs | 143 + src-tauri/src/protocol/anthropic/decode.rs | 90 + src-tauri/src/protocol/anthropic/encode.rs | 144 + src-tauri/src/protocol/anthropic/mod.rs | 22 + src-tauri/src/protocol/anthropic/tests.rs | 97 + src-tauri/src/protocol/codec.rs | 123 + src-tauri/src/protocol/codex_history.rs | 2148 ----------- .../src/protocol/codex_history/cache_items.rs | 91 + .../src/protocol/codex_history/inner_index.rs | 110 + .../protocol/codex_history/inner_insert.rs | 138 + .../src/protocol/codex_history/materialize.rs | 119 + src-tauri/src/protocol/codex_history/merge.rs | 116 + src-tauri/src/protocol/codex_history/mod.rs | 38 + .../src/protocol/codex_history/resolve.rs | 203 + .../src/protocol/codex_history/sizing.rs | 44 + src-tauri/src/protocol/codex_history/store.rs | 164 + .../codex_history/tests_call_fallback.rs | 175 + .../codex_history/tests_continuation.rs | 135 + .../protocol/codex_history/tests_eviction.rs | 163 + .../src/protocol/codex_history/tests_hops.rs | 174 + .../protocol/codex_history/tests_limits.rs | 172 + .../codex_history/tests_materialize.rs | 167 + .../protocol/codex_history/tests_native.rs | 163 + src-tauri/src/protocol/codex_history/types.rs | 82 + src-tauri/src/protocol/mod.rs | 527 +-- src-tauri/src/protocol/openai_chat_client.rs | 237 -- .../src/protocol/openai_chat_client/decode.rs | 106 + .../src/protocol/openai_chat_client/encode.rs | 88 + .../src/protocol/openai_chat_client/mod.rs | 12 + .../src/protocol/openai_chat_client/tests.rs | 40 + src-tauri/src/protocol/openai_responses.rs | 2722 -------------- .../openai_responses/decode_request.rs | 215 ++ .../openai_responses/decode_response.rs | 93 + .../openai_responses/encode_request.rs | 105 + .../openai_responses/encode_response.rs | 103 + .../protocol/openai_responses/encode_sse.rs | 149 + .../src/protocol/openai_responses/helpers.rs | 87 + .../src/protocol/openai_responses/history.rs | 131 + .../src/protocol/openai_responses/mod.rs | 65 + .../src/protocol/openai_responses/parts.rs | 118 + .../openai_responses/tests_aliases.rs | 110 + .../openai_responses/tests_collisions.rs | 121 + .../openai_responses/tests_extended.rs | 145 + .../protocol/openai_responses/tests_lite.rs | 148 + .../openai_responses/tests_request.rs | 154 + .../openai_responses/tests_response.rs | 156 + .../openai_responses/tests_validate.rs | 146 + .../protocol/openai_responses/tool_collect.rs | 157 + .../protocol/openai_responses/tool_items.rs | 93 + .../protocol/openai_responses/tool_names.rs | 140 + .../openai_responses/tool_registry.rs | 185 + .../src/protocol/openai_responses/tools.rs | 125 + .../src/protocol/openai_responses/validate.rs | 135 + src-tauri/src/protocol/signatures.rs | 112 + src-tauri/src/protocol/stream.rs | 2447 ------------ src-tauri/src/protocol/stream/ablock.rs | 119 + .../protocol/stream/anthropic_responses.rs | 104 + .../stream/anthropic_responses_block.rs | 108 + .../stream/anthropic_responses_finish.rs | 91 + .../stream/anthropic_responses_push.rs | 167 + .../src/protocol/stream/chat_anthropic.rs | 167 + .../protocol/stream/chat_anthropic_push.rs | 192 + .../src/protocol/stream/chat_responses.rs | 97 + .../protocol/stream/chat_responses_finish.rs | 135 + .../protocol/stream/chat_responses_items.rs | 135 + .../protocol/stream/chat_responses_push.rs | 145 + .../protocol/stream/chat_responses_tools.rs | 111 + src-tauri/src/protocol/stream/common.rs | 42 + src-tauri/src/protocol/stream/mod.rs | 45 + src-tauri/src/protocol/stream/resp_items.rs | 122 + .../stream/tests_anthropic_responses.rs | 68 + .../protocol/stream/tests_chat_anthropic.rs | 120 + .../protocol/stream/tests_chat_responses.rs | 111 + src-tauri/src/protocol/stream/tests_errors.rs | 134 + .../src/protocol/stream/tests_extended.rs | 88 + src-tauri/src/protocol/stream/tests_ids.rs | 190 + src-tauri/src/protocol/stream/transcoder.rs | 123 + src-tauri/src/protocol/tests.rs | 153 + src-tauri/src/protocol/wire.rs | 143 + src-tauri/src/qoder.rs | 1200 ------ src-tauri/src/qoder/exec.rs | 116 + src-tauri/src/qoder/guard.rs | 80 + src-tauri/src/qoder/helper.rs | 145 + src-tauri/src/qoder/limits.rs | 31 + src-tauri/src/qoder/meta.rs | 34 + src-tauri/src/qoder/mod.rs | 32 + src-tauri/src/qoder/normalize.rs | 136 + src-tauri/src/qoder/prefetch.rs | 141 + src-tauri/src/qoder/read.rs | 147 + src-tauri/src/qoder/roots.rs | 48 + src-tauri/src/qoder/tests.rs | 162 + src-tauri/src/qoder/tests_more.rs | 121 + src-tauri/src/qoder/titles.rs | 130 + src-tauri/src/startup.rs | 201 + src-tauri/src/store.rs | 655 ---- src-tauri/src/store/defaults.rs | 24 + src-tauri/src/store/dirs.rs | 130 + src-tauri/src/store/io.rs | 96 + src-tauri/src/store/mod.rs | 25 + src-tauri/src/store/normalize.rs | 201 + src-tauri/src/store/paths.rs | 70 + src-tauri/src/store/tests.rs | 158 + src-tauri/src/tray.rs | 119 + src-tauri/src/trayicon.rs | 207 ++ src-tauri/src/usage.rs | 1137 ------ src-tauri/src/usage/build.rs | 130 + src-tauri/src/usage/claude.rs | 107 + src-tauri/src/usage/codex.rs | 190 + src-tauri/src/usage/diag.rs | 79 + src-tauri/src/usage/diag_probe.rs | 8 + src-tauri/src/usage/mod.rs | 53 + src-tauri/src/usage/model.rs | 95 + src-tauri/src/usage/query.rs | 151 + src-tauri/src/usage/real_data_probe.rs | 70 + src-tauri/src/usage/roots.rs | 78 + src-tauri/src/usage/tests.rs | 173 + src-tauri/src/usage/tests_codex.rs | 78 + src-tauri/src/ziputil.rs | 291 -- src-tauri/src/ziputil/bundle.rs | 47 + src-tauri/src/ziputil/mod.rs | 23 + src-tauri/src/ziputil/read.rs | 80 + src-tauri/src/ziputil/tests.rs | 63 + src-tauri/src/ziputil/write.rs | 98 + src-tauri/tauri.conf.json | 2 +- src/main/bootstrap.js | 102 - src/main/claude.js | 117 - src/main/claudeDesktop.js | 179 - src/main/claudeModels.js | 24 - src/main/codex.js | 723 ---- src/main/countTokens.js | 95 - src/main/export-assets/runtime-analytics.js | 61 + src/main/export-assets/runtime-messages.js | 168 + src/main/export-assets/runtime-render.js | 101 + src/main/export-assets/runtime-ui.js | 104 + src/main/export-assets/runtime.js | 425 --- src/main/exportHtml.js | 309 -- src/main/history.js | 763 ---- src/main/hotpaths.js | 65 - src/main/icon.png | Bin 879934 -> 0 bytes src/main/iconTemplate.png | Bin 241 -> 0 bytes src/main/iconTemplate@2x.png | Bin 449 -> 0 bytes src/main/insights.js | 314 -- src/main/main.js | 1019 ----- src/main/monitor.js | 40 - src/main/preload.js | 76 - src/main/proxy.js | 864 ----- src/main/store.js | 142 - src/main/updater.js | 309 -- src/main/usage.js | 171 - src/main/zipStore.js | 169 - src/renderer/analytics-events.js | 158 + src/renderer/analytics.js | 151 +- src/renderer/conversations.js | 1994 ---------- src/renderer/css/01.css | 137 + src/renderer/css/02.css | 111 + src/renderer/css/03.css | 117 + src/renderer/css/04.css | 113 + src/renderer/css/05.css | 109 + src/renderer/css/06.css | 112 + src/renderer/css/07.css | 115 + src/renderer/css/08.css | 111 + src/renderer/css/09.css | 112 + src/renderer/css/10.css | 115 + src/renderer/css/11.css | 113 + src/renderer/css/12.css | 119 + src/renderer/css/13.css | 110 + src/renderer/css/14.css | 128 + src/renderer/css/15.css | 170 + src/renderer/css/16.css | 114 + src/renderer/css/17.css | 118 + src/renderer/css/18.css | 128 + src/renderer/css/19.css | 111 + src/renderer/css/20.css | 110 + src/renderer/css/21.css | 128 + src/renderer/css/22.css | 112 + src/renderer/css/23.css | 169 + src/renderer/css/24.css | 18 + src/renderer/i18n.js | 51 - src/renderer/index.html | 492 +-- src/renderer/input.css | 2852 +------------- src/renderer/js/core/bridge.js | 129 + src/renderer/js/core/dom.js | 76 + src/renderer/js/core/i18n.js | 84 + src/renderer/{ => js/core}/icons.js | 12 +- src/renderer/js/core/loader.js | 48 + src/renderer/js/core/state.js | 83 + src/renderer/js/core/theme.js | 27 + src/renderer/js/core/toast.js | 71 + src/renderer/js/main.js | 85 + src/renderer/js/popover/heatmap.js | 67 + src/renderer/js/popover/main.js | 65 + src/renderer/js/popover/stats.js | 64 + .../js/views/conversations/actions.js | 130 + src/renderer/js/views/conversations/agents.js | 62 + src/renderer/js/views/conversations/code.js | 78 + .../js/views/conversations/codex-format.js | 84 + src/renderer/js/views/conversations/detail.js | 178 + .../js/views/conversations/events-detail.js | 122 + .../js/views/conversations/events-list.js | 102 + src/renderer/js/views/conversations/format.js | 105 + src/renderer/js/views/conversations/index.js | 49 + src/renderer/js/views/conversations/layout.js | 75 + .../js/views/conversations/list-order.js | 69 + .../js/views/conversations/list-row.js | 71 + src/renderer/js/views/conversations/list.js | 121 + src/renderer/js/views/conversations/live.js | 79 + .../js/views/conversations/message.js | 107 + .../js/views/conversations/meta-edit.js | 154 + src/renderer/js/views/conversations/panels.js | 102 + src/renderer/js/views/conversations/search.js | 181 + src/renderer/js/views/conversations/state.js | 89 + .../js/views/conversations/subagents.js | 97 + .../js/views/conversations/template.js | 75 + src/renderer/js/views/conversations/tools.js | 99 + .../js/views/conversations/tooltip.js | 50 + src/renderer/js/views/conversations/window.js | 91 + .../js/views/monitor/drawer-search.js | 75 + src/renderer/js/views/monitor/drawer.js | 178 + src/renderer/js/views/monitor/feed.js | 50 + src/renderer/js/views/monitor/index.js | 94 + src/renderer/js/views/monitor/log.js | 56 + src/renderer/js/views/monitor/template.js | 61 + src/renderer/js/views/plugins/actions.js | 85 + src/renderer/js/views/plugins/forms.js | 112 + src/renderer/js/views/plugins/git.js | 68 + src/renderer/js/views/plugins/index.js | 149 + src/renderer/js/views/providers/drag.js | 35 + src/renderer/js/views/providers/hero.js | 117 + .../js/views/providers/icon-picker.js | 62 + src/renderer/js/views/providers/icon.js | 35 + src/renderer/js/views/providers/index.js | 151 + .../js/views/providers/modal-template.js | 82 + src/renderer/js/views/providers/modal.js | 180 + src/renderer/js/views/providers/presets.js | 20 + src/renderer/js/views/registry.js | 104 + src/renderer/js/views/settings/about-pane.js | 147 + src/renderer/js/views/settings/conv-font.js | 25 + src/renderer/js/views/settings/data-pane.js | 65 + .../js/views/settings/gateway-pane.js | 93 + .../js/views/settings/gateway-template.js | 50 + .../js/views/settings/general-pane.js | 170 + src/renderer/js/views/settings/index.js | 91 + src/renderer/popover.html | 9 +- src/renderer/popover.js | 200 - src/renderer/renderer.js | 1837 --------- src/renderer/tauri-bridge.js | 130 - src/renderer/theme-boot.js | 26 + src/shared/i18n-dict.js | 2270 ------------ src/shared/i18n/en-app.js | 127 + src/shared/i18n/en-conv.js | 140 + src/shared/i18n/en-ops.js | 129 + src/shared/i18n/en-settings.js | 89 + src/shared/i18n/index.js | 58 + src/shared/i18n/ja-app.js | 127 + src/shared/i18n/ja-conv.js | 140 + src/shared/i18n/ja-ops.js | 129 + src/shared/i18n/ja-settings.js | 89 + src/shared/i18n/ko-app.js | 127 + src/shared/i18n/ko-conv.js | 140 + src/shared/i18n/ko-ops.js | 129 + src/shared/i18n/ko-settings.js | 89 + src/shared/i18n/parts.js | 13 + src/shared/i18n/zh-TW-app.js | 127 + src/shared/i18n/zh-TW-conv.js | 140 + src/shared/i18n/zh-TW-ops.js | 129 + src/shared/i18n/zh-TW-settings.js | 89 + src/shared/i18n/zh-app.js | 127 + src/shared/i18n/zh-conv.js | 140 + src/shared/i18n/zh-ops.js | 129 + src/shared/i18n/zh-settings.js | 89 + test/ccbud-meta.test.js | 89 - test/claude.test.js | 73 - test/codex.test.js | 178 - test/count-tokens.test.js | 109 - test/exchange.test.js | 95 - test/file-size.test.js | 57 + test/head-fallback.test.js | 91 - test/history.test.js | 138 - test/i18n.test.js | 15 +- test/insights.test.js | 162 - test/models.test.js | 72 - test/monitor.test.js | 36 - test/retry.test.js | 75 - test/run.js | 21 + test/selftest.js | 274 -- test/smoke-fixtures.js | 92 + test/smoke.test.js | 136 + test/store.test.js | 50 - test/usage.test.js | 52 - test/zip.test.js | 79 - 436 files changed, 40028 insertions(+), 43379 deletions(-) create mode 100644 docs/architecture.md delete mode 100644 src-tauri/src/antigravity.rs create mode 100644 src-tauri/src/antigravity/content.rs create mode 100644 src-tauri/src/antigravity/mod.rs create mode 100644 src-tauri/src/antigravity/roots.rs create mode 100644 src-tauri/src/antigravity/session.rs create mode 100644 src-tauri/src/antigravity/steps.rs create mode 100644 src-tauri/src/antigravity/tests.rs create mode 100644 src-tauri/src/antigravity/wire.rs delete mode 100644 src-tauri/src/codex.rs create mode 100644 src-tauri/src/codex/exec.rs create mode 100644 src-tauri/src/codex/items.rs create mode 100644 src-tauri/src/codex/meta.rs create mode 100644 src-tauri/src/codex/mod.rs create mode 100644 src-tauri/src/codex/normalize.rs create mode 100644 src-tauri/src/codex/records.rs create mode 100644 src-tauri/src/codex/roots.rs create mode 100644 src-tauri/src/codex/tests.rs create mode 100644 src-tauri/src/codex/tests_more.rs create mode 100644 src-tauri/src/codex/titles.rs create mode 100644 src-tauri/src/codex/tools.rs delete mode 100644 src-tauri/src/codexconnect.rs create mode 100644 src-tauri/src/codexconnect/config.rs create mode 100644 src-tauri/src/codexconnect/connect.rs create mode 100644 src-tauri/src/codexconnect/mod.rs create mode 100644 src-tauri/src/codexconnect/tests.rs create mode 100644 src-tauri/src/commands/autoupdate.rs create mode 100644 src-tauri/src/commands/config.rs create mode 100644 src-tauri/src/commands/connect.rs create mode 100644 src-tauri/src/commands/fmt_tests.rs create mode 100644 src-tauri/src/commands/gateway.rs create mode 100644 src-tauri/src/commands/history.rs create mode 100644 src-tauri/src/commands/history_export.rs create mode 100644 src-tauri/src/commands/mod.rs create mode 100644 src-tauri/src/commands/plugins.rs create mode 100644 src-tauri/src/commands/provider_test.rs create mode 100644 src-tauri/src/commands/replay.rs create mode 100644 src-tauri/src/commands/selfcheck.rs create mode 100644 src-tauri/src/commands/selfcheck_js.rs create mode 100644 src-tauri/src/commands/update.rs create mode 100644 src-tauri/src/commands/util.rs create mode 100644 src-tauri/src/commands/window.rs delete mode 100644 src-tauri/src/copilot.rs create mode 100644 src-tauri/src/copilot/meta.rs create mode 100644 src-tauri/src/copilot/mod.rs create mode 100644 src-tauri/src/copilot/normalize.rs create mode 100644 src-tauri/src/copilot/roots.rs create mode 100644 src-tauri/src/copilot/session.rs create mode 100644 src-tauri/src/copilot/tests.rs delete mode 100644 src-tauri/src/exporthtml.rs create mode 100644 src-tauri/src/exporthtml/assets.rs create mode 100644 src-tauri/src/exporthtml/build.rs create mode 100644 src-tauri/src/exporthtml/html.rs create mode 100644 src-tauri/src/exporthtml/mod.rs create mode 100644 src-tauri/src/exporthtml/name.rs create mode 100644 src-tauri/src/exporthtml/parse.rs create mode 100644 src-tauri/src/exporthtml/session.rs create mode 100644 src-tauri/src/exporthtml/shape.rs create mode 100644 src-tauri/src/exporthtml/tests.rs create mode 100644 src-tauri/src/exporthtml/tests_more.rs delete mode 100644 src-tauri/src/gateway.rs create mode 100644 src-tauri/src/gateway/capture.rs create mode 100644 src-tauri/src/gateway/finish.rs create mode 100644 src-tauri/src/gateway/finish_buffered.rs create mode 100644 src-tauri/src/gateway/finish_translated.rs create mode 100644 src-tauri/src/gateway/forward.rs create mode 100644 src-tauri/src/gateway/handler.rs create mode 100644 src-tauri/src/gateway/history_args.rs create mode 100644 src-tauri/src/gateway/history_prep.rs create mode 100644 src-tauri/src/gateway/mock.rs create mode 100644 src-tauri/src/gateway/mod.rs create mode 100644 src-tauri/src/gateway/models.rs create mode 100644 src-tauri/src/gateway/monitor.rs create mode 100644 src-tauri/src/gateway/prepare.rs create mode 100644 src-tauri/src/gateway/redact.rs create mode 100644 src-tauri/src/gateway/responses_history.rs create mode 100644 src-tauri/src/gateway/retry.rs create mode 100644 src-tauri/src/gateway/routing.rs create mode 100644 src-tauri/src/gateway/selftest.rs create mode 100644 src-tauri/src/gateway/selftest_routing.rs create mode 100644 src-tauri/src/gateway/selftest_xlate.rs create mode 100644 src-tauri/src/gateway/session.rs create mode 100644 src-tauri/src/gateway/signatures.rs create mode 100644 src-tauri/src/gateway/sse.rs create mode 100644 src-tauri/src/gateway/state.rs create mode 100644 src-tauri/src/gateway/stream_passthrough.rs create mode 100644 src-tauri/src/gateway/stream_transcode.rs create mode 100644 src-tauri/src/gateway/targets.rs create mode 100644 src-tauri/src/gateway/tests.rs create mode 100644 src-tauri/src/gateway/tests_history.rs create mode 100644 src-tauri/src/gateway/tests_routing.rs create mode 100644 src-tauri/src/gateway/tests_signatures.rs delete mode 100644 src-tauri/src/grok.rs create mode 100644 src-tauri/src/grok/meta.rs create mode 100644 src-tauri/src/grok/mod.rs create mode 100644 src-tauri/src/grok/normalize.rs create mode 100644 src-tauri/src/grok/roots.rs create mode 100644 src-tauri/src/grok/session.rs create mode 100644 src-tauri/src/grok/tests.rs delete mode 100644 src-tauri/src/history.rs create mode 100644 src-tauri/src/history/codexdedupe.rs create mode 100644 src-tauri/src/history/edit.rs create mode 100644 src-tauri/src/history/foreign.rs create mode 100644 src-tauri/src/history/foreign_probe.rs create mode 100644 src-tauri/src/history/import.rs create mode 100644 src-tauri/src/history/importpaths.rs create mode 100644 src-tauri/src/history/jsonl.rs create mode 100644 src-tauri/src/history/list.rs create mode 100644 src-tauri/src/history/meta.rs create mode 100644 src-tauri/src/history/mod.rs create mode 100644 src-tauri/src/history/norm.rs create mode 100644 src-tauri/src/history/paths.rs create mode 100644 src-tauri/src/history/search.rs create mode 100644 src-tauri/src/history/searchfmt.rs create mode 100644 src-tauri/src/history/searchscan.rs create mode 100644 src-tauri/src/history/searchtext.rs create mode 100644 src-tauri/src/history/selftest.rs create mode 100644 src-tauri/src/history/session.rs create mode 100644 src-tauri/src/history/skills.rs create mode 100644 src-tauri/src/history/subagents.rs create mode 100644 src-tauri/src/history/tests_foreign.rs create mode 100644 src-tauri/src/history/tests_misc.rs create mode 100644 src-tauri/src/history/tests_qoder.rs create mode 100644 src-tauri/src/history/tests_search.rs create mode 100644 src-tauri/src/history/text.rs create mode 100644 src-tauri/src/legacybundle.rs delete mode 100644 src-tauri/src/plugin.rs create mode 100644 src-tauri/src/plugin/git.rs create mode 100644 src-tauri/src/plugin/install.rs create mode 100644 src-tauri/src/plugin/lifecycle.rs create mode 100644 src-tauri/src/plugin/manager.rs create mode 100644 src-tauri/src/plugin/manifest.rs create mode 100644 src-tauri/src/plugin/manifest_meta.rs create mode 100644 src-tauri/src/plugin/mod.rs create mode 100644 src-tauri/src/plugin/providers.rs create mode 100644 src-tauri/src/plugin/status.rs create mode 100644 src-tauri/src/plugin/util.rs create mode 100644 src-tauri/src/popover.rs delete mode 100644 src-tauri/src/protocol/anthropic.rs create mode 100644 src-tauri/src/protocol/anthropic/blocks.rs create mode 100644 src-tauri/src/protocol/anthropic/decode.rs create mode 100644 src-tauri/src/protocol/anthropic/encode.rs create mode 100644 src-tauri/src/protocol/anthropic/mod.rs create mode 100644 src-tauri/src/protocol/anthropic/tests.rs create mode 100644 src-tauri/src/protocol/codec.rs delete mode 100644 src-tauri/src/protocol/codex_history.rs create mode 100644 src-tauri/src/protocol/codex_history/cache_items.rs create mode 100644 src-tauri/src/protocol/codex_history/inner_index.rs create mode 100644 src-tauri/src/protocol/codex_history/inner_insert.rs create mode 100644 src-tauri/src/protocol/codex_history/materialize.rs create mode 100644 src-tauri/src/protocol/codex_history/merge.rs create mode 100644 src-tauri/src/protocol/codex_history/mod.rs create mode 100644 src-tauri/src/protocol/codex_history/resolve.rs create mode 100644 src-tauri/src/protocol/codex_history/sizing.rs create mode 100644 src-tauri/src/protocol/codex_history/store.rs create mode 100644 src-tauri/src/protocol/codex_history/tests_call_fallback.rs create mode 100644 src-tauri/src/protocol/codex_history/tests_continuation.rs create mode 100644 src-tauri/src/protocol/codex_history/tests_eviction.rs create mode 100644 src-tauri/src/protocol/codex_history/tests_hops.rs create mode 100644 src-tauri/src/protocol/codex_history/tests_limits.rs create mode 100644 src-tauri/src/protocol/codex_history/tests_materialize.rs create mode 100644 src-tauri/src/protocol/codex_history/tests_native.rs create mode 100644 src-tauri/src/protocol/codex_history/types.rs delete mode 100644 src-tauri/src/protocol/openai_chat_client.rs create mode 100644 src-tauri/src/protocol/openai_chat_client/decode.rs create mode 100644 src-tauri/src/protocol/openai_chat_client/encode.rs create mode 100644 src-tauri/src/protocol/openai_chat_client/mod.rs create mode 100644 src-tauri/src/protocol/openai_chat_client/tests.rs delete mode 100644 src-tauri/src/protocol/openai_responses.rs create mode 100644 src-tauri/src/protocol/openai_responses/decode_request.rs create mode 100644 src-tauri/src/protocol/openai_responses/decode_response.rs create mode 100644 src-tauri/src/protocol/openai_responses/encode_request.rs create mode 100644 src-tauri/src/protocol/openai_responses/encode_response.rs create mode 100644 src-tauri/src/protocol/openai_responses/encode_sse.rs create mode 100644 src-tauri/src/protocol/openai_responses/helpers.rs create mode 100644 src-tauri/src/protocol/openai_responses/history.rs create mode 100644 src-tauri/src/protocol/openai_responses/mod.rs create mode 100644 src-tauri/src/protocol/openai_responses/parts.rs create mode 100644 src-tauri/src/protocol/openai_responses/tests_aliases.rs create mode 100644 src-tauri/src/protocol/openai_responses/tests_collisions.rs create mode 100644 src-tauri/src/protocol/openai_responses/tests_extended.rs create mode 100644 src-tauri/src/protocol/openai_responses/tests_lite.rs create mode 100644 src-tauri/src/protocol/openai_responses/tests_request.rs create mode 100644 src-tauri/src/protocol/openai_responses/tests_response.rs create mode 100644 src-tauri/src/protocol/openai_responses/tests_validate.rs create mode 100644 src-tauri/src/protocol/openai_responses/tool_collect.rs create mode 100644 src-tauri/src/protocol/openai_responses/tool_items.rs create mode 100644 src-tauri/src/protocol/openai_responses/tool_names.rs create mode 100644 src-tauri/src/protocol/openai_responses/tool_registry.rs create mode 100644 src-tauri/src/protocol/openai_responses/tools.rs create mode 100644 src-tauri/src/protocol/openai_responses/validate.rs create mode 100644 src-tauri/src/protocol/signatures.rs delete mode 100644 src-tauri/src/protocol/stream.rs create mode 100644 src-tauri/src/protocol/stream/ablock.rs create mode 100644 src-tauri/src/protocol/stream/anthropic_responses.rs create mode 100644 src-tauri/src/protocol/stream/anthropic_responses_block.rs create mode 100644 src-tauri/src/protocol/stream/anthropic_responses_finish.rs create mode 100644 src-tauri/src/protocol/stream/anthropic_responses_push.rs create mode 100644 src-tauri/src/protocol/stream/chat_anthropic.rs create mode 100644 src-tauri/src/protocol/stream/chat_anthropic_push.rs create mode 100644 src-tauri/src/protocol/stream/chat_responses.rs create mode 100644 src-tauri/src/protocol/stream/chat_responses_finish.rs create mode 100644 src-tauri/src/protocol/stream/chat_responses_items.rs create mode 100644 src-tauri/src/protocol/stream/chat_responses_push.rs create mode 100644 src-tauri/src/protocol/stream/chat_responses_tools.rs create mode 100644 src-tauri/src/protocol/stream/common.rs create mode 100644 src-tauri/src/protocol/stream/mod.rs create mode 100644 src-tauri/src/protocol/stream/resp_items.rs create mode 100644 src-tauri/src/protocol/stream/tests_anthropic_responses.rs create mode 100644 src-tauri/src/protocol/stream/tests_chat_anthropic.rs create mode 100644 src-tauri/src/protocol/stream/tests_chat_responses.rs create mode 100644 src-tauri/src/protocol/stream/tests_errors.rs create mode 100644 src-tauri/src/protocol/stream/tests_extended.rs create mode 100644 src-tauri/src/protocol/stream/tests_ids.rs create mode 100644 src-tauri/src/protocol/stream/transcoder.rs create mode 100644 src-tauri/src/protocol/tests.rs create mode 100644 src-tauri/src/protocol/wire.rs delete mode 100644 src-tauri/src/qoder.rs create mode 100644 src-tauri/src/qoder/exec.rs create mode 100644 src-tauri/src/qoder/guard.rs create mode 100644 src-tauri/src/qoder/helper.rs create mode 100644 src-tauri/src/qoder/limits.rs create mode 100644 src-tauri/src/qoder/meta.rs create mode 100644 src-tauri/src/qoder/mod.rs create mode 100644 src-tauri/src/qoder/normalize.rs create mode 100644 src-tauri/src/qoder/prefetch.rs create mode 100644 src-tauri/src/qoder/read.rs create mode 100644 src-tauri/src/qoder/roots.rs create mode 100644 src-tauri/src/qoder/tests.rs create mode 100644 src-tauri/src/qoder/tests_more.rs create mode 100644 src-tauri/src/qoder/titles.rs create mode 100644 src-tauri/src/startup.rs delete mode 100644 src-tauri/src/store.rs create mode 100644 src-tauri/src/store/defaults.rs create mode 100644 src-tauri/src/store/dirs.rs create mode 100644 src-tauri/src/store/io.rs create mode 100644 src-tauri/src/store/mod.rs create mode 100644 src-tauri/src/store/normalize.rs create mode 100644 src-tauri/src/store/paths.rs create mode 100644 src-tauri/src/store/tests.rs create mode 100644 src-tauri/src/tray.rs create mode 100644 src-tauri/src/trayicon.rs delete mode 100644 src-tauri/src/usage.rs create mode 100644 src-tauri/src/usage/build.rs create mode 100644 src-tauri/src/usage/claude.rs create mode 100644 src-tauri/src/usage/codex.rs create mode 100644 src-tauri/src/usage/diag.rs create mode 100644 src-tauri/src/usage/diag_probe.rs create mode 100644 src-tauri/src/usage/mod.rs create mode 100644 src-tauri/src/usage/model.rs create mode 100644 src-tauri/src/usage/query.rs create mode 100644 src-tauri/src/usage/real_data_probe.rs create mode 100644 src-tauri/src/usage/roots.rs create mode 100644 src-tauri/src/usage/tests.rs create mode 100644 src-tauri/src/usage/tests_codex.rs delete mode 100644 src-tauri/src/ziputil.rs create mode 100644 src-tauri/src/ziputil/bundle.rs create mode 100644 src-tauri/src/ziputil/mod.rs create mode 100644 src-tauri/src/ziputil/read.rs create mode 100644 src-tauri/src/ziputil/tests.rs create mode 100644 src-tauri/src/ziputil/write.rs delete mode 100644 src/main/bootstrap.js delete mode 100644 src/main/claude.js delete mode 100644 src/main/claudeDesktop.js delete mode 100644 src/main/claudeModels.js delete mode 100644 src/main/codex.js delete mode 100644 src/main/countTokens.js create mode 100644 src/main/export-assets/runtime-analytics.js create mode 100644 src/main/export-assets/runtime-messages.js create mode 100644 src/main/export-assets/runtime-render.js create mode 100644 src/main/export-assets/runtime-ui.js delete mode 100644 src/main/export-assets/runtime.js delete mode 100644 src/main/exportHtml.js delete mode 100644 src/main/history.js delete mode 100644 src/main/hotpaths.js delete mode 100644 src/main/icon.png delete mode 100644 src/main/iconTemplate.png delete mode 100644 src/main/iconTemplate@2x.png delete mode 100644 src/main/insights.js delete mode 100644 src/main/main.js delete mode 100644 src/main/monitor.js delete mode 100644 src/main/preload.js delete mode 100644 src/main/proxy.js delete mode 100644 src/main/store.js delete mode 100644 src/main/updater.js delete mode 100644 src/main/usage.js delete mode 100644 src/main/zipStore.js create mode 100644 src/renderer/analytics-events.js delete mode 100644 src/renderer/conversations.js create mode 100644 src/renderer/css/01.css create mode 100644 src/renderer/css/02.css create mode 100644 src/renderer/css/03.css create mode 100644 src/renderer/css/04.css create mode 100644 src/renderer/css/05.css create mode 100644 src/renderer/css/06.css create mode 100644 src/renderer/css/07.css create mode 100644 src/renderer/css/08.css create mode 100644 src/renderer/css/09.css create mode 100644 src/renderer/css/10.css create mode 100644 src/renderer/css/11.css create mode 100644 src/renderer/css/12.css create mode 100644 src/renderer/css/13.css create mode 100644 src/renderer/css/14.css create mode 100644 src/renderer/css/15.css create mode 100644 src/renderer/css/16.css create mode 100644 src/renderer/css/17.css create mode 100644 src/renderer/css/18.css create mode 100644 src/renderer/css/19.css create mode 100644 src/renderer/css/20.css create mode 100644 src/renderer/css/21.css create mode 100644 src/renderer/css/22.css create mode 100644 src/renderer/css/23.css create mode 100644 src/renderer/css/24.css delete mode 100644 src/renderer/i18n.js create mode 100644 src/renderer/js/core/bridge.js create mode 100644 src/renderer/js/core/dom.js create mode 100644 src/renderer/js/core/i18n.js rename src/renderer/{ => js/core}/icons.js (93%) create mode 100644 src/renderer/js/core/loader.js create mode 100644 src/renderer/js/core/state.js create mode 100644 src/renderer/js/core/theme.js create mode 100644 src/renderer/js/core/toast.js create mode 100644 src/renderer/js/main.js create mode 100644 src/renderer/js/popover/heatmap.js create mode 100644 src/renderer/js/popover/main.js create mode 100644 src/renderer/js/popover/stats.js create mode 100644 src/renderer/js/views/conversations/actions.js create mode 100644 src/renderer/js/views/conversations/agents.js create mode 100644 src/renderer/js/views/conversations/code.js create mode 100644 src/renderer/js/views/conversations/codex-format.js create mode 100644 src/renderer/js/views/conversations/detail.js create mode 100644 src/renderer/js/views/conversations/events-detail.js create mode 100644 src/renderer/js/views/conversations/events-list.js create mode 100644 src/renderer/js/views/conversations/format.js create mode 100644 src/renderer/js/views/conversations/index.js create mode 100644 src/renderer/js/views/conversations/layout.js create mode 100644 src/renderer/js/views/conversations/list-order.js create mode 100644 src/renderer/js/views/conversations/list-row.js create mode 100644 src/renderer/js/views/conversations/list.js create mode 100644 src/renderer/js/views/conversations/live.js create mode 100644 src/renderer/js/views/conversations/message.js create mode 100644 src/renderer/js/views/conversations/meta-edit.js create mode 100644 src/renderer/js/views/conversations/panels.js create mode 100644 src/renderer/js/views/conversations/search.js create mode 100644 src/renderer/js/views/conversations/state.js create mode 100644 src/renderer/js/views/conversations/subagents.js create mode 100644 src/renderer/js/views/conversations/template.js create mode 100644 src/renderer/js/views/conversations/tools.js create mode 100644 src/renderer/js/views/conversations/tooltip.js create mode 100644 src/renderer/js/views/conversations/window.js create mode 100644 src/renderer/js/views/monitor/drawer-search.js create mode 100644 src/renderer/js/views/monitor/drawer.js create mode 100644 src/renderer/js/views/monitor/feed.js create mode 100644 src/renderer/js/views/monitor/index.js create mode 100644 src/renderer/js/views/monitor/log.js create mode 100644 src/renderer/js/views/monitor/template.js create mode 100644 src/renderer/js/views/plugins/actions.js create mode 100644 src/renderer/js/views/plugins/forms.js create mode 100644 src/renderer/js/views/plugins/git.js create mode 100644 src/renderer/js/views/plugins/index.js create mode 100644 src/renderer/js/views/providers/drag.js create mode 100644 src/renderer/js/views/providers/hero.js create mode 100644 src/renderer/js/views/providers/icon-picker.js create mode 100644 src/renderer/js/views/providers/icon.js create mode 100644 src/renderer/js/views/providers/index.js create mode 100644 src/renderer/js/views/providers/modal-template.js create mode 100644 src/renderer/js/views/providers/modal.js create mode 100644 src/renderer/js/views/providers/presets.js create mode 100644 src/renderer/js/views/registry.js create mode 100644 src/renderer/js/views/settings/about-pane.js create mode 100644 src/renderer/js/views/settings/conv-font.js create mode 100644 src/renderer/js/views/settings/data-pane.js create mode 100644 src/renderer/js/views/settings/gateway-pane.js create mode 100644 src/renderer/js/views/settings/gateway-template.js create mode 100644 src/renderer/js/views/settings/general-pane.js create mode 100644 src/renderer/js/views/settings/index.js delete mode 100644 src/renderer/popover.js delete mode 100644 src/renderer/renderer.js delete mode 100644 src/renderer/tauri-bridge.js create mode 100644 src/renderer/theme-boot.js delete mode 100644 src/shared/i18n-dict.js create mode 100644 src/shared/i18n/en-app.js create mode 100644 src/shared/i18n/en-conv.js create mode 100644 src/shared/i18n/en-ops.js create mode 100644 src/shared/i18n/en-settings.js create mode 100644 src/shared/i18n/index.js create mode 100644 src/shared/i18n/ja-app.js create mode 100644 src/shared/i18n/ja-conv.js create mode 100644 src/shared/i18n/ja-ops.js create mode 100644 src/shared/i18n/ja-settings.js create mode 100644 src/shared/i18n/ko-app.js create mode 100644 src/shared/i18n/ko-conv.js create mode 100644 src/shared/i18n/ko-ops.js create mode 100644 src/shared/i18n/ko-settings.js create mode 100644 src/shared/i18n/parts.js create mode 100644 src/shared/i18n/zh-TW-app.js create mode 100644 src/shared/i18n/zh-TW-conv.js create mode 100644 src/shared/i18n/zh-TW-ops.js create mode 100644 src/shared/i18n/zh-TW-settings.js create mode 100644 src/shared/i18n/zh-app.js create mode 100644 src/shared/i18n/zh-conv.js create mode 100644 src/shared/i18n/zh-ops.js create mode 100644 src/shared/i18n/zh-settings.js delete mode 100644 test/ccbud-meta.test.js delete mode 100644 test/claude.test.js delete mode 100644 test/codex.test.js delete mode 100644 test/count-tokens.test.js delete mode 100644 test/exchange.test.js create mode 100644 test/file-size.test.js delete mode 100644 test/head-fallback.test.js delete mode 100644 test/history.test.js delete mode 100644 test/insights.test.js delete mode 100644 test/models.test.js delete mode 100644 test/monitor.test.js delete mode 100644 test/retry.test.js create mode 100644 test/run.js delete mode 100644 test/selftest.js create mode 100644 test/smoke-fixtures.js create mode 100644 test/smoke.test.js delete mode 100644 test/store.test.js delete mode 100644 test/usage.test.js delete mode 100644 test/zip.test.js diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d5f4f34..c53eb52 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -16,16 +16,22 @@ permissions: jobs: test: - name: Rust unit tests + name: Unit tests runs-on: ubuntu-22.04 steps: - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 - uses: dtolnay/rust-toolchain@stable - uses: swatinem/rust-cache@v2 with: workspaces: './src-tauri -> target' - name: Install Linux deps run: sudo apt-get update && sudo apt-get install -y libwebkit2gtk-4.1-dev libssl-dev + # i18n dictionary parity + the repo-wide 220-line module rule. + - name: npm test + run: node test/run.js - name: cargo test run: cargo test --lib --manifest-path src-tauri/Cargo.toml diff --git a/build/generate-tray-icon.js b/build/generate-tray-icon.js index c5323c7..8040c22 100644 --- a/build/generate-tray-icon.js +++ b/build/generate-tray-icon.js @@ -113,8 +113,5 @@ const out2x = path.join(__dirname, 'iconTemplate@2x.png'); fs.writeFileSync(out1x, generatePng(22, 1)); fs.writeFileSync(out2x, generatePng(44, 2)); -// Copy to source folder -fs.copyFileSync(out1x, path.join(__dirname, '..', 'src', 'main', 'iconTemplate.png')); -fs.copyFileSync(out2x, path.join(__dirname, '..', 'src', 'main', 'iconTemplate@2x.png')); - +// build/iconTemplate.png is the tray icon the Rust backend embeds (include_bytes! in lib.rs). console.log('Successfully generated minimalist template icons.'); diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..4a45ee0 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,53 @@ +# Architecture + +CC Buddy is a Tauri app: a Rust backend (`src-tauri/`) and a plain-ESM renderer (`src/renderer/`) +with no bundler. This document records the two rules that shape the codebase. + +## Rule 1 — no source file over 220 lines + +Enforced by `test/file-size.test.js` (part of `npm test`). Vendored bundles, the generated +`styles.css` and the build-synced `src/renderer/shared/` copy are exempt; everything else is +hand-maintained source and must stay under the limit. Reaching for a split is the intended +response, not raising the number. + +The corollary is a module per responsibility. Where a file would otherwise grow, it becomes a +directory module (`foo.rs` → `foo/mod.rs` + siblings; a view → `views//index.js` + siblings) +whose `mod.rs` / `index.js` is the only public surface. + +## Rule 2 — nothing on the cold-start path that a later click could load + +**Backend.** `setup()` in `src-tauri/src/lib.rs` only builds the window, tray and event hooks. +Every filesystem-heavy boot step — one-time `historyDirs` migrations, plugin reconcile, CLI +connection repair, gateway and plugin start, history-watcher registration, usage-cache warm — +runs off the main thread from `startup::spawn_background_boot`, in the same order as before. +The window paints and accepts input while that work proceeds. + +**Renderer.** `index.html` ships the shell plus the first view (服务) only. `js/main.js` and the +`js/core/` modules it imports are the only JavaScript parsed before first paint: + +| Loaded at startup | Loaded on demand | +| --- | --- | +| shell markup + 服务 view | 插件 / 监控 / 设置 / 会话 markup and modules (`views/registry.js`) | +| active locale's dictionary parts | the other four locales (`shared/i18n/parts.js` manifest) | +| `theme-boot.js` (pre-paint theme/lang stamp) | `marked` + `highlight.js` (`core/loader.js`, on first transcript or inspector open) | +| analytics queue stub (deferred) | provider modal, request-inspector drawer, plugin forms | + +Two idle prefetches warm the heaviest lazy paths (vendor bundles, the 会话 view) after boot, so +the first click still feels instant without costing startup. + +## Module map + +``` +src/renderer/ + js/core/ bridge (IPC) · state · i18n · loader · dom · icons · theme · toast + js/views/ registry + providers/ plugins/ monitor/ settings/ conversations/ + js/popover/ tray usage window + css/ numbered partials; input.css is the @import manifest (order = cascade order) +src/shared/i18n/ per-language, per-domain dictionary parts + index (Node) / parts.js (browser) +src-tauri/src/ startup · gateway/ · history/ · protocol/ · codex/ · store · usage · plugin · … +``` + +## Testing + +- `npm test` — i18n dictionary parity across locales, part-manifest integrity, file-size rule. +- `cd src-tauri && cargo test` — the gateway, history, usage, export and protocol engines. diff --git a/package-lock.json b/package-lock.json index 791a227..d84f014 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,15 @@ { "name": "ccbud", - "version": "1.3.5", + "version": "1.3.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ccbud", - "version": "1.3.5", + "version": "1.3.8", "license": "GPL-3.0-only", "dependencies": { - "@microsoft/clarity": "^1.0.2", - "js-tiktoken": "^1.0.21" + "@microsoft/clarity": "^1.0.2" }, "devDependencies": { "@highlightjs/cdn-assets": "^11.11.1", @@ -214,9 +213,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -238,9 +234,6 @@ "arm" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -262,9 +255,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -286,9 +276,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -310,9 +297,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -334,9 +318,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -585,9 +566,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -605,9 +583,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -625,9 +600,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -645,9 +617,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -876,9 +845,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0 OR MIT", "optional": true, "os": [ @@ -896,9 +862,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "Apache-2.0 OR MIT", "optional": true, "os": [ @@ -916,9 +879,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0 OR MIT", "optional": true, "os": [ @@ -936,9 +896,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0 OR MIT", "optional": true, "os": [ @@ -956,9 +913,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "Apache-2.0 OR MIT", "optional": true, "os": [ @@ -1019,26 +973,6 @@ "node": ">= 10" } }, - "node_modules/base64-js": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", - "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, "node_modules/braces": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", @@ -1139,15 +1073,6 @@ "jiti": "lib/jiti-cli.mjs" } }, - "node_modules/js-tiktoken": { - "version": "1.0.21", - "resolved": "https://registry.npmjs.org/js-tiktoken/-/js-tiktoken-1.0.21.tgz", - "integrity": "sha512-biOj/6M5qdgx5TKjDnFT1ymSpM5tbd3ylwDtrQvFQSu0Z7bBYko2dF+W/aUkXUPuk6IVpRxk/3Q2sHOzGlS36g==", - "license": "MIT", - "dependencies": { - "base64-js": "^1.5.1" - } - }, "node_modules/lightningcss": { "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", @@ -1291,9 +1216,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -1315,9 +1237,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -1339,9 +1258,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -1363,9 +1279,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ diff --git a/package.json b/package.json index ac3ec06..05f57f9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ccbud", - "version": "1.3.8", + "version": "1.3.9", "description": "CC Buddy — Coding CLI Buddy. A cross-platform desktop app that proxies Claude Code to any Anthropic-compatible provider (one-click switching, model mapping) and browses your Claude Code & Codex session history.", "author": { "name": "loadchange", @@ -19,7 +19,7 @@ "release": "node scripts/release.js", "start": "tauri dev", "dev": "tauri dev", - "test": "node test/selftest.js", + "test": "node test/run.js", "update:cask": "node scripts/update-cask.js", "dist": "tauri build", "dist:mac": "tauri build", @@ -34,7 +34,6 @@ "tailwindcss": "^4.3.1" }, "dependencies": { - "@microsoft/clarity": "^1.0.2", - "js-tiktoken": "^1.0.21" + "@microsoft/clarity": "^1.0.2" } } diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 1f41abd..2f1504d 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -89,7 +89,7 @@ checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" [[package]] name = "app" -version = "1.3.8" +version = "1.3.9" dependencies = [ "arboard", "async-stream", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index d8ccb2d..61fad0b 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "app" -version = "1.3.8" +version = "1.3.9" description = "CCBuddy — Coding CLI Buddy" authors = ["loadchange "] license = "GPL-3.0-only" diff --git a/src-tauri/src/antigravity.rs b/src-tauri/src/antigravity.rs deleted file mode 100644 index 57599ba..0000000 --- a/src-tauri/src/antigravity.rs +++ /dev/null @@ -1,725 +0,0 @@ -// Google Antigravity CLI (`agy`) session support — reads its per-conversation SQLite stores -// (`~/.gemini/antigravity-cli/conversations/.db`, `steps` table) plus the sibling -// `conversation_summaries.db` (title / preview / workspace uris — plain text), and normalizes -// them into the SAME session/message shape the renderer consumes (history::Norm). -// -// A step's `step_payload` is a protobuf blob with no published schema. A minimal wire-format -// walker recovers the stable fields (reverse-engineered against real conversations): -// #1 step type enum #4 status -// #5 metadata: #5.1 {sec,nanos} created · #5.4 tool call {#1 id, #2 name, #3 args-JSON, -// #7 result (opaque/encrypted — not recoverable)} · #5.9 generation stats -// {#2 input tokens, #3 output tokens} -// #19 user input: #19.2 text · #19.9 attachments {#1 mime, #2 bytes, #5 path} -// #20 model turn: #20.1 assistant text -// Steps whose payload drifts from this map degrade to being skipped (never crash) — the -// summaries DB alone still lists the conversation. Tool RESULTS are stored in a non-readable -// encoding, so tool cards show name/args and the renderer's "no result" marker. -// -// DBs may be WAL-journaled and open in a live agy process: connections are read-only with a -// short busy timeout, and freshness checks use max(mtime(db), mtime(db-wal)). -// -// Title/tags/soft-delete live in the shared foreign-CLI sidecar (~/.ccbud/agent-meta.json) -// keyed `antigravity:` — the DBs belong to another tool and are never written. - -#![allow(dead_code)] - -use crate::history::Norm; -use rusqlite::{Connection, OpenFlags}; -use serde_json::{json, Value}; -use std::fs; -use std::path::{Path, PathBuf}; - -fn home() -> PathBuf { - std::env::var("HOME").map(PathBuf::from).unwrap_or_else(|_| PathBuf::from(".")) -} - -/// Antigravity CLI's data dir as a history-dir entry string (`~/.gemini/antigravity-cli`). -pub fn default_root() -> PathBuf { - home().join(".gemini").join("antigravity-cli") -} - -pub fn agy_label() -> String { - crate::store::collapse_home(&default_root().to_string_lossy()) -} - -pub fn root_exists() -> bool { - default_root().join("conversations").is_dir() -} - -/// Walk every conversation DB under a `conversations/` dir. -pub fn walk(conversations_dir: &Path, cb: &mut F) { - let entries = match fs::read_dir(conversations_dir) { - Ok(e) => e, - Err(_) => return, - }; - for ent in entries.flatten() { - let p = ent.path(); - if p.is_file() && p.extension().and_then(|e| e.to_str()) == Some("db") { - cb(p); - } - } -} - -/// Container-shape test for detail/edit routing: `…/conversations/.db`. -pub fn looks_agy_path(file: &Path) -> bool { - file.extension().and_then(|e| e.to_str()) == Some("db") - && file - .parent() - .and_then(|d| d.file_name()) - .and_then(|n| n.to_str()) - .map(|n| n == "conversations") - .unwrap_or(false) -} - -fn session_uuid(file: &Path) -> String { - file.file_stem().and_then(|s| s.to_str()).unwrap_or("").to_string() -} - -fn sidecar_key(file: &Path) -> String { - format!("antigravity:{}", session_uuid(file)) -} - -fn sidecar_meta(file: &Path) -> (Option, Vec, bool) { - crate::sidecar::meta(&crate::sidecar::agent_file(), &sidecar_key(file)) -} - -pub fn is_deleted(file: &Path) -> bool { - sidecar_meta(file).2 -} - -pub fn set_meta(file: &str, patch: &Value) -> Value { - let key = sidecar_key(Path::new(file)); - if key == "antigravity:" { - return json!({ "ok": false, "reason": "empty" }); - } - crate::sidecar::set_meta(&crate::sidecar::agent_file(), &key, patch) -} - -/// WAL-aware freshness stamp: a live agy writes into `-wal` without touching the main -/// file's mtime, so cache keys must take the max of both. -pub fn wal_mtime_ms(file: &Path) -> f64 { - let m = |p: &Path| { - fs::metadata(p) - .and_then(|md| md.modified()) - .ok() - .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) - .map(|d| d.as_millis() as f64) - .unwrap_or(0.0) - }; - let mut wal = file.as_os_str().to_os_string(); - wal.push("-wal"); - m(file).max(m(Path::new(&wal))) -} - -fn open_ro(path: &Path) -> Option { - let conn = Connection::open_with_flags( - path, - OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX, - ) - .ok()?; - let _ = conn.busy_timeout(std::time::Duration::from_millis(400)); - Some(conn) -} - -// ---- protobuf wire walker (schema-less) ---- - -enum Wire { - Varint(u64), - Bytes(Vec), - Fixed, -} - -/// One message level → (field number, value) pairs. None when the buffer isn't a valid message. -fn wire_fields(buf: &[u8]) -> Option> { - let mut out = vec![]; - let mut i = 0usize; - fn varint(buf: &[u8], i: &mut usize) -> Option { - let mut v: u64 = 0; - let mut shift = 0u32; - loop { - let b = *buf.get(*i)?; - *i += 1; - v |= ((b & 0x7f) as u64) << shift; - if b & 0x80 == 0 { - return Some(v); - } - shift += 7; - if shift > 63 { - return None; - } - } - } - while i < buf.len() { - let tag = varint(buf, &mut i)?; - let (field, wt) = ((tag >> 3) as u32, tag & 7); - if field == 0 { - return None; - } - match wt { - 0 => out.push((field, Wire::Varint(varint(buf, &mut i)?))), - 2 => { - let len = varint(buf, &mut i)? as usize; - if i + len > buf.len() { - return None; - } - out.push((field, Wire::Bytes(buf[i..i + len].to_vec()))); - i += len; - } - 5 => { - if i + 4 > buf.len() { - return None; - } - i += 4; - out.push((field, Wire::Fixed)); - } - 1 => { - if i + 8 > buf.len() { - return None; - } - i += 8; - out.push((field, Wire::Fixed)); - } - _ => return None, - } - } - Some(out) -} - -fn field_bytes<'a>(fields: &'a [(u32, Wire)], no: u32) -> Option<&'a [u8]> { - fields.iter().find_map(|(f, w)| match w { - Wire::Bytes(b) if *f == no => Some(b.as_slice()), - _ => None, - }) -} - -fn field_msg(fields: &[(u32, Wire)], no: u32) -> Option> { - wire_fields(field_bytes(fields, no)?) -} - -fn field_str(fields: &[(u32, Wire)], no: u32) -> Option { - let b = field_bytes(fields, no)?; - let s = std::str::from_utf8(b).ok()?; - Some(s.to_string()) -} - -fn field_varint(fields: &[(u32, Wire)], no: u32) -> Option { - fields.iter().find_map(|(f, w)| match w { - Wire::Varint(v) if *f == no => Some(*v), - _ => None, - }) -} - -/// `{#1 seconds, #2 nanos}` timestamp message → RFC3339 (ms precision). -fn ts_of(fields: &[(u32, Wire)], no: u32) -> Option { - let m = field_msg(fields, no)?; - let secs = field_varint(&m, 1)? as i64; - let nanos = field_varint(&m, 2).unwrap_or(0) as u32; - let dt = chrono::DateTime::from_timestamp(secs, nanos)?; - Some(dt.to_rfc3339_opts(chrono::SecondsFormat::Millis, true)) -} - -fn ts_ms_of(fields: &[(u32, Wire)], no: u32) -> Option { - let m = field_msg(fields, no)?; - let secs = field_varint(&m, 1)? as f64; - let nanos = field_varint(&m, 2).unwrap_or(0) as f64; - Some(secs * 1000.0 + (nanos / 1_000_000.0).floor()) -} - -// ---- content mapping ---- - -const B64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; - -fn b64_encode(data: &[u8]) -> String { - let mut out = String::with_capacity((data.len() + 2) / 3 * 4); - for chunk in data.chunks(3) { - let b = [chunk[0], *chunk.get(1).unwrap_or(&0), *chunk.get(2).unwrap_or(&0)]; - let n = ((b[0] as u32) << 16) | ((b[1] as u32) << 8) | b[2] as u32; - out.push(B64[(n >> 18) as usize & 63] as char); - out.push(B64[(n >> 12) as usize & 63] as char); - out.push(if chunk.len() > 1 { B64[(n >> 6) as usize & 63] as char } else { '=' }); - out.push(if chunk.len() > 2 { B64[n as usize & 63] as char } else { '=' }); - } - out -} - -/// Antigravity tool name + parsed arguments → (renderer tool name, renderer input). The args -/// JSON carries display strings (toolAction/toolSummary) alongside the real params — dropped -/// from generic passthrough to keep cards clean. -fn map_tool(name: &str, args: &Value) -> (String, Value) { - let s = |k: &str| args.get(k).and_then(|v| v.as_str()).unwrap_or("").to_string(); - match name { - "run_command" => { - let mut input = json!({ "command": s("CommandLine") }); - if !s("Cwd").is_empty() { - input["description"] = json!(s("Cwd")); - } - ("Bash".into(), input) - } - "view_file" => ("Read".into(), json!({ "file_path": s("AbsolutePath") })), - "list_dir" => ("LS".into(), json!({ "path": s("DirectoryPath") })), - "grep_search" => { - let mut input = json!({ "pattern": s("Query") }); - if !s("SearchPath").is_empty() { - input["path"] = json!(s("SearchPath")); - } - ("Grep".into(), input) - } - "find_by_name" => ("Glob".into(), json!({ "pattern": s("Pattern"), "path": s("SearchDirectory") })), - "replace_file_content" => ( - "Edit".into(), - json!({ "file_path": s("TargetFile"), "old_string": s("TargetContent"), "new_string": s("ReplacementContent") }), - ), - "write_to_file" => ( - "Write".into(), - json!({ "file_path": s("TargetFile"), "content": s("CodeContent") }), - ), - "read_url_content" => ("WebFetch".into(), json!({ "url": s("Url") })), - "search_web" => ("WebSearch".into(), json!({ "query": s("query") })), - _ => { - let mut input = args.clone(); - if let Some(o) = input.as_object_mut() { - o.remove("toolAction"); - o.remove("toolSummary"); - } - (name.to_string(), if input.is_object() { input } else { json!({}) }) - } - } -} - -/// Decode one step row into zero or more renderer messages, accumulating usage into `n`. -fn push_step(n: &mut Norm, payload: &[u8]) { - let fields = match wire_fields(payload) { - Some(f) => f, - None => return, - }; - let meta5 = field_msg(&fields, 5).unwrap_or_default(); - let ts = ts_of(&meta5, 1); - let with_ts = |mut m: Value| { - if let Some(t) = &ts { - m["ts"] = json!(t); - } - m - }; - - // user turn: #19 {2: text, 9: attachments {1 mime, 2 bytes, 5 path}} - if let Some(user) = field_msg(&fields, 19) { - let mut blocks: Vec = vec![]; - if let Some(text) = field_str(&user, 2) { - if !text.trim().is_empty() { - blocks.push(json!({ "type": "text", "text": text })); - } - } - for (f, w) in &user { - if *f != 9 { - continue; - } - if let Wire::Bytes(b) = w { - if let Some(att) = wire_fields(b) { - let mime = field_str(&att, 1).unwrap_or_default(); - let data = field_bytes(&att, 2); - match data { - // cap embedded images at 8 MB raw — larger ones degrade to a path note - Some(bytes) if mime.starts_with("image/") && bytes.len() <= 8_000_000 => { - blocks.push(json!({ - "type": "image", - "source": { "type": "base64", "media_type": mime, "data": b64_encode(bytes) } - })); - } - _ => { - if let Some(p) = field_str(&att, 5) { - blocks.push(json!({ "type": "text", "text": format!("[attachment: {}]", p) })); - } - } - } - } - } - } - if !blocks.is_empty() { - n.messages.push(with_ts(json!({ "role": "user", "content": blocks }))); - } - return; - } - - // tool call: #5.4 {1 id, 2 name, 3 args-json} (results are stored opaquely — omitted) - if let Some(call) = field_msg(&meta5, 4) { - let name = field_str(&call, 2).unwrap_or_else(|| "tool".into()); - let args: Value = field_str(&call, 3) - .and_then(|s| serde_json::from_str(&s).ok()) - .unwrap_or(json!({})); - let (tname, input) = map_tool(&name, &args); - let id = field_str(&call, 1).unwrap_or_default(); - n.messages.push(with_ts(json!({ - "role": "assistant", - "content": [{ "type": "tool_use", "id": id, "name": tname, "input": input }], - }))); - return; - } - - // model turn: #20.1 assistant text; #5.9 {2 input, 3 output} token stats - let turn20 = field_msg(&fields, 20); - let text = turn20.as_ref().and_then(|t| field_str(t, 1).or_else(|| field_str(t, 8))); - let stats = field_msg(&meta5, 9); - let usage = stats.as_ref().map(|st| { - let input = field_varint(st, 2).unwrap_or(0) as i64; - let output = field_varint(st, 3).unwrap_or(0) as i64; - json!({ "inputTokens": input, "outputTokens": output, "cacheRead": 0, "cacheCreation": 0 }) - }); - if let Some(text) = text { - if !text.trim().is_empty() { - let mut m = json!({ "role": "assistant", "content": [{ "type": "text", "text": text }] }); - if let Some(u) = &usage { - let input = u.get("inputTokens").and_then(|v| v.as_i64()).unwrap_or(0); - let output = u.get("outputTokens").and_then(|v| v.as_i64()).unwrap_or(0); - if input + output > 0 { - m["usage"] = u.clone(); - let t = n.totals.as_object_mut().unwrap(); - t["in"] = json!(t["in"].as_i64().unwrap_or(0) + input); - t["out"] = json!(t["out"].as_i64().unwrap_or(0) + output); - t["turns"] = json!(t["turns"].as_i64().unwrap_or(0) + 1); - } - } - n.messages.push(with_ts(m)); - } - } -} - -/// Depth-first search for the first utf8 string field with `prefix` anywhere in a message tree. -fn find_str_with_prefix(buf: &[u8], prefix: &str, depth: u8) -> Option { - let fields = wire_fields(buf)?; - for (_, w) in &fields { - if let Wire::Bytes(b) = w { - if let Ok(s) = std::str::from_utf8(b) { - if s.starts_with(prefix) { - return Some(s.to_string()); - } - } - if depth < 6 { - if let Some(found) = find_str_with_prefix(b, prefix, depth + 1) { - return Some(found); - } - } - } - } - None -} - -fn uri_to_path(uri: &str) -> String { - crate::grok::percent_decode(uri.strip_prefix("file://").unwrap_or(uri)) -} - -/// Workspace cwd for conversations the summaries DB hasn't indexed (a few percent of real -/// stores): the per-conversation trajectory_metadata_blob embeds the workspace file:// uri. -fn fallback_cwd(file: &Path) -> Option { - let conn = open_ro(file)?; - let blob: Vec = conn - .query_row("SELECT data FROM trajectory_metadata_blob LIMIT 1", [], |r| r.get(0)) - .ok()?; - find_str_with_prefix(&blob, "file://", 0).map(|u| uri_to_path(&u)) -} - -/// Title-of-last-resort for un-indexed conversations: the first user step's prose. -fn first_user_step_text(file: &Path) -> Option { - let conn = open_ro(file)?; - let payload: Vec = conn - .query_row("SELECT step_payload FROM steps WHERE step_type = 14 ORDER BY idx LIMIT 1", [], |r| r.get(0)) - .ok()?; - let fields = wire_fields(&payload)?; - let user = field_msg(&fields, 19)?; - let text = field_str(&user, 2)?; - let t: String = text.split_whitespace().collect::>().join(" "); - if t.is_empty() { - None - } else { - Some(t.chars().take(90).collect()) - } -} - -/// One conversation's summaries-DB row: (title, preview, first workspace path, step_count). -fn summaries_row(file: &Path) -> Option<(String, String, Option, i64)> { - let root = file.parent()?.parent()?; - let conn = open_ro(&root.join("conversation_summaries.db"))?; - let uuid = session_uuid(file); - conn.query_row( - "SELECT title, preview, workspace_uris, step_count FROM conversation_summaries WHERE conversation_id = ?1", - [&uuid], - |row| { - let title: String = row.get(0).unwrap_or_default(); - let preview: String = row.get(1).unwrap_or_default(); - let uris: String = row.get(2).unwrap_or_default(); - let steps: i64 = row.get(3).unwrap_or(0); - Ok((title, preview, uris, steps)) - }, - ) - .ok() - .map(|(title, preview, uris, steps)| { - let cwd = serde_json::from_str::(&uris) - .ok() - .and_then(|v| v.as_array().and_then(|a| a.first().cloned())) - .and_then(|u| u.as_str().map(|s| s.to_string())) - .map(|u| uri_to_path(&u)); - (title, preview, cwd, steps) - }) -} - -/// Read + normalize a conversation DB into the renderer's message model. -pub fn normalize_db(file: &Path) -> Norm { - let mut n = Norm::default(); - if let Some((_, _, cwd, _)) = summaries_row(file) { - n.cwd = cwd; - } - if n.cwd.is_none() { - n.cwd = fallback_cwd(file); - } - n.session_id = Some(session_uuid(file)); - let conn = match open_ro(file) { - Some(c) => c, - None => return n, - }; - let mut stmt = match conn.prepare("SELECT step_payload FROM steps ORDER BY idx") { - Ok(s) => s, - Err(_) => return n, - }; - let rows = stmt.query_map([], |row| row.get::<_, Vec>(0)); - if let Ok(rows) = rows { - for payload in rows.flatten() { - push_step(&mut n, &payload); - } - } - n.first_ts = n.messages.first().and_then(|m| m.get("ts")).and_then(|v| v.as_str()).map(|s| s.to_string()); - n.last_ts = n.messages.last().and_then(|m| m.get("ts")).and_then(|v| v.as_str()).map(|s| s.to_string()); - n -} - -/// Creation stamp (ms) from the first step's timestamp — content-derived, immune to file -/// rewrites, matching record_created_ms semantics for jsonl sources. -fn first_step_ms(file: &Path) -> Option { - let conn = open_ro(file)?; - let payload: Vec = conn - .query_row("SELECT step_payload FROM steps ORDER BY idx LIMIT 1", [], |r| r.get(0)) - .ok()?; - let fields = wire_fields(&payload)?; - let meta5 = field_msg(&fields, 5)?; - ts_ms_of(&meta5, 1) -} - -/// List-row meta — summaries DB + first-step timestamp; never parses the full step log. -pub fn session_meta_from(file: &Path, dir_id: &str, dir_label: &str) -> Option { - let meta = fs::metadata(file).ok()?; - let uuid = session_uuid(file); - let (cc_title, cc_tags, cc_deleted) = sidecar_meta(file); - let sum = summaries_row(file); - let (sum_title, preview, cwd) = match &sum { - Some((t, p, c, _)) => (t.trim().to_string(), p.trim().to_string(), c.clone()), - None => (String::new(), String::new(), None), - }; - let cwd = cwd.or_else(|| fallback_cwd(file)); - let auto_title: String = if !sum_title.is_empty() { - sum_title - } else if !preview.is_empty() { - preview.chars().take(90).collect() - } else { - first_user_step_text(file).unwrap_or_default() - }; - let created = first_step_ms(file).unwrap_or_else(|| crate::history::created_ms(file)); - Some(json!({ - "id": format!("antigravity:{}", uuid), - "file": file.to_string_lossy(), - "source": "antigravity", - "dirId": dir_id, - "dirLabel": dir_label, - "sessionId": uuid, - "cwd": cwd.clone(), - "project": cwd.as_deref().map(crate::history::base_name).unwrap_or_default(), - "gitBranch": Value::Null, - "title": cc_title.clone().unwrap_or_else(|| auto_title.clone()), - "autoTitle": auto_title, - "tags": cc_tags, - "model": Value::Null, - "isSubagent": false, - "imported": false, - "deleted": cc_deleted, - "createdAt": created, - "lastActivity": wal_mtime_ms(file), - "sizeKB": (meta.len() as f64 / 1024.0).round() as i64, - })) -} - -/// Full-detail shape (history.rs get_session routes here — the source is SQLite, not jsonl). -pub fn session_from(file: &str) -> Value { - let path = Path::new(file); - let n = normalize_db(path); - let (cc_title, cc_tags, cc_deleted) = sidecar_meta(path); - let sum = summaries_row(path); - let sum_title = sum - .as_ref() - .map(|(t, _, _, _)| t.trim().to_string()) - .filter(|s| !s.is_empty()); - let auto_title = sum_title.unwrap_or_else(|| crate::history::first_user_text(&n.messages)); - let uuid = session_uuid(path); - json!({ - "meta": { - "id": format!("antigravity:{}", uuid), - "file": file, - "source": "antigravity", - "assistant": "Antigravity", - "title": cc_title.clone().unwrap_or_else(|| auto_title.clone()), - "autoTitle": auto_title, - "tags": cc_tags, - "summary": Value::Null, - "sessionId": uuid, - "cwd": n.cwd.clone(), - "project": n.cwd.as_deref().map(crate::history::base_name).unwrap_or_default(), - "gitBranch": Value::Null, - "version": Value::Null, - "isSubagent": false, - "deleted": cc_deleted, - "imported": false, - "importedFrom": Value::Null, - "importedAt": Value::Null, - "model": n.model, - "totals": n.totals, - "messages": n.messages.len(), - "subagentCount": 0, - "firstTs": n.first_ts, - "lastTs": n.last_ts, - }, - "messages": n.messages, - "subagents": {}, - }) -} - -#[cfg(test)] -mod tests { - use super::*; - - // hand-rolled wire encoding helpers (tests only) - fn enc_varint(mut v: u64, out: &mut Vec) { - loop { - let b = (v & 0x7f) as u8; - v >>= 7; - if v == 0 { - out.push(b); - break; - } - out.push(b | 0x80); - } - } - fn tag(field: u32, wt: u8, out: &mut Vec) { - enc_varint(((field as u64) << 3) | wt as u64, out); - } - fn put_varint(field: u32, v: u64, out: &mut Vec) { - tag(field, 0, out); - enc_varint(v, out); - } - fn put_bytes(field: u32, data: &[u8], out: &mut Vec) { - tag(field, 2, out); - enc_varint(data.len() as u64, out); - out.extend_from_slice(data); - } - fn put_str(field: u32, s: &str, out: &mut Vec) { - put_bytes(field, s.as_bytes(), out); - } - fn ts_msg(secs: u64) -> Vec { - let mut m = vec![]; - put_varint(1, secs, &mut m); - put_varint(2, 500_000_000, &mut m); - m - } - - fn user_step(text: &str) -> Vec { - let mut meta5 = vec![]; - put_bytes(1, &ts_msg(1_783_811_237), &mut meta5); - let mut u19 = vec![]; - put_str(2, text, &mut u19); - let mut att = vec![]; - put_str(1, "image/png", &mut att); - put_bytes(2, b"ABC", &mut att); - put_str(5, "/tmp/x.png", &mut att); - put_bytes(9, &att, &mut u19); - let mut step = vec![]; - put_varint(1, 14, &mut step); - put_varint(4, 3, &mut step); - put_bytes(5, &meta5, &mut step); - put_bytes(19, &u19, &mut step); - step - } - - fn tool_step() -> Vec { - let mut call = vec![]; - put_str(1, "call-9", &mut call); - put_str(2, "run_command", &mut call); - put_str(3, "{\"CommandLine\":\"ls -la\",\"Cwd\":\"/tmp\",\"toolSummary\":\"Run\"}", &mut call); - let mut meta5 = vec![]; - put_bytes(1, &ts_msg(1_783_811_240), &mut meta5); - put_bytes(4, &call, &mut meta5); - let mut step = vec![]; - put_varint(1, 21, &mut step); - put_varint(4, 3, &mut step); - put_bytes(5, &meta5, &mut step); - step - } - - fn gen_step(text: &str) -> Vec { - let mut stats = vec![]; - put_varint(1, 1132, &mut stats); - put_varint(2, 20245, &mut stats); - put_varint(3, 346, &mut stats); - let mut meta5 = vec![]; - put_bytes(1, &ts_msg(1_783_811_242), &mut meta5); - put_bytes(9, &stats, &mut meta5); - let mut t20 = vec![]; - put_str(1, text, &mut t20); - let mut step = vec![]; - put_varint(1, 15, &mut step); - put_varint(4, 3, &mut step); - put_bytes(5, &meta5, &mut step); - put_bytes(20, &t20, &mut step); - step - } - - #[test] - fn decodes_steps() { - let mut n = Norm::default(); - push_step(&mut n, &user_step("修复登录")); - push_step(&mut n, &tool_step()); - push_step(&mut n, &gen_step("已修复。")); - assert_eq!(n.messages.len(), 3); - assert_eq!(n.messages[0]["role"], "user"); - assert_eq!(n.messages[0]["content"][0]["text"], "修复登录"); - assert_eq!(n.messages[0]["content"][1]["type"], "image"); - assert_eq!(n.messages[0]["content"][1]["source"]["data"], "QUJD"); - let tool = &n.messages[1]["content"][0]; - assert_eq!(tool["name"], "Bash"); - assert_eq!(tool["input"]["command"], "ls -la"); - assert_eq!(tool["id"], "call-9"); - assert_eq!(n.messages[2]["content"][0]["text"], "已修复。"); - assert_eq!(n.messages[2]["usage"]["inputTokens"], 20245); - assert_eq!(n.messages[2]["usage"]["outputTokens"], 346); - assert_eq!(n.totals["in"], 20245); - assert_eq!(n.totals["turns"], 1); - assert!(n.messages[0]["ts"].as_str().unwrap().starts_with("2026-")); - } - - #[test] - fn garbage_payload_is_skipped() { - let mut n = Norm::default(); - push_step(&mut n, &[0xff, 0x00, 0x13, 0x37]); - push_step(&mut n, b""); - assert!(n.messages.is_empty()); - } - - #[test] - fn b64_matches_reference() { - assert_eq!(b64_encode(b"ABC"), "QUJD"); - assert_eq!(b64_encode(b"AB"), "QUI="); - assert_eq!(b64_encode(b"A"), "QQ=="); - assert_eq!(b64_encode(b""), ""); - } - - #[test] - fn detects_paths() { - assert!(looks_agy_path(Path::new("/x/antigravity-cli/conversations/ab-1.db"))); - assert!(!looks_agy_path(Path::new("/x/antigravity-cli/conversation_summaries.db"))); - assert!(!looks_agy_path(Path::new("/x/conversations/notes.txt"))); - } -} diff --git a/src-tauri/src/antigravity/content.rs b/src-tauri/src/antigravity/content.rs new file mode 100644 index 0000000..db87cb9 --- /dev/null +++ b/src-tauri/src/antigravity/content.rs @@ -0,0 +1,65 @@ +// Attachment base64 and the Antigravity tool → renderer tool mapping. Moved verbatim from +// antigravity.rs. + +use serde_json::{json, Value}; + +// ---- content mapping ---- + +const B64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + +pub(super) fn b64_encode(data: &[u8]) -> String { + let mut out = String::with_capacity((data.len() + 2) / 3 * 4); + for chunk in data.chunks(3) { + let b = [chunk[0], *chunk.get(1).unwrap_or(&0), *chunk.get(2).unwrap_or(&0)]; + let n = ((b[0] as u32) << 16) | ((b[1] as u32) << 8) | b[2] as u32; + out.push(B64[(n >> 18) as usize & 63] as char); + out.push(B64[(n >> 12) as usize & 63] as char); + out.push(if chunk.len() > 1 { B64[(n >> 6) as usize & 63] as char } else { '=' }); + out.push(if chunk.len() > 2 { B64[n as usize & 63] as char } else { '=' }); + } + out +} + +/// Antigravity tool name + parsed arguments → (renderer tool name, renderer input). The args +/// JSON carries display strings (toolAction/toolSummary) alongside the real params — dropped +/// from generic passthrough to keep cards clean. +pub(super) fn map_tool(name: &str, args: &Value) -> (String, Value) { + let s = |k: &str| args.get(k).and_then(|v| v.as_str()).unwrap_or("").to_string(); + match name { + "run_command" => { + let mut input = json!({ "command": s("CommandLine") }); + if !s("Cwd").is_empty() { + input["description"] = json!(s("Cwd")); + } + ("Bash".into(), input) + } + "view_file" => ("Read".into(), json!({ "file_path": s("AbsolutePath") })), + "list_dir" => ("LS".into(), json!({ "path": s("DirectoryPath") })), + "grep_search" => { + let mut input = json!({ "pattern": s("Query") }); + if !s("SearchPath").is_empty() { + input["path"] = json!(s("SearchPath")); + } + ("Grep".into(), input) + } + "find_by_name" => ("Glob".into(), json!({ "pattern": s("Pattern"), "path": s("SearchDirectory") })), + "replace_file_content" => ( + "Edit".into(), + json!({ "file_path": s("TargetFile"), "old_string": s("TargetContent"), "new_string": s("ReplacementContent") }), + ), + "write_to_file" => ( + "Write".into(), + json!({ "file_path": s("TargetFile"), "content": s("CodeContent") }), + ), + "read_url_content" => ("WebFetch".into(), json!({ "url": s("Url") })), + "search_web" => ("WebSearch".into(), json!({ "query": s("query") })), + _ => { + let mut input = args.clone(); + if let Some(o) = input.as_object_mut() { + o.remove("toolAction"); + o.remove("toolSummary"); + } + (name.to_string(), if input.is_object() { input } else { json!({}) }) + } + } +} diff --git a/src-tauri/src/antigravity/mod.rs b/src-tauri/src/antigravity/mod.rs new file mode 100644 index 0000000..1746ab4 --- /dev/null +++ b/src-tauri/src/antigravity/mod.rs @@ -0,0 +1,40 @@ +// Google Antigravity CLI (`agy`) session support — reads its per-conversation SQLite stores +// (`~/.gemini/antigravity-cli/conversations/.db`, `steps` table) plus the sibling +// `conversation_summaries.db` (title / preview / workspace uris — plain text), and normalizes +// them into the SAME session/message shape the renderer consumes (history::Norm). +// +// A step's `step_payload` is a protobuf blob with no published schema. A minimal wire-format +// walker recovers the stable fields (reverse-engineered against real conversations): +// #1 step type enum #4 status +// #5 metadata: #5.1 {sec,nanos} created · #5.4 tool call {#1 id, #2 name, #3 args-JSON, +// #7 result (opaque/encrypted — not recoverable)} · #5.9 generation stats +// {#2 input tokens, #3 output tokens} +// #19 user input: #19.2 text · #19.9 attachments {#1 mime, #2 bytes, #5 path} +// #20 model turn: #20.1 assistant text +// Steps whose payload drifts from this map degrade to being skipped (never crash) — the +// summaries DB alone still lists the conversation. Tool RESULTS are stored in a non-readable +// encoding, so tool cards show name/args and the renderer's "no result" marker. +// +// DBs may be WAL-journaled and open in a live agy process: connections are read-only with a +// short busy timeout, and freshness checks use max(mtime(db), mtime(db-wal)). +// +// Title/tags/soft-delete live in the shared foreign-CLI sidecar (~/.ccbud/agent-meta.json) +// keyed `antigravity:` — the DBs belong to another tool and are never written. + +#![allow(dead_code)] +mod content; +mod roots; +mod session; +mod steps; +mod wire; +#[cfg(test)] +mod tests; + +pub use roots::{ + agy_label, is_deleted, looks_agy_path, root_exists, set_meta, wal_mtime_ms, walk, +}; +// Part of the module's API but currently only referenced from within it — a non-test build sees +// the re-export as unused; allow that instead of dropping the path. +#[allow(unused_imports)] +pub use roots::default_root; +pub use session::{normalize_db, session_from, session_meta_from}; diff --git a/src-tauri/src/antigravity/roots.rs b/src-tauri/src/antigravity/roots.rs new file mode 100644 index 0000000..4525593 --- /dev/null +++ b/src-tauri/src/antigravity/roots.rs @@ -0,0 +1,99 @@ +// Antigravity data root, path routing, the shared foreign-CLI sidecar, freshness and the +// read-only SQLite connection. Moved verbatim from antigravity.rs. + +use rusqlite::{Connection, OpenFlags}; +use serde_json::{json, Value}; +use std::fs; +use std::path::{Path, PathBuf}; + +fn home() -> PathBuf { + std::env::var("HOME").map(PathBuf::from).unwrap_or_else(|_| PathBuf::from(".")) +} + +/// Antigravity CLI's data dir as a history-dir entry string (`~/.gemini/antigravity-cli`). +pub fn default_root() -> PathBuf { + home().join(".gemini").join("antigravity-cli") +} + +pub fn agy_label() -> String { + crate::store::collapse_home(&default_root().to_string_lossy()) +} + +pub fn root_exists() -> bool { + default_root().join("conversations").is_dir() +} + +/// Walk every conversation DB under a `conversations/` dir. +pub fn walk(conversations_dir: &Path, cb: &mut F) { + let entries = match fs::read_dir(conversations_dir) { + Ok(e) => e, + Err(_) => return, + }; + for ent in entries.flatten() { + let p = ent.path(); + if p.is_file() && p.extension().and_then(|e| e.to_str()) == Some("db") { + cb(p); + } + } +} + +/// Container-shape test for detail/edit routing: `…/conversations/.db`. +pub fn looks_agy_path(file: &Path) -> bool { + file.extension().and_then(|e| e.to_str()) == Some("db") + && file + .parent() + .and_then(|d| d.file_name()) + .and_then(|n| n.to_str()) + .map(|n| n == "conversations") + .unwrap_or(false) +} + +pub(super) fn session_uuid(file: &Path) -> String { + file.file_stem().and_then(|s| s.to_str()).unwrap_or("").to_string() +} + +fn sidecar_key(file: &Path) -> String { + format!("antigravity:{}", session_uuid(file)) +} + +pub(super) fn sidecar_meta(file: &Path) -> (Option, Vec, bool) { + crate::sidecar::meta(&crate::sidecar::agent_file(), &sidecar_key(file)) +} + +pub fn is_deleted(file: &Path) -> bool { + sidecar_meta(file).2 +} + +pub fn set_meta(file: &str, patch: &Value) -> Value { + let key = sidecar_key(Path::new(file)); + if key == "antigravity:" { + return json!({ "ok": false, "reason": "empty" }); + } + crate::sidecar::set_meta(&crate::sidecar::agent_file(), &key, patch) +} + +/// WAL-aware freshness stamp: a live agy writes into `-wal` without touching the main +/// file's mtime, so cache keys must take the max of both. +pub fn wal_mtime_ms(file: &Path) -> f64 { + let m = |p: &Path| { + fs::metadata(p) + .and_then(|md| md.modified()) + .ok() + .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|d| d.as_millis() as f64) + .unwrap_or(0.0) + }; + let mut wal = file.as_os_str().to_os_string(); + wal.push("-wal"); + m(file).max(m(Path::new(&wal))) +} + +pub(super) fn open_ro(path: &Path) -> Option { + let conn = Connection::open_with_flags( + path, + OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX, + ) + .ok()?; + let _ = conn.busy_timeout(std::time::Duration::from_millis(400)); + Some(conn) +} diff --git a/src-tauri/src/antigravity/session.rs b/src-tauri/src/antigravity/session.rs new file mode 100644 index 0000000..81c8d0d --- /dev/null +++ b/src-tauri/src/antigravity/session.rs @@ -0,0 +1,196 @@ +// Whole-conversation normalization: the summaries DB row, the per-conversation steps DB, and +// the session/meta payloads the renderer consumes. Moved verbatim from antigravity.rs. + +use crate::history::Norm; +use serde_json::{json, Value}; +use std::fs; +use std::path::Path; + +use super::roots::{open_ro, session_uuid, sidecar_meta, wal_mtime_ms}; +use super::steps::{find_str_with_prefix, push_step}; +use super::wire::{field_msg, field_str, ts_ms_of, wire_fields}; + +fn uri_to_path(uri: &str) -> String { + crate::grok::percent_decode(uri.strip_prefix("file://").unwrap_or(uri)) +} + +/// Workspace cwd for conversations the summaries DB hasn't indexed (a few percent of real +/// stores): the per-conversation trajectory_metadata_blob embeds the workspace file:// uri. +fn fallback_cwd(file: &Path) -> Option { + let conn = open_ro(file)?; + let blob: Vec = conn + .query_row("SELECT data FROM trajectory_metadata_blob LIMIT 1", [], |r| r.get(0)) + .ok()?; + find_str_with_prefix(&blob, "file://", 0).map(|u| uri_to_path(&u)) +} + +/// Title-of-last-resort for un-indexed conversations: the first user step's prose. +fn first_user_step_text(file: &Path) -> Option { + let conn = open_ro(file)?; + let payload: Vec = conn + .query_row("SELECT step_payload FROM steps WHERE step_type = 14 ORDER BY idx LIMIT 1", [], |r| r.get(0)) + .ok()?; + let fields = wire_fields(&payload)?; + let user = field_msg(&fields, 19)?; + let text = field_str(&user, 2)?; + let t: String = text.split_whitespace().collect::>().join(" "); + if t.is_empty() { + None + } else { + Some(t.chars().take(90).collect()) + } +} + +/// One conversation's summaries-DB row: (title, preview, first workspace path, step_count). +fn summaries_row(file: &Path) -> Option<(String, String, Option, i64)> { + let root = file.parent()?.parent()?; + let conn = open_ro(&root.join("conversation_summaries.db"))?; + let uuid = session_uuid(file); + conn.query_row( + "SELECT title, preview, workspace_uris, step_count FROM conversation_summaries WHERE conversation_id = ?1", + [&uuid], + |row| { + let title: String = row.get(0).unwrap_or_default(); + let preview: String = row.get(1).unwrap_or_default(); + let uris: String = row.get(2).unwrap_or_default(); + let steps: i64 = row.get(3).unwrap_or(0); + Ok((title, preview, uris, steps)) + }, + ) + .ok() + .map(|(title, preview, uris, steps)| { + let cwd = serde_json::from_str::(&uris) + .ok() + .and_then(|v| v.as_array().and_then(|a| a.first().cloned())) + .and_then(|u| u.as_str().map(|s| s.to_string())) + .map(|u| uri_to_path(&u)); + (title, preview, cwd, steps) + }) +} + +/// Read + normalize a conversation DB into the renderer's message model. +pub fn normalize_db(file: &Path) -> Norm { + let mut n = Norm::default(); + if let Some((_, _, cwd, _)) = summaries_row(file) { + n.cwd = cwd; + } + if n.cwd.is_none() { + n.cwd = fallback_cwd(file); + } + n.session_id = Some(session_uuid(file)); + let conn = match open_ro(file) { + Some(c) => c, + None => return n, + }; + let mut stmt = match conn.prepare("SELECT step_payload FROM steps ORDER BY idx") { + Ok(s) => s, + Err(_) => return n, + }; + let rows = stmt.query_map([], |row| row.get::<_, Vec>(0)); + if let Ok(rows) = rows { + for payload in rows.flatten() { + push_step(&mut n, &payload); + } + } + n.first_ts = n.messages.first().and_then(|m| m.get("ts")).and_then(|v| v.as_str()).map(|s| s.to_string()); + n.last_ts = n.messages.last().and_then(|m| m.get("ts")).and_then(|v| v.as_str()).map(|s| s.to_string()); + n +} + +/// Creation stamp (ms) from the first step's timestamp — content-derived, immune to file +/// rewrites, matching record_created_ms semantics for jsonl sources. +fn first_step_ms(file: &Path) -> Option { + let conn = open_ro(file)?; + let payload: Vec = conn + .query_row("SELECT step_payload FROM steps ORDER BY idx LIMIT 1", [], |r| r.get(0)) + .ok()?; + let fields = wire_fields(&payload)?; + let meta5 = field_msg(&fields, 5)?; + ts_ms_of(&meta5, 1) +} + +/// List-row meta — summaries DB + first-step timestamp; never parses the full step log. +pub fn session_meta_from(file: &Path, dir_id: &str, dir_label: &str) -> Option { + let meta = fs::metadata(file).ok()?; + let uuid = session_uuid(file); + let (cc_title, cc_tags, cc_deleted) = sidecar_meta(file); + let sum = summaries_row(file); + let (sum_title, preview, cwd) = match &sum { + Some((t, p, c, _)) => (t.trim().to_string(), p.trim().to_string(), c.clone()), + None => (String::new(), String::new(), None), + }; + let cwd = cwd.or_else(|| fallback_cwd(file)); + let auto_title: String = if !sum_title.is_empty() { + sum_title + } else if !preview.is_empty() { + preview.chars().take(90).collect() + } else { + first_user_step_text(file).unwrap_or_default() + }; + let created = first_step_ms(file).unwrap_or_else(|| crate::history::created_ms(file)); + Some(json!({ + "id": format!("antigravity:{}", uuid), + "file": file.to_string_lossy(), + "source": "antigravity", + "dirId": dir_id, + "dirLabel": dir_label, + "sessionId": uuid, + "cwd": cwd.clone(), + "project": cwd.as_deref().map(crate::history::base_name).unwrap_or_default(), + "gitBranch": Value::Null, + "title": cc_title.clone().unwrap_or_else(|| auto_title.clone()), + "autoTitle": auto_title, + "tags": cc_tags, + "model": Value::Null, + "isSubagent": false, + "imported": false, + "deleted": cc_deleted, + "createdAt": created, + "lastActivity": wal_mtime_ms(file), + "sizeKB": (meta.len() as f64 / 1024.0).round() as i64, + })) +} + +/// Full-detail shape (history.rs get_session routes here — the source is SQLite, not jsonl). +pub fn session_from(file: &str) -> Value { + let path = Path::new(file); + let n = normalize_db(path); + let (cc_title, cc_tags, cc_deleted) = sidecar_meta(path); + let sum = summaries_row(path); + let sum_title = sum + .as_ref() + .map(|(t, _, _, _)| t.trim().to_string()) + .filter(|s| !s.is_empty()); + let auto_title = sum_title.unwrap_or_else(|| crate::history::first_user_text(&n.messages)); + let uuid = session_uuid(path); + json!({ + "meta": { + "id": format!("antigravity:{}", uuid), + "file": file, + "source": "antigravity", + "assistant": "Antigravity", + "title": cc_title.clone().unwrap_or_else(|| auto_title.clone()), + "autoTitle": auto_title, + "tags": cc_tags, + "summary": Value::Null, + "sessionId": uuid, + "cwd": n.cwd.clone(), + "project": n.cwd.as_deref().map(crate::history::base_name).unwrap_or_default(), + "gitBranch": Value::Null, + "version": Value::Null, + "isSubagent": false, + "deleted": cc_deleted, + "imported": false, + "importedFrom": Value::Null, + "importedAt": Value::Null, + "model": n.model, + "totals": n.totals, + "messages": n.messages.len(), + "subagentCount": 0, + "firstTs": n.first_ts, + "lastTs": n.last_ts, + }, + "messages": n.messages, + "subagents": {}, + }) +} diff --git a/src-tauri/src/antigravity/steps.rs b/src-tauri/src/antigravity/steps.rs new file mode 100644 index 0000000..a2c9756 --- /dev/null +++ b/src-tauri/src/antigravity/steps.rs @@ -0,0 +1,127 @@ +// One `steps` row → renderer messages, plus the depth-first string probe the cwd fallback uses. +// Moved verbatim from antigravity.rs. + +use crate::history::Norm; +use serde_json::{json, Value}; + +use super::content::{b64_encode, map_tool}; +use super::wire::{ + field_bytes, field_msg, field_str, field_varint, ts_of, wire_fields, Wire, +}; + +/// Decode one step row into zero or more renderer messages, accumulating usage into `n`. +pub(super) fn push_step(n: &mut Norm, payload: &[u8]) { + let fields = match wire_fields(payload) { + Some(f) => f, + None => return, + }; + let meta5 = field_msg(&fields, 5).unwrap_or_default(); + let ts = ts_of(&meta5, 1); + let with_ts = |mut m: Value| { + if let Some(t) = &ts { + m["ts"] = json!(t); + } + m + }; + + // user turn: #19 {2: text, 9: attachments {1 mime, 2 bytes, 5 path}} + if let Some(user) = field_msg(&fields, 19) { + let mut blocks: Vec = vec![]; + if let Some(text) = field_str(&user, 2) { + if !text.trim().is_empty() { + blocks.push(json!({ "type": "text", "text": text })); + } + } + for (f, w) in &user { + if *f != 9 { + continue; + } + if let Wire::Bytes(b) = w { + if let Some(att) = wire_fields(b) { + let mime = field_str(&att, 1).unwrap_or_default(); + let data = field_bytes(&att, 2); + match data { + // cap embedded images at 8 MB raw — larger ones degrade to a path note + Some(bytes) if mime.starts_with("image/") && bytes.len() <= 8_000_000 => { + blocks.push(json!({ + "type": "image", + "source": { "type": "base64", "media_type": mime, "data": b64_encode(bytes) } + })); + } + _ => { + if let Some(p) = field_str(&att, 5) { + blocks.push(json!({ "type": "text", "text": format!("[attachment: {}]", p) })); + } + } + } + } + } + } + if !blocks.is_empty() { + n.messages.push(with_ts(json!({ "role": "user", "content": blocks }))); + } + return; + } + + // tool call: #5.4 {1 id, 2 name, 3 args-json} (results are stored opaquely — omitted) + if let Some(call) = field_msg(&meta5, 4) { + let name = field_str(&call, 2).unwrap_or_else(|| "tool".into()); + let args: Value = field_str(&call, 3) + .and_then(|s| serde_json::from_str(&s).ok()) + .unwrap_or(json!({})); + let (tname, input) = map_tool(&name, &args); + let id = field_str(&call, 1).unwrap_or_default(); + n.messages.push(with_ts(json!({ + "role": "assistant", + "content": [{ "type": "tool_use", "id": id, "name": tname, "input": input }], + }))); + return; + } + + // model turn: #20.1 assistant text; #5.9 {2 input, 3 output} token stats + let turn20 = field_msg(&fields, 20); + let text = turn20.as_ref().and_then(|t| field_str(t, 1).or_else(|| field_str(t, 8))); + let stats = field_msg(&meta5, 9); + let usage = stats.as_ref().map(|st| { + let input = field_varint(st, 2).unwrap_or(0) as i64; + let output = field_varint(st, 3).unwrap_or(0) as i64; + json!({ "inputTokens": input, "outputTokens": output, "cacheRead": 0, "cacheCreation": 0 }) + }); + if let Some(text) = text { + if !text.trim().is_empty() { + let mut m = json!({ "role": "assistant", "content": [{ "type": "text", "text": text }] }); + if let Some(u) = &usage { + let input = u.get("inputTokens").and_then(|v| v.as_i64()).unwrap_or(0); + let output = u.get("outputTokens").and_then(|v| v.as_i64()).unwrap_or(0); + if input + output > 0 { + m["usage"] = u.clone(); + let t = n.totals.as_object_mut().unwrap(); + t["in"] = json!(t["in"].as_i64().unwrap_or(0) + input); + t["out"] = json!(t["out"].as_i64().unwrap_or(0) + output); + t["turns"] = json!(t["turns"].as_i64().unwrap_or(0) + 1); + } + } + n.messages.push(with_ts(m)); + } + } +} + +/// Depth-first search for the first utf8 string field with `prefix` anywhere in a message tree. +pub(super) fn find_str_with_prefix(buf: &[u8], prefix: &str, depth: u8) -> Option { + let fields = wire_fields(buf)?; + for (_, w) in &fields { + if let Wire::Bytes(b) = w { + if let Ok(s) = std::str::from_utf8(b) { + if s.starts_with(prefix) { + return Some(s.to_string()); + } + } + if depth < 6 { + if let Some(found) = find_str_with_prefix(b, prefix, depth + 1) { + return Some(found); + } + } + } + } + None +} diff --git a/src-tauri/src/antigravity/tests.rs b/src-tauri/src/antigravity/tests.rs new file mode 100644 index 0000000..a153ff7 --- /dev/null +++ b/src-tauri/src/antigravity/tests.rs @@ -0,0 +1,136 @@ +use super::content::b64_encode; +use super::roots::looks_agy_path; +use super::steps::push_step; +use crate::history::Norm; +use std::path::Path; + +// hand-rolled wire encoding helpers (tests only) +fn enc_varint(mut v: u64, out: &mut Vec) { + loop { + let b = (v & 0x7f) as u8; + v >>= 7; + if v == 0 { + out.push(b); + break; + } + out.push(b | 0x80); + } +} +fn tag(field: u32, wt: u8, out: &mut Vec) { + enc_varint(((field as u64) << 3) | wt as u64, out); +} +fn put_varint(field: u32, v: u64, out: &mut Vec) { + tag(field, 0, out); + enc_varint(v, out); +} +fn put_bytes(field: u32, data: &[u8], out: &mut Vec) { + tag(field, 2, out); + enc_varint(data.len() as u64, out); + out.extend_from_slice(data); +} +fn put_str(field: u32, s: &str, out: &mut Vec) { + put_bytes(field, s.as_bytes(), out); +} +fn ts_msg(secs: u64) -> Vec { + let mut m = vec![]; + put_varint(1, secs, &mut m); + put_varint(2, 500_000_000, &mut m); + m +} + +fn user_step(text: &str) -> Vec { + let mut meta5 = vec![]; + put_bytes(1, &ts_msg(1_783_811_237), &mut meta5); + let mut u19 = vec![]; + put_str(2, text, &mut u19); + let mut att = vec![]; + put_str(1, "image/png", &mut att); + put_bytes(2, b"ABC", &mut att); + put_str(5, "/tmp/x.png", &mut att); + put_bytes(9, &att, &mut u19); + let mut step = vec![]; + put_varint(1, 14, &mut step); + put_varint(4, 3, &mut step); + put_bytes(5, &meta5, &mut step); + put_bytes(19, &u19, &mut step); + step +} + +fn tool_step() -> Vec { + let mut call = vec![]; + put_str(1, "call-9", &mut call); + put_str(2, "run_command", &mut call); + put_str(3, "{\"CommandLine\":\"ls -la\",\"Cwd\":\"/tmp\",\"toolSummary\":\"Run\"}", &mut call); + let mut meta5 = vec![]; + put_bytes(1, &ts_msg(1_783_811_240), &mut meta5); + put_bytes(4, &call, &mut meta5); + let mut step = vec![]; + put_varint(1, 21, &mut step); + put_varint(4, 3, &mut step); + put_bytes(5, &meta5, &mut step); + step +} + +fn gen_step(text: &str) -> Vec { + let mut stats = vec![]; + put_varint(1, 1132, &mut stats); + put_varint(2, 20245, &mut stats); + put_varint(3, 346, &mut stats); + let mut meta5 = vec![]; + put_bytes(1, &ts_msg(1_783_811_242), &mut meta5); + put_bytes(9, &stats, &mut meta5); + let mut t20 = vec![]; + put_str(1, text, &mut t20); + let mut step = vec![]; + put_varint(1, 15, &mut step); + put_varint(4, 3, &mut step); + put_bytes(5, &meta5, &mut step); + put_bytes(20, &t20, &mut step); + step +} + +#[test] +fn decodes_steps() { + let mut n = Norm::default(); + push_step(&mut n, &user_step("修复登录")); + push_step(&mut n, &tool_step()); + push_step(&mut n, &gen_step("已修复。")); + assert_eq!(n.messages.len(), 3); + assert_eq!(n.messages[0]["role"], "user"); + assert_eq!(n.messages[0]["content"][0]["text"], "修复登录"); + assert_eq!(n.messages[0]["content"][1]["type"], "image"); + assert_eq!(n.messages[0]["content"][1]["source"]["data"], "QUJD"); + let tool = &n.messages[1]["content"][0]; + assert_eq!(tool["name"], "Bash"); + assert_eq!(tool["input"]["command"], "ls -la"); + assert_eq!(tool["id"], "call-9"); + assert_eq!(n.messages[2]["content"][0]["text"], "已修复。"); + assert_eq!(n.messages[2]["usage"]["inputTokens"], 20245); + assert_eq!(n.messages[2]["usage"]["outputTokens"], 346); + assert_eq!(n.totals["in"], 20245); + assert_eq!(n.totals["turns"], 1); + assert!(n.messages[0]["ts"].as_str().unwrap().starts_with("2026-")); +} + +#[test] +fn garbage_payload_is_skipped() { + let mut n = Norm::default(); + push_step(&mut n, &[0xff, 0x00, 0x13, 0x37]); + push_step(&mut n, b""); + assert!(n.messages.is_empty()); +} + +#[test] +fn b64_matches_reference() { + assert_eq!(b64_encode(b"ABC"), "QUJD"); + assert_eq!(b64_encode(b"AB"), "QUI="); + assert_eq!(b64_encode(b"A"), "QQ=="); + assert_eq!(b64_encode(b""), ""); +} + +#[test] +fn detects_paths() { + assert!(looks_agy_path(Path::new("/x/antigravity-cli/conversations/ab-1.db"))); + assert!(!looks_agy_path(Path::new("/x/antigravity-cli/conversation_summaries.db"))); + assert!(!looks_agy_path(Path::new("/x/conversations/notes.txt"))); +} diff --git a/src-tauri/src/antigravity/wire.rs b/src-tauri/src/antigravity/wire.rs new file mode 100644 index 0000000..d3cbdcd --- /dev/null +++ b/src-tauri/src/antigravity/wire.rs @@ -0,0 +1,106 @@ +// The schema-less protobuf wire walker used to recover a step payload's stable fields. Moved +// verbatim from antigravity.rs. + +// ---- protobuf wire walker (schema-less) ---- + +pub(super) enum Wire { + Varint(u64), + Bytes(Vec), + Fixed, +} + +/// One message level → (field number, value) pairs. None when the buffer isn't a valid message. +pub(super) fn wire_fields(buf: &[u8]) -> Option> { + let mut out = vec![]; + let mut i = 0usize; + fn varint(buf: &[u8], i: &mut usize) -> Option { + let mut v: u64 = 0; + let mut shift = 0u32; + loop { + let b = *buf.get(*i)?; + *i += 1; + v |= ((b & 0x7f) as u64) << shift; + if b & 0x80 == 0 { + return Some(v); + } + shift += 7; + if shift > 63 { + return None; + } + } + } + while i < buf.len() { + let tag = varint(buf, &mut i)?; + let (field, wt) = ((tag >> 3) as u32, tag & 7); + if field == 0 { + return None; + } + match wt { + 0 => out.push((field, Wire::Varint(varint(buf, &mut i)?))), + 2 => { + let len = varint(buf, &mut i)? as usize; + if i + len > buf.len() { + return None; + } + out.push((field, Wire::Bytes(buf[i..i + len].to_vec()))); + i += len; + } + 5 => { + if i + 4 > buf.len() { + return None; + } + i += 4; + out.push((field, Wire::Fixed)); + } + 1 => { + if i + 8 > buf.len() { + return None; + } + i += 8; + out.push((field, Wire::Fixed)); + } + _ => return None, + } + } + Some(out) +} + +pub(super) fn field_bytes<'a>(fields: &'a [(u32, Wire)], no: u32) -> Option<&'a [u8]> { + fields.iter().find_map(|(f, w)| match w { + Wire::Bytes(b) if *f == no => Some(b.as_slice()), + _ => None, + }) +} + +pub(super) fn field_msg(fields: &[(u32, Wire)], no: u32) -> Option> { + wire_fields(field_bytes(fields, no)?) +} + +pub(super) fn field_str(fields: &[(u32, Wire)], no: u32) -> Option { + let b = field_bytes(fields, no)?; + let s = std::str::from_utf8(b).ok()?; + Some(s.to_string()) +} + +pub(super) fn field_varint(fields: &[(u32, Wire)], no: u32) -> Option { + fields.iter().find_map(|(f, w)| match w { + Wire::Varint(v) if *f == no => Some(*v), + _ => None, + }) +} + +/// `{#1 seconds, #2 nanos}` timestamp message → RFC3339 (ms precision). +pub(super) fn ts_of(fields: &[(u32, Wire)], no: u32) -> Option { + let m = field_msg(fields, no)?; + let secs = field_varint(&m, 1)? as i64; + let nanos = field_varint(&m, 2).unwrap_or(0) as u32; + let dt = chrono::DateTime::from_timestamp(secs, nanos)?; + Some(dt.to_rfc3339_opts(chrono::SecondsFormat::Millis, true)) +} + +pub(super) fn ts_ms_of(fields: &[(u32, Wire)], no: u32) -> Option { + let m = field_msg(fields, no)?; + let secs = field_varint(&m, 1)? as f64; + let nanos = field_varint(&m, 2).unwrap_or(0) as f64; + Some(secs * 1000.0 + (nanos / 1_000_000.0).floor()) +} diff --git a/src-tauri/src/codex.rs b/src-tauri/src/codex.rs deleted file mode 100644 index 0ab17c1..0000000 --- a/src-tauri/src/codex.rs +++ /dev/null @@ -1,1624 +0,0 @@ -// Codex CLI session support — reads OpenAI Codex's on-disk rollout logs -// (`~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl`) and normalizes them into the SAME -// session/message shape the renderer consumes for Claude Code history, so the 对话 view -// (list / detail / search / live-follow / export) browses both without renderer forks. -// -// A rollout line is `{timestamp, type, payload}` with type ∈ {session_meta, turn_context, -// response_item, event_msg, compacted}. Conversation content lives in response_item payloads -// (message / reasoning / function_call / function_call_output / local_shell_call / -// custom_tool_call / web_search_call); event_msg mostly duplicates that content, but token_count -// carries usage and user_message supplies a bounded title fallback for image-heavy first turns. -// Very old Codex builds wrote -// payload objects directly per line (no envelope) — handled by treating such a line as its -// own payload. -// -// Tool calls are mapped onto the tool vocabulary the renderer already draws natively: -// shell/exec_command/local_shell_call → Bash, update_plan → TodoWrite, view_image → Read, -// web_search → WebSearch, apply_patch → ApplyPatch (a codex-specific card). -// -// Title/tags/soft-delete: Codex files belong to another tool, so per-conversation -// customization never rewrites them (unlike Claude's in-file `__ccbud__`) — it lives in a -// sidecar map at `~/.ccbud/codex-meta.json`, keyed by the rollout file stem. - -#![allow(dead_code)] - -use crate::history::{image_block, Norm}; -use rusqlite::{Connection, OpenFlags, OptionalExtension}; -use serde_json::{json, Value}; -use std::fs; -use std::io::{BufRead, BufReader}; -use std::path::{Path, PathBuf}; - -/// The DEFAULT config dir as a history-dir entry string (`~/.codex`), used by the one-time -/// startup migration that adds it to `historyDirs`. Honors CODEX_HOME like the codex CLI. -pub fn codex_label() -> String { - let root = sessions_root(); - let dir = root.parent().unwrap_or(&root); - crate::store::collapse_home(&dir.to_string_lossy()) -} - -fn home() -> PathBuf { - std::env::var("HOME").map(PathBuf::from).unwrap_or_else(|_| PathBuf::from(".")) -} - -/// Codex's DEFAULT sessions tree. Honors CODEX_HOME the way the codex CLI does. Only the -/// auto-add migration keys off this — browsing walks `/sessions` of every configured dir. -pub fn sessions_root() -> PathBuf { - match std::env::var("CODEX_HOME") { - Ok(h) if !h.trim().is_empty() => PathBuf::from(h).join("sessions"), - _ => home().join(".codex").join("sessions"), - } -} - -pub fn root_exists() -> bool { - sessions_root().is_dir() -} - -fn codex_home_for_rollout(file: &Path) -> Option { - file.ancestors() - .find(|dir| { - matches!( - dir.file_name().and_then(|name| name.to_str()), - Some("sessions") | Some("archived_sessions") - ) - }) - .and_then(Path::parent) - .map(Path::to_path_buf) -} - -fn resolve_sqlite_home_path(raw: &str, codex_home: &Path) -> Option { - let raw = raw.trim(); - if raw.is_empty() { - return None; - } - if raw == "~" { - return Some(home()); - } - if let Some(rest) = raw.strip_prefix("~/") { - return Some(home().join(rest)); - } - let path = PathBuf::from(raw); - Some(if path.is_absolute() { path } else { codex_home.join(path) }) -} - -fn configured_sqlite_home(codex_home: &Path) -> Option { - let raw = fs::read_to_string(codex_home.join("config.toml")).ok()?; - let doc = raw.parse::().ok()?; - resolve_sqlite_home_path(doc.get("sqlite_home")?.as_str()?, codex_home) -} - -/// Codex treats the completed state DB's rollout_path as authoritative for a canonical thread id. -/// This is intentionally queried only when ccbud has found duplicate physical candidates, so the -/// normal list walk never opens SQLite per row. A missing/stale/incomplete DB simply means callers -/// fall back to validated metadata + mtime, just as Codex does during scan-and-repair. -pub fn preferred_rollout_path(file: &Path, thread_id: &str) -> Option { - let codex_home = codex_home_for_rollout(file)?; - let sqlite_home = configured_sqlite_home(&codex_home) - .or_else(|| { - std::env::var("CODEX_SQLITE_HOME") - .ok() - .and_then(|value| resolve_sqlite_home_path(&value, &codex_home)) - }) - .unwrap_or(codex_home); - let db = sqlite_home.join("state_5.sqlite"); - if !db.is_file() { - return None; - } - let conn = Connection::open_with_flags( - db, - OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX, - ) - .ok()?; - let status = conn - .query_row( - "SELECT status FROM backfill_state WHERE id = 1", - [], - |row| row.get::<_, String>(0), - ) - .optional() - .ok()??; - if status != "complete" { - return None; - } - let rollout = conn - .query_row( - "SELECT rollout_path FROM threads WHERE id = ?1 AND archived = 0", - [thread_id], - |row| row.get::<_, String>(0), - ) - .optional() - .ok()??; - let rollout = PathBuf::from(rollout); - rollout.is_file().then_some(rollout) -} - -/// Walk every rollout .jsonl under a sessions tree (date-sharded YYYY/MM/DD, but walked -/// generically so a layout change doesn't lose sessions). Depth-capped against cycles. -pub fn walk_sessions(root: &Path, mut cb: F) { - fn walk(dir: &Path, depth: u32, cb: &mut F) { - if depth > 6 { - return; - } - let entries = match fs::read_dir(dir) { - Ok(e) => e, - Err(_) => return, - }; - for ent in entries.flatten() { - let p = ent.path(); - if p.is_dir() { - walk(&p, depth + 1, cb); - } else if p.is_file() && p.extension().and_then(|e| e.to_str()) == Some("jsonl") { - cb(p); - } - } - } - walk(root, 0, &mut cb); -} - -/// Format sniff on parsed records — routes files that LOOK like Codex rollouts (incl. copies -/// imported into the app store, where the path no longer says so). Claude Code records never -/// use these type tags, and old-format bare Codex items lack Claude's `.message` wrapper. -pub fn looks_codex(recs: &[Value]) -> bool { - recs.iter().take(8).any(|r| { - match r.get("type").and_then(|v| v.as_str()) { - Some("session_meta") | Some("turn_context") | Some("event_msg") | Some("compacted") => true, - Some("response_item") => r.get("payload").is_some(), - // old envelope-less rollout: response items at the top level - Some("message") | Some("function_call") | Some("function_call_output") - | Some("reasoning") | Some("local_shell_call") => r.get("message").is_none(), - _ => r.get("record_type").is_some(), - } - }) -} - -/// (type, payload, timestamp) of a rollout line, tolerating the old envelope-less format. -fn split_line(rec: &Value) -> (&str, &Value, Option<&str>) { - let ts = rec.get("timestamp").and_then(|v| v.as_str()); - let t = rec.get("type").and_then(|v| v.as_str()).unwrap_or(""); - if let Some(p) = rec.get("payload") { - return (t, p, ts); - } - match t { - "message" | "function_call" | "function_call_output" | "reasoning" | "local_shell_call" - | "custom_tool_call" | "custom_tool_call_output" | "web_search_call" => ("response_item", rec, ts), - // old first line: bare SessionMeta {id, timestamp, instructions, cwd?, git?} - "" if rec.get("id").is_some() && rec.get("timestamp").is_some() => ("session_meta", rec, ts), - _ => (t, rec, ts), - } -} - -#[derive(Default)] -struct CanonicalThreadMeta { - thread_id: Option, - root_session_id: Option, - parent_thread_id: Option, - forked_from_id: Option, - is_subagent: bool, - agent_path: Option, - agent_nickname: Option, - agent_role: Option, - agent_depth: Option, -} - -// The first SessionMeta is canonical for the physical rollout. Subagent/fork rollouts can copy -// ancestor SessionMeta records behind it, and every thread in that tree intentionally shares the -// same session_id. The unique thread key is the first meta's id. -fn canonical_thread_meta(payload: &Value) -> CanonicalThreadMeta { - let subagent = payload - .get("source") - .and_then(|source| source.get("subagent").or_else(|| source.get("sub_agent"))) - .or_else(|| { - payload - .get("thread_source") - .and_then(|source| source.get("subagent").or_else(|| source.get("sub_agent"))) - }); - let detail = subagent.and_then(|source| { - ["thread_spawn", "review", "compact", "other"] - .iter() - .find_map(|key| source.get(*key).filter(|value| value.is_object())) - .or_else(|| source.as_object().and_then(|object| object.values().find(|value| value.is_object()))) - }); - let string = |value: Option<&Value>| { - value - .and_then(Value::as_str) - .filter(|value| !value.trim().is_empty()) - .map(str::to_string) - }; - let thread_id = string( - payload - .get("id") - .or_else(|| payload.get("thread_id")), - ); - let root_session_id = string(payload.get("session_id")).or_else(|| thread_id.clone()); - let parent_thread_id = string( - payload - .get("parent_thread_id") - .or_else(|| detail.and_then(|value| value.get("parent_thread_id"))), - ); - let is_subagent = subagent.is_some() - || payload.get("thread_source").and_then(Value::as_str) == Some("subagent") - || payload - .get("agent_path") - .and_then(Value::as_str) - .is_some_and(|value| !value.is_empty()) - || payload - .get("agent_nickname") - .and_then(Value::as_str) - .is_some_and(|value| !value.is_empty()) - || (parent_thread_id.is_some() && thread_id != root_session_id); - CanonicalThreadMeta { - thread_id, - root_session_id, - parent_thread_id, - forked_from_id: string(payload.get("forked_from_id")), - is_subagent, - // Current Codex stores the canonical Agent identity on SessionMeta itself. Older - // rollouts only carried it inside source.subagent., so keep that as a fallback. - agent_path: string( - payload - .get("agent_path") - .or_else(|| detail.and_then(|value| value.get("agent_path"))), - ), - agent_nickname: string( - payload - .get("agent_nickname") - .or_else(|| detail.and_then(|value| value.get("agent_nickname"))), - ), - agent_role: string( - payload - .get("agent_role") - .or_else(|| payload.get("agent_type")) - .or_else(|| detail.and_then(|value| value.get("agent_role"))) - .or_else(|| detail.and_then(|value| value.get("agent_type"))), - ), - agent_depth: detail.and_then(|value| value.get("depth")).and_then(|value| value.as_i64()), - } -} - -/// Harness-injected user turns (environment/permissions/instructions wrappers) that aren't -/// human prose — hidden from the timeline, exactly like Claude's isMeta records. -fn is_meta_user_text(t: &str) -> bool { - let t = t.trim_start(); - ["", "", " bool { - let source = t.trim_start(); - let Some(heading) = source.strip_prefix('#') else { return false; }; - let heading = heading.trim_start().to_ascii_lowercase(); - heading.starts_with("agents.md instructions for ") - && heading.contains("") -} - -// Codex serializes a loaded Skill as a synthetic user turn. Keep the snapshot embedded in the -// rollout rather than reading the current SKILL.md from disk: historical sessions must show the -// exact instructions that were loaded at the time. Anchoring the whole envelope leaves quoted -// markup in normal user prose untouched. -fn skill_load_block(t: &str) -> Option { - static SKILL_ENVELOPE_RE: std::sync::OnceLock = std::sync::OnceLock::new(); - let re = SKILL_ENVELOPE_RE.get_or_init(|| { - regex::Regex::new( - r"(?is)^\s*\s*(.*?)\s*(.*?)(.*)\s*$", - ) - .unwrap() - }); - let captures = re.captures(t)?; - let name = captures.get(1)?.as_str().trim(); - let path = captures.get(2)?.as_str().trim(); - if name.is_empty() || path.is_empty() { - return None; - } - let snapshot = captures.get(3)?.as_str(); - Some(json!({ - "type": "skill_load", - "name": name, - "path": path, - "snapshot": snapshot, - })) -} - -fn joined_text(content: &Value, kinds: &[&str]) -> String { - let arr = match content.as_array() { - Some(a) => a, - None => return content.as_str().unwrap_or("").to_string(), - }; - arr.iter() - .filter(|b| kinds.contains(&b.get("type").and_then(|t| t.as_str()).unwrap_or(""))) - .filter_map(|b| b.get("text").and_then(|t| t.as_str())) - .collect::>() - .join("\n") -} - -// Codex surrounds each real input_image with text-only transport tags. Replace the opening tag -// with its safe display name (`[Image #1]`) and drop the closing tag; image_block still carries -// the actual bitmap to the renderer. -fn image_transport_label(text: &str) -> Option { - let source = text.trim(); - let lower = source.to_ascii_lowercase(); - if !lower.starts_with("') { - return None; - } - let boundary = source.as_bytes().get(6).copied(); - if !matches!(boundary, Some(b'>')) && !boundary.map(|b| b.is_ascii_whitespace()).unwrap_or(false) { - return None; - } - - let mut label = None; - if let Some(pos) = lower.find("name") { - let rest = source[pos + 4..].trim_start(); - if let Some(value) = rest.strip_prefix('=') { - let value = value.trim_start(); - label = if let Some(quote) = value.chars().next().filter(|c| *c == '"' || *c == '\'') { - value[quote.len_utf8()..] - .find(quote) - .map(|end| value[quote.len_utf8()..quote.len_utf8() + end].to_string()) - } else if value.starts_with('[') { - value.find(']').map(|end| value[..=end].to_string()) - } else { - Some(value.split(|c: char| c.is_whitespace() || c == '>').next().unwrap_or("").to_string()) - }; - } - } - Some(label.filter(|s| !s.trim().is_empty()).unwrap_or_else(|| "[Image]".to_string())) -} - -fn joined_user_text(content: &Value) -> String { - let arr = match content.as_array() { - Some(a) => a, - None => return content.as_str().unwrap_or("").to_string(), - }; - let has_image = arr - .iter() - .any(|b| b.get("type").and_then(|t| t.as_str()) == Some("input_image")); - arr.iter() - .filter(|b| matches!(b.get("type").and_then(|t| t.as_str()), Some("input_text") | Some("text"))) - .filter_map(|b| { - let text = b.get("text").and_then(|t| t.as_str()).unwrap_or(""); - if has_image { - if text.trim().eq_ignore_ascii_case("") { - return None; - } - if let Some(label) = image_transport_label(text) { - return Some(label); - } - } - if text.is_empty() { None } else { Some(text.to_string()) } - }) - .collect::>() - .join("\n") -} - -fn event_user_display_text(payload: &Value) -> String { - let message = payload.get("message").and_then(|v| v.as_str()).unwrap_or("").trim(); - let image_count = payload.get("images").and_then(|v| v.as_array()).map(Vec::len).unwrap_or(0) - + payload.get("local_images").and_then(|v| v.as_array()).map(Vec::len).unwrap_or(0); - let labels = (1..=image_count) - .map(|i| format!("[Image #{}]", i)) - .collect::>() - .join(" "); - format!("{}{}{}", labels, if !labels.is_empty() && !message.is_empty() { " " } else { "" }, message) - .trim() - .to_string() -} - -fn event_user_title_from_record(rec: &Value) -> String { - let (ty, payload, _) = split_line(rec); - if ty != "event_msg" || payload.get("type").and_then(|v| v.as_str()) != Some("user_message") { - return String::new(); - } - let text = event_user_display_text(payload); - if text.is_empty() { - String::new() - } else { - crate::history::first_user_text(&[json!({ - "role": "user", - "content": [{ "type": "text", "text": text }], - })]) - } -} - -fn first_event_user_title(recs: &[Value]) -> String { - recs.iter() - .map(event_user_title_from_record) - .find(|title| !title.is_empty()) - .unwrap_or_default() -} - -fn append_scan_segment(line: &mut Vec, dropping: &mut bool, segment: &[u8], max_line: usize) { - if *dropping || segment.is_empty() { - return; - } - if line.len().saturating_add(segment.len()) > max_line { - line.clear(); - *dropping = true; - } else { - line.extend_from_slice(segment); - } -} - -fn event_user_title_from_line(line: &[u8]) -> String { - let line = line.strip_suffix(b"\r").unwrap_or(line); - serde_json::from_slice::(line) - .map(|rec| event_user_title_from_record(&rec)) - .unwrap_or_default() -} - -// List metadata normally parses only the first 128 KiB. If an image-first response_item is a -// larger single JSON line, stream past it and read the following compact user_message event. -fn scan_event_user_title(file: &Path) -> String { - const MAX_SCAN: usize = 64 * 1024 * 1024; - const MAX_LINE: usize = 256 * 1024; - let input = match fs::File::open(file) { - Ok(file) => file, - Err(_) => return String::new(), - }; - let mut reader = BufReader::new(input); - let mut line: Vec = vec![]; - let mut dropping = false; - let mut scanned = 0usize; - while scanned < MAX_SCAN { - let available = match reader.fill_buf() { - Ok(buf) if !buf.is_empty() => buf, - Ok(_) | Err(_) => break, - }; - let take = available.len().min(MAX_SCAN - scanned); - let mut start = 0usize; - for i in 0..take { - if available[i] == b'\n' { - append_scan_segment(&mut line, &mut dropping, &available[start..i], MAX_LINE); - let title = if dropping { String::new() } else { event_user_title_from_line(&line) }; - line.clear(); - dropping = false; - if !title.is_empty() { - return title; - } - start = i + 1; - } - } - append_scan_segment(&mut line, &mut dropping, &available[start..take], MAX_LINE); - reader.consume(take); - scanned = scanned.saturating_add(take); - } - if dropping { String::new() } else { event_user_title_from_line(&line) } -} - -/// argv → display command: unwrap the ["bash","-lc", script] convention, else shell-ish join. -fn join_argv(cmd: &Value) -> String { - if let Some(s) = cmd.as_str() { - return s.to_string(); - } - let arr = match cmd.as_array() { - Some(a) => a, - None => return String::new(), - }; - let parts: Vec = arr.iter().map(|x| x.as_str().unwrap_or_default().to_string()).collect(); - if parts.len() == 3 - && ["bash", "sh", "zsh", "dash"].contains(&parts[0].as_str()) - && ["-lc", "-c"].contains(&parts[1].as_str()) - { - return parts[2].clone(); - } - parts - .iter() - .map(|p| { - if p.is_empty() || p.chars().any(|c| c.is_whitespace() || c == '"' || c == '\'') { - format!("{:?}", p) // debug-quote args with spaces/quotes - } else { - p.clone() - } - }) - .collect::>() - .join(" ") -} - -/// Codex tool name + parsed arguments → (renderer tool name, renderer input). -fn map_tool(name: &str, args: &Value) -> (String, Value) { - let s = |k: &str| args.get(k).and_then(|v| v.as_str()).unwrap_or("").to_string(); - match name { - "shell" | "local_shell" | "container.exec" => { - let mut input = json!({ "command": join_argv(args.get("command").unwrap_or(&Value::Null)) }); - let desc = if !s("justification").is_empty() { s("justification") } else { s("workdir") }; - if !desc.is_empty() { - input["description"] = json!(desc); - } - ("Bash".into(), input) - } - "shell_command" => ("Bash".into(), json!({ "command": s("command") })), - "exec_command" => { - let cmd = if !s("cmd").is_empty() { s("cmd") } else { s("command") }; - ("Bash".into(), json!({ "command": cmd })) - } - "apply_patch" => { - let patch = if !s("input").is_empty() { s("input") } else { s("patch") }; - ("ApplyPatch".into(), json!({ "patch": patch })) - } - "update_plan" => { - let todos: Vec = args - .get("plan") - .and_then(|p| p.as_array()) - .map(|a| { - a.iter() - .map(|st| { - json!({ - "content": st.get("step").and_then(|v| v.as_str()).unwrap_or(""), - "status": st.get("status").and_then(|v| v.as_str()).unwrap_or("pending"), - }) - }) - .collect() - }) - .unwrap_or_default(); - ("TodoWrite".into(), json!({ "todos": todos })) - } - "view_image" => ("Read".into(), json!({ "file_path": s("path") })), - "web_search" => ("WebSearch".into(), json!({ "query": s("query") })), - _ => ( - name.to_string(), - if args.is_object() { args.clone() } else { json!({}) }, - ), - } -} - -// ---- code-mode `exec` scripts (custom_tool_call name "exec") ---- -// -// Codex code-mode (gpt-*-sol) emits one custom tool named `exec` whose input is JavaScript -// calling `tools.*` (exec_command / write_stdin / …). The dominant shape by far is a single -// `tools.exec_command({cmd, workdir, …})` plus print plumbing (`text(r.output);` and friends) — -// semantically just a shell run, so it renders as the familiar Bash card (command + workdir). -// Anything else (write_stdin, Promise.all batches, real orchestration code) keeps the whole -// script as a `Script` card the renderer shows as highlighted JavaScript. Extraction is -// conservative: any parse doubt falls back to the Script card, never to a wrong command. - -/// First `{…}` object literal at/after `from`, brace-matched with double-quoted strings (and -/// their escapes) treated as opaque — shell commands are full of braces and quotes. -fn extract_object(s: &str, from: usize) -> Option<(usize, usize)> { - let start = from + s[from..].find('{')?; - let (mut depth, mut in_str, mut esc) = (0i32, false, false); - for (i, &b) in s.as_bytes().iter().enumerate().skip(start) { - if in_str { - if esc { - esc = false; - } else if b == b'\\' { - esc = true; - } else if b == b'"' { - in_str = false; - } - continue; - } - match b { - b'"' => in_str = true, - b'{' => depth += 1, - b'}' => { - depth -= 1; - if depth == 0 { - return Some((start, i)); - } - } - _ => {} - } - } - None -} - -/// Quote bare JS object keys (`{cmd: …}` → `{"cmd": …}`) outside string context so serde can -/// parse code-mode's object-literal arguments; double-quoted string contents pass verbatim. -fn quote_js_keys(s: &str) -> String { - let chars: Vec = s.chars().collect(); - let mut out = String::with_capacity(s.len() + 16); - let (mut in_str, mut esc) = (false, false); - let mut i = 0; - while i < chars.len() { - let c = chars[i]; - if in_str { - if esc { - esc = false; - } else if c == '\\' { - esc = true; - } else if c == '"' { - in_str = false; - } - out.push(c); - i += 1; - continue; - } - if c == '"' { - in_str = true; - out.push(c); - i += 1; - continue; - } - if c == '{' || c == ',' { - out.push(c); - i += 1; - while i < chars.len() && chars[i].is_whitespace() { - out.push(chars[i]); - i += 1; - } - let start = i; - while i < chars.len() && (chars[i].is_ascii_alphanumeric() || chars[i] == '_' || chars[i] == '$') { - i += 1; - } - if i > start { - let mut j = i; - while j < chars.len() && chars[j].is_whitespace() { - j += 1; - } - let ident: String = chars[start..i].iter().collect(); - if j < chars.len() && chars[j] == ':' { - out.push('"'); - out.push_str(&ident); - out.push('"'); - } else { - out.push_str(&ident); - } - } - continue; - } - out.push(c); - i += 1; - } - out -} - -/// The `{…}` argument of a tools.* call: strict JSON first (code-mode usually emits JSON), -/// then a bare-key-quoted retry for JS object literals. -fn parse_call_args(obj: &str) -> Option { - serde_json::from_str::(obj) - .ok() - .or_else(|| serde_json::from_str::("e_js_keys(obj)).ok()) - .filter(|v| v.is_object()) -} - -/// Code-mode exec script → renderer tool card (see module comment above). -fn map_exec_script(script: &str) -> (String, Value) { - let fallback = || ("Script".to_string(), json!({ "code": script })); - // exactly one tools.* call, and it must be exec_command (a cmd string that itself mentions - // "tools." trips the count — conservative fallback, never a wrong command) - if script.matches("tools.").count() != 1 { - return fallback(); - } - let call = match script.find("tools.exec_command(") { - Some(i) => i, - None => return fallback(), - }; - // prefix must be assignment/await plumbing only: `const r = await` / `let out = await` / `await` - let prefix: Vec<&str> = script[..call].split_whitespace().collect(); - let prefix_ok = match prefix.as_slice() { - [] | ["await"] => true, - [kw, _name, "=", "await"] => matches!(*kw, "const" | "let" | "var"), - _ => false, - }; - if !prefix_ok { - return fallback(); - } - let after = call + "tools.exec_command(".len(); - let (ostart, oend) = match extract_object(script, after) { - Some(span) => span, - None => return fallback(), - }; - if !script[after..ostart].trim().is_empty() { - return fallback(); - } - let args = match parse_call_args(&script[ostart..=oend]) { - Some(a) => a, - None => return fallback(), - }; - let cmd = args - .get("cmd") - .or_else(|| args.get("command")) - .and_then(|v| v.as_str()) - .unwrap_or(""); - if cmd.is_empty() { - return fallback(); - } - // tail must close the call, then carry only print plumbing - let rest = script[oend + 1..].trim_start(); - let rest = match rest.strip_prefix(')') { - Some(r) => r, - None => return fallback(), - }; - let rest = rest.strip_prefix(';').unwrap_or(rest); - let plumbing = rest.lines().all(|l| { - let l = l.trim(); - l.is_empty() || l.starts_with("text(") || l.starts_with("if (") || l.starts_with("//") - }); - if !plumbing { - return fallback(); - } - let mut input = json!({ "command": cmd }); - if let Some(wd) = args.get("workdir").and_then(|v| v.as_str()) { - if !wd.is_empty() { - input["description"] = json!(wd); - } - } - ("Bash".into(), input) -} - -/// Error heuristic for code-mode exec output text: the runner's own status header -/// ("Script failed…" / "Exit code: N…"). -fn exec_text_err(text: &str) -> bool { - if text.starts_with("Script failed") { - return true; - } - if let Some(rest) = text.strip_prefix("Exit code: ") { - let digits: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect(); - return digits.parse::().map(|c| c != 0).unwrap_or(false); - } - false -} - -/// Tool output payload → (display text, is_error). Unwraps codex's JSON-wrapped shell output -/// ({"output","metadata":{exit_code}}) and reads exec_command's "exited with code N" header. -fn shape_output(out: &Value) -> (String, bool) { - // structured payload: { content, success? } - if out.is_object() { - let text = out - .get("content") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()) - .unwrap_or_else(|| serde_json::to_string_pretty(out).unwrap_or_default()); - let err = out.get("success").and_then(|v| v.as_bool()) == Some(false); - return (text, err); - } - let s = out.as_str().unwrap_or("").to_string(); - if let Ok(v) = serde_json::from_str::(&s) { - if v.is_object() { - if let Some(o) = v.get("output").and_then(|x| x.as_str()) { - let code = v - .get("metadata") - .and_then(|m| m.get("exit_code")) - .and_then(|c| c.as_i64()) - .unwrap_or(0); - return (o.to_string(), code != 0); - } - if let Some(c) = v.get("content").and_then(|x| x.as_str()) { - let err = v.get("success").and_then(|x| x.as_bool()) == Some(false); - return (c.to_string(), err); - } - } - } - // code-mode runner header (older builds wrote it as a plain string): "Exit code: N…" / - // "Script failed…" - if exec_text_err(&s) { - return (s, true); - } - // exec_command header: "…\nProcess exited with code N\n…" near the top - let head: String = s.chars().take(240).collect(); - if let Some(pos) = head.find("exited with code ") { - let digits: String = head[pos + "exited with code ".len()..] - .chars() - .take_while(|c| c.is_ascii_digit()) - .collect(); - if let Ok(code) = digits.parse::() { - return (s, code != 0); - } - } - (s, false) -} - -/// Normalize parsed rollout records into the renderer's message model. -pub fn normalize(recs: &[Value]) -> Norm { - let mut messages: Vec = vec![]; - let (mut tin, mut tout, mut tcr, mut turns) = (0i64, 0i64, 0i64, 0i64); - let mut model: Option = None; - let mut cwd: Option = None; - let mut session_id: Option = None; - let mut thread_id: Option = None; - let mut parent_thread_id: Option = None; - let mut forked_from_id: Option = None; - let mut is_subagent = false; - let mut agent_path: Option = None; - let mut agent_nickname: Option = None; - let mut agent_role: Option = None; - let mut agent_depth: Option = None; - let mut saw_session_meta = false; - let mut git_branch: Option = None; - let mut version: Option = None; - - for rec in recs { - let (ty, p, ts) = split_line(rec); - let with_ts = |mut m: Value| { - if let Some(t) = ts { - m["ts"] = json!(t); - } - m - }; - match ty { - "session_meta" => { - if !saw_session_meta { - saw_session_meta = true; - let identity = canonical_thread_meta(p); - thread_id = identity.thread_id; - session_id = identity.root_session_id; - parent_thread_id = identity.parent_thread_id; - forked_from_id = identity.forked_from_id; - is_subagent = identity.is_subagent; - agent_path = identity.agent_path; - agent_nickname = identity.agent_nickname; - agent_role = identity.agent_role; - agent_depth = identity.agent_depth; - } - let sid = p - .get("session_id") - .or_else(|| p.get("id")) - .and_then(|v| v.as_str()); - if session_id.is_none() { - session_id = sid.map(|s| s.to_string()); - } - if cwd.is_none() { - cwd = p.get("cwd").and_then(|v| v.as_str()).map(|s| s.to_string()); - } - if version.is_none() { - version = p.get("cli_version").and_then(|v| v.as_str()).map(|s| s.to_string()); - } - if git_branch.is_none() { - git_branch = p - .get("git") - .and_then(|g| g.get("branch")) - .and_then(|v| v.as_str()) - .map(|s| s.to_string()); - } - } - "turn_context" => { - if let Some(m) = p.get("model").and_then(|v| v.as_str()) { - model = Some(m.to_string()); - } - if cwd.is_none() { - cwd = p.get("cwd").and_then(|v| v.as_str()).map(|s| s.to_string()); - } - } - "compacted" => { - let text = p.get("message").and_then(|v| v.as_str()).unwrap_or("").trim().to_string(); - if !text.is_empty() { - messages.push(with_ts(json!({ "role": "user", "content": [{ "type": "text", "text": text }] }))); - } - } - "event_msg" => match p.get("type").and_then(|v| v.as_str()).unwrap_or("") { - "token_count" => { - let u = p.get("info").and_then(|i| i.get("last_token_usage")); - if let Some(u) = u { - let input = u.get("input_tokens").and_then(|v| v.as_i64()).unwrap_or(0); - let cached = u.get("cached_input_tokens").and_then(|v| v.as_i64()).unwrap_or(0); - let output = u.get("output_tokens").and_then(|v| v.as_i64()).unwrap_or(0); - if input + cached + output > 0 { - let usage = json!({ - "inputTokens": (input - cached).max(0), - "outputTokens": output, - "cacheRead": cached, - "cacheCreation": 0, - }); - tin += (input - cached).max(0); - tout += output; - tcr += cached; - turns += 1; - // Per-turn usage rides the turn's last assistant message (codex emits - // one token_count per model turn). - if let Some(m) = messages - .iter_mut() - .rev() - .find(|m| m.get("role").and_then(|r| r.as_str()) == Some("assistant") && m.get("usage").is_none()) - { - m["usage"] = usage; - } - } - } - } - "turn_aborted" => { - messages.push(with_ts(json!({ - "role": "user", - "content": [{ "type": "text", "text": "[Request interrupted by user]" }], - }))); - } - _ => {} - }, - "response_item" => { - let it = p.get("type").and_then(|v| v.as_str()).unwrap_or(""); - match it { - "message" => { - let role = p.get("role").and_then(|v| v.as_str()).unwrap_or(""); - let content = p.get("content").cloned().unwrap_or(Value::Null); - if role == "assistant" { - let text = joined_text(&content, &["output_text", "text"]); - if !text.trim().is_empty() { - let mut m = json!({ "role": "assistant", "content": [{ "type": "text", "text": text }] }); - if let Some(md) = &model { - m["modelActual"] = json!(md); - } - messages.push(with_ts(m)); - } - } else if role == "user" { - let text = joined_user_text(&content); - if let Some(skill) = skill_load_block(&text) { - messages.push(with_ts(json!({ - "role": "user", - "_meta": true, - "content": [skill], - }))); - continue; - } - if is_meta_user_text(&text) { - continue; - } - let mut blocks: Vec = vec![]; - if !text.trim().is_empty() { - blocks.push(json!({ "type": "text", "text": text })); - } - if let Some(arr) = content.as_array() { - for b in arr { - if b.get("type").and_then(|t| t.as_str()) == Some("input_image") { - if let Some(img) = b - .get("image_url") - .and_then(|u| u.as_str()) - .and_then(image_block) - { - blocks.push(img); - } - } - } - } - if !blocks.is_empty() { - let mut message = json!({ "role": "user", "content": blocks }); - if is_agents_bootstrap(&text) { - message["_meta"] = json!(true); - } - messages.push(with_ts(message)); - } - } // system / developer turns: harness plumbing, not conversation - } - "reasoning" => { - let mut txt = joined_text(&p.get("summary").cloned().unwrap_or(Value::Null), &["summary_text", "text"]); - let extra = joined_text(&p.get("content").cloned().unwrap_or(Value::Null), &["reasoning_text", "text"]); - if !extra.trim().is_empty() { - if !txt.trim().is_empty() { - txt.push_str("\n\n"); - } - txt.push_str(&extra); - } - if !txt.trim().is_empty() { - let mut m = json!({ "role": "assistant", "content": [{ "type": "thinking", "thinking": txt }] }); - if let Some(md) = &model { - m["modelActual"] = json!(md); - } - messages.push(with_ts(m)); - } - } - "function_call" => { - let name = p.get("name").and_then(|v| v.as_str()).unwrap_or("tool"); - let args: Value = p - .get("arguments") - .and_then(|v| v.as_str()) - .and_then(|s| serde_json::from_str(s).ok()) - .unwrap_or_else(|| p.get("arguments").cloned().unwrap_or(json!({}))); - let (tname, input) = map_tool(name, &args); - let id = p - .get("call_id") - .or_else(|| p.get("id")) - .and_then(|v| v.as_str()) - .unwrap_or(""); - let mut m = json!({ - "role": "assistant", - "content": [{ "type": "tool_use", "id": id, "name": tname, "input": input }], - }); - if let Some(md) = &model { - m["modelActual"] = json!(md); - } - messages.push(with_ts(m)); - } - "local_shell_call" => { - let cmd = p - .get("action") - .and_then(|a| a.get("command")) - .cloned() - .unwrap_or(Value::Null); - let id = p - .get("call_id") - .or_else(|| p.get("id")) - .and_then(|v| v.as_str()) - .unwrap_or(""); - let mut m = json!({ - "role": "assistant", - "content": [{ "type": "tool_use", "id": id, "name": "Bash", "input": { "command": join_argv(&cmd) } }], - }); - if let Some(md) = &model { - m["modelActual"] = json!(md); - } - messages.push(with_ts(m)); - } - "custom_tool_call" => { - let name = p.get("name").and_then(|v| v.as_str()).unwrap_or("tool"); - let input_s = p.get("input").and_then(|v| v.as_str()).unwrap_or(""); - let (tname, input) = if name == "apply_patch" { - ("ApplyPatch".to_string(), json!({ "patch": input_s })) - } else if name == "exec" { - map_exec_script(input_s) - } else { - (name.to_string(), json!({ "input": input_s })) - }; - let id = p - .get("call_id") - .or_else(|| p.get("id")) - .and_then(|v| v.as_str()) - .unwrap_or(""); - let mut m = json!({ - "role": "assistant", - "content": [{ "type": "tool_use", "id": id, "name": tname, "input": input }], - }); - if let Some(md) = &model { - m["modelActual"] = json!(md); - } - messages.push(with_ts(m)); - } - "function_call_output" | "custom_tool_call_output" => { - let out = p.get("output").cloned().unwrap_or(Value::Null); - let id = p.get("call_id").and_then(|v| v.as_str()).unwrap_or(""); - // Newer code-mode outputs are block ARRAYS — {input_text} chunks (status - // header + stdout, concatenated verbatim) plus optional {input_image} - // screenshots, which become renderer image blocks. - let (content, err) = if let Some(arr) = out.as_array() { - let text: String = arr - .iter() - .filter(|b| { - matches!( - b.get("type").and_then(|t| t.as_str()), - Some("input_text") | Some("output_text") | Some("text") - ) - }) - .filter_map(|b| b.get("text").and_then(|t| t.as_str())) - .collect(); - let images: Vec = arr - .iter() - .filter(|b| b.get("type").and_then(|t| t.as_str()) == Some("input_image")) - .filter_map(|b| b.get("image_url").and_then(|u| u.as_str()).and_then(image_block)) - .collect(); - let err = exec_text_err(&text); - if images.is_empty() { - (json!(text), err) - } else { - let mut blocks = vec![json!({ "type": "text", "text": text })]; - blocks.extend(images); - (Value::Array(blocks), err) - } - } else { - let (text, err) = shape_output(&out); - (json!(text), err) - }; - let mut tr = json!({ "type": "tool_result", "tool_use_id": id, "content": content }); - if err { - tr["is_error"] = json!(true); - } - messages.push(with_ts(json!({ "role": "user", "content": [tr] }))); - } - "web_search_call" => { - let q = p - .get("action") - .and_then(|a| a.get("query")) - .and_then(|v| v.as_str()) - .unwrap_or(""); - let id = p - .get("id") - .or_else(|| p.get("call_id")) - .and_then(|v| v.as_str()) - .unwrap_or(""); - let mut m = json!({ - "role": "assistant", - "content": [{ "type": "tool_use", "id": id, "name": "WebSearch", "input": { "query": q } }], - }); - if let Some(md) = &model { - m["modelActual"] = json!(md); - } - messages.push(with_ts(m)); - } - _ => {} - } - } - _ => {} - } - } - - let first_ts = messages - .first() - .and_then(|m| m.get("ts")) - .and_then(|v| v.as_str()) - .map(|s| s.to_string()); - let last_ts = messages - .last() - .and_then(|m| m.get("ts")) - .and_then(|v| v.as_str()) - .map(|s| s.to_string()); - - Norm { - messages, - totals: json!({ "in": tin, "out": tout, "cacheRead": tcr, "cacheCreation": 0, "turns": turns }), - model, - first_ts, - last_ts, - cwd, - session_id, - thread_id, - parent_thread_id, - forked_from_id, - is_subagent, - agent_path, - agent_nickname, - agent_role, - agent_depth, - git_branch, - version, - } -} - -/// (cwd, canonical thread id) from a Codex head — used to name an imported store copy. -pub fn head_ids(recs: &[Value]) -> (Option, Option) { - for rec in recs { - let (ty, p, _) = split_line(rec); - if ty == "session_meta" { - let cwd = p.get("cwd").and_then(|v| v.as_str()).map(|s| s.to_string()); - let sid = p - // Every subagent in a tree shares session_id. The FIRST SessionMeta.id is the - // unique rollout key; using session_id makes sibling imports collide. - .get("id") - .or_else(|| p.get("thread_id")) - .and_then(|v| v.as_str()) - .map(|s| s.to_string()); - return (cwd, sid); - } - } - (None, None) -} - -// ---- sidecar customization (shared store, ~/.ccbud/codex-meta.json, keyed by rollout stem) ---- - -fn stem_of(file: &Path) -> String { - file.file_stem().and_then(|s| s.to_str()).unwrap_or("").to_string() -} - -/// (custom title, tags, deleted) for a codex session, from the sidecar. -fn sidecar_meta(file: &Path) -> (Option, Vec, bool) { - crate::sidecar::meta(&crate::sidecar::codex_file(), &stem_of(file)) -} - -pub fn is_deleted(file: &Path) -> bool { - sidecar_meta(file).2 -} - -/// set_ccbud-equivalent for codex sessions: same patch semantics ({title?, tags?, delete?}), -/// persisted to the sidecar instead of the rollout file (never mutate another tool's data). -pub fn set_meta(file: &str, patch: &Value) -> Value { - let stem = stem_of(Path::new(file)); - if stem.is_empty() { - return json!({ "ok": false, "reason": "empty" }); - } - crate::sidecar::set_meta(&crate::sidecar::codex_file(), &stem, patch) -} - -/// Drop a session's sidecar entry (after its rollout file is deleted forever). -pub fn remove_meta(file: &str) { - crate::sidecar::remove_meta(&crate::sidecar::codex_file(), &stem_of(Path::new(file))); -} - -// ---- list/detail shapes (codex flavors of history.rs session_meta / get_session) ---- - -fn subagent_title(n: &Norm) -> String { - if !n.is_subagent { - return String::new(); - } - let path = n - .agent_path - .as_deref() - .unwrap_or("") - .trim_start_matches('/') - .strip_prefix("root/") - .unwrap_or_else(|| n.agent_path.as_deref().unwrap_or("").trim_start_matches('/')); - let mut parts = Vec::new(); - if let Some(nickname) = n.agent_nickname.as_deref().filter(|value| !value.trim().is_empty()) { - parts.push(nickname.trim()); - } - if !path.is_empty() { - parts.push(path); - } - if parts.is_empty() { - "Codex subagent".to_string() - } else { - parts.join(" · ") - } -} - -fn is_canonical_thread_id(value: &str) -> bool { - let bytes = value.as_bytes(); - bytes.len() == 36 - && [8usize, 13, 18, 23].into_iter().all(|index| bytes[index] == b'-') - && bytes - .iter() - .enumerate() - .all(|(index, byte)| [8usize, 13, 18, 23].contains(&index) || byte.is_ascii_hexdigit()) -} - -/// List-row meta from already-parsed head records. `dir_id` is `__codex__` for the live tree -/// or `__imported__` for snapshots copied into the app store. -pub fn session_meta_from(file: &Path, recs: &[Value], dir_id: &str, dir_label: &str) -> Option { - let meta = fs::metadata(file).ok()?; - let n = normalize(recs); - // Live rollouts customize via the sidecar (never rewrite another tool's files); imported - // COPIES (marked by an .import.json) are our own files, where the standard in-file - // __ccbud__ (written by set_ccbud) applies. - let native = crate::history::read_import_meta(&file.to_string_lossy()).is_none(); - let (cc_title, cc_tags, cc_deleted) = if native { - sidecar_meta(file) - } else { - crate::history::read_ccbud(recs) - }; - let mut transcript_title = crate::history::first_user_text(&n.messages); - if transcript_title.is_empty() { - transcript_title = first_event_user_title(recs); - } - if transcript_title.is_empty() && meta.len() > 131072 { - transcript_title = scan_event_user_title(file); - } - let agent_title = subagent_title(&n); - let auto_title = if agent_title.is_empty() { transcript_title } else { agent_title }; - let stem = stem_of(file); - let mt = meta - .modified() - .ok() - .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) - .map(|d| d.as_millis() as f64) - .unwrap_or(0.0); - Some(json!({ - // Row ids are UI identities, so include the configured store. A live rollout and an - // imported snapshot can legitimately share the same filename/thread id. - "id": format!("codex:{}:{}", dir_id, stem), - "file": file.to_string_lossy(), - "source": "codex", - "dirId": dir_id, - "dirLabel": dir_label, - "sessionId": n.thread_id.clone().or_else(|| n.session_id.clone()).unwrap_or_else(|| stem.clone()), - "threadId": n.thread_id.clone().or_else(|| n.session_id.clone()).unwrap_or_else(|| stem.clone()), - "canonicalThreadIdValid": n.thread_id.as_deref().is_some_and(is_canonical_thread_id), - "rootSessionId": n.session_id.clone().or_else(|| n.thread_id.clone()).unwrap_or_else(|| stem.clone()), - "parentThreadId": n.parent_thread_id.clone(), - "forkedFromId": n.forked_from_id.clone(), - "cwd": n.cwd.clone(), - "project": n.cwd.as_deref().map(crate::history::base_name).unwrap_or_default(), - "gitBranch": n.git_branch.clone(), - "title": cc_title.clone().unwrap_or_else(|| auto_title.clone()), - "autoTitle": auto_title, - "tags": cc_tags, - "model": n.model, - "isSubagent": n.is_subagent, - "agentPath": n.agent_path.clone(), - "agentNickname": n.agent_nickname.clone(), - "agentRole": n.agent_role.clone(), - "agentDepth": n.agent_depth, - "imported": dir_id == "__imported__", - "deleted": cc_deleted, - "createdAt": crate::history::record_created_ms(recs, file), - "lastActivity": mt, - "sizeKB": (meta.len() as f64 / 1024.0).round() as i64, - })) -} - -/// Full-detail shape from already-parsed records (history.rs get_session routes here). -pub fn session_from_recs(file: &str, recs: &[Value]) -> Value { - let path = Path::new(file); - let n = normalize(recs); - let import_meta = crate::history::read_import_meta(file); - // Same sidecar-vs-in-file split as session_meta_from. - let (cc_title, cc_tags, cc_deleted) = if import_meta.is_none() { - sidecar_meta(path) - } else { - crate::history::read_ccbud(recs) - }; - let mut transcript_title = crate::history::first_user_text(&n.messages); - if transcript_title.is_empty() { - transcript_title = first_event_user_title(recs); - } - let agent_title = subagent_title(&n); - let auto_title = if agent_title.is_empty() { transcript_title } else { agent_title }; - let stem = stem_of(path); - json!({ - "meta": { - "id": format!("codex:{}", stem), - "file": file, - "source": "codex", - "assistant": "Codex", - "title": cc_title.clone().unwrap_or_else(|| auto_title.clone()), - "autoTitle": auto_title, - "tags": cc_tags, - "summary": Value::Null, - "sessionId": n.thread_id.clone().or_else(|| n.session_id.clone()).unwrap_or_else(|| stem.clone()), - "threadId": n.thread_id.clone().or_else(|| n.session_id.clone()).unwrap_or_else(|| stem.clone()), - "canonicalThreadIdValid": n.thread_id.as_deref().is_some_and(is_canonical_thread_id), - "rootSessionId": n.session_id.clone().or_else(|| n.thread_id.clone()).unwrap_or_else(|| stem.clone()), - "parentThreadId": n.parent_thread_id.clone(), - "forkedFromId": n.forked_from_id.clone(), - "cwd": n.cwd.clone(), - "project": n.cwd.as_deref().map(crate::history::base_name).unwrap_or_default(), - "gitBranch": n.git_branch.clone(), - "version": n.version.clone(), - "isSubagent": n.is_subagent, - "agentPath": n.agent_path.clone(), - "agentNickname": n.agent_nickname.clone(), - "agentRole": n.agent_role.clone(), - "agentDepth": n.agent_depth, - "deleted": cc_deleted, - "imported": import_meta.is_some(), - "importedFrom": import_meta.as_ref().and_then(|m| m.get("originalPath")).cloned().unwrap_or(Value::Null), - "importedAt": import_meta.as_ref().and_then(|m| m.get("importedAt")).cloned().unwrap_or(Value::Null), - "model": n.model, - "totals": n.totals, - "messages": n.messages.len(), - "subagentCount": 0, - "firstTs": n.first_ts, - "lastTs": n.last_ts, - }, - "messages": n.messages, - "subagents": {}, - }) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn line(ts: &str, ty: &str, payload: Value) -> String { - serde_json::to_string(&json!({ "timestamp": ts, "type": ty, "payload": payload })).unwrap() - } - - fn fixture() -> Vec { - let lines = vec![ - line("2026-07-04T07:13:08.965Z", "session_meta", json!({ - "session_id": "019f-abc", "id": "019f-abc", "timestamp": "2026-07-04T07:13:07.386Z", - "cwd": "/tmp/projx", "originator": "codex-tui", "cli_version": "0.142.5", - "git": { "branch": "main" } - })), - line("2026-07-04T07:13:08.967Z", "turn_context", json!({ "cwd": "/tmp/projx", "model": "gpt-5.5" })), - line("2026-07-04T07:13:08.967Z", "response_item", json!({ - "type": "message", "role": "user", - "content": [{ "type": "input_text", "text": "\n/tmp/projx\n" }] - })), - line("2026-07-04T07:13:08.969Z", "response_item", json!({ - "type": "message", "role": "user", "content": [{ "type": "input_text", "text": "fix the bug please" }] - })), - line("2026-07-04T07:13:09.100Z", "event_msg", json!({ "type": "user_message", "message": "fix the bug please" })), - line("2026-07-04T07:13:10.000Z", "response_item", json!({ - "type": "reasoning", "summary": [{ "type": "summary_text", "text": "Looking at the repo" }], "encrypted_content": "xxx" - })), - line("2026-07-04T07:13:11.000Z", "response_item", json!({ - "type": "function_call", "name": "exec_command", - "arguments": "{\"cmd\": \"ls -la\", \"yield_time_ms\": 10000}", "call_id": "call_1" - })), - line("2026-07-04T07:13:12.000Z", "response_item", json!({ - "type": "function_call_output", "call_id": "call_1", - "output": "Chunk ID: x\nWall time: 0.1 seconds\nProcess exited with code 0\nOutput:\n---\na.txt\nb.txt" - })), - line("2026-07-04T07:13:13.000Z", "response_item", json!({ - "type": "function_call", "name": "shell", - "arguments": "{\"command\": [\"bash\", \"-lc\", \"cargo test\"], \"workdir\": \"/tmp/projx\"}", "call_id": "call_2" - })), - line("2026-07-04T07:13:14.000Z", "response_item", json!({ - "type": "function_call_output", "call_id": "call_2", - "output": "{\"output\": \"error: it broke\", \"metadata\": {\"exit_code\": 101, \"duration_seconds\": 1.5}}" - })), - line("2026-07-04T07:13:15.000Z", "response_item", json!({ - "type": "function_call", "name": "update_plan", - "arguments": "{\"plan\": [{\"step\": \"read code\", \"status\": \"completed\"}, {\"step\": \"fix bug\", \"status\": \"in_progress\"}]}", - "call_id": "call_3" - })), - line("2026-07-04T07:13:16.000Z", "response_item", json!({ - "type": "custom_tool_call", "name": "apply_patch", "call_id": "call_4", - "input": "*** Begin Patch\n*** Update File: src/a.rs\n@@\n-old\n+new\n*** End Patch" - })), - line("2026-07-04T07:13:17.000Z", "response_item", json!({ - "type": "message", "role": "assistant", - "content": [{ "type": "output_text", "text": "Done — fixed." }], "phase": "final_answer" - })), - line("2026-07-04T07:13:17.500Z", "event_msg", json!({ - "type": "token_count", - "info": { - "total_token_usage": { "input_tokens": 900, "cached_input_tokens": 600, "output_tokens": 80, "total_tokens": 980 }, - "last_token_usage": { "input_tokens": 900, "cached_input_tokens": 600, "output_tokens": 80, "total_tokens": 980 }, - "model_context_window": 258400 - } - })), - ]; - lines - .iter() - .map(|l| serde_json::from_str::(l).unwrap()) - .collect() - } - - #[test] - fn normalizes_rollout_into_renderer_model() { - let recs = fixture(); - assert!(looks_codex(&recs)); - let n = normalize(&recs); - - assert_eq!(n.session_id.as_deref(), Some("019f-abc")); - assert_eq!(n.cwd.as_deref(), Some("/tmp/projx")); - assert_eq!(n.version.as_deref(), Some("0.142.5")); - assert_eq!(n.git_branch.as_deref(), Some("main")); - assert_eq!(n.model.as_deref(), Some("gpt-5.5")); - - // env-context user turn skipped; real prose, reasoning, 4 tool calls, 2 results, final text - let roles: Vec<&str> = n.messages.iter().map(|m| m["role"].as_str().unwrap()).collect(); - assert_eq!(roles, vec!["user", "assistant", "assistant", "user", "assistant", "user", "assistant", "assistant", "assistant"]); - - let title = crate::history::first_user_text(&n.messages); - assert_eq!(title, "fix the bug please"); - - // exec_command → Bash card with the raw command - let tu1 = &n.messages[2]["content"][0]; - assert_eq!(tu1["type"], "tool_use"); - assert_eq!(tu1["name"], "Bash"); - assert_eq!(tu1["input"]["command"], "ls -la"); - // its ok result pairs by call id and is not an error - let tr1 = &n.messages[3]["content"][0]; - assert_eq!(tr1["tool_use_id"], "call_1"); - assert!(tr1.get("is_error").is_none()); - - // shell argv ["bash","-lc","cargo test"] unwraps; exit_code 101 marks the result as error - let tu2 = &n.messages[4]["content"][0]; - assert_eq!(tu2["input"]["command"], "cargo test"); - let tr2 = &n.messages[5]["content"][0]; - assert_eq!(tr2["is_error"], true); - assert_eq!(tr2["content"], "error: it broke"); - - // update_plan → TodoWrite todos - let tu3 = &n.messages[6]["content"][0]; - assert_eq!(tu3["name"], "TodoWrite"); - assert_eq!(tu3["input"]["todos"][1]["status"], "in_progress"); - - // apply_patch custom tool → ApplyPatch {patch} - let tu4 = &n.messages[7]["content"][0]; - assert_eq!(tu4["name"], "ApplyPatch"); - assert!(tu4["input"]["patch"].as_str().unwrap().contains("*** Update File: src/a.rs")); - - // reasoning became a thinking block - assert_eq!(n.messages[1]["content"][0]["type"], "thinking"); - - // token_count landed on the final assistant text turn and rolled into totals - let last = n.messages.last().unwrap(); - assert_eq!(last["usage"]["inputTokens"], 300); // input − cached - assert_eq!(last["usage"]["cacheRead"], 600); - assert_eq!(n.totals["out"], 80); - assert_eq!(n.totals["turns"], 1); - - // timestamps span the emitted messages - assert_eq!(n.first_ts.as_deref(), Some("2026-07-04T07:13:08.969Z")); - assert_eq!(n.last_ts.as_deref(), Some("2026-07-04T07:13:17.000Z")); - } - - // Machine-data smoke: run explicitly with `cargo test --lib -- --ignored` on a machine that - // has real Codex sessions. Verifies every real rollout sniffs + normalizes + shapes. - #[test] - #[ignore] - fn real_codex_sessions_smoke() { - if !root_exists() { - eprintln!("no ~/.codex/sessions — skipping"); - return; - } - let mut n = 0; - let label = codex_label(); - walk_sessions(&sessions_root(), |p| { - let raw = fs::read_to_string(&p).unwrap_or_default(); - let recs = crate::history::parse_lines(&raw); - assert!(looks_codex(&recs), "not sniffed as codex: {:?}", p); - let norm = normalize(&recs); - assert!(norm.session_id.is_some() || norm.messages.is_empty(), "no session id: {:?}", p); - let sess = session_from_recs(&p.to_string_lossy(), &recs); - assert_eq!(sess["meta"]["assistant"], "Codex"); - let listed = session_meta_from(&p, &recs, &label, &label).unwrap(); - assert_eq!(listed["source"], "codex"); - n += 1; - }); - eprintln!("smoke-checked {} real codex sessions", n); - } - - #[test] - fn claude_records_do_not_sniff_as_codex() { - let recs = vec![ - json!({ "type": "user", "message": { "role": "user", "content": "hi" }, "cwd": "/x", "sessionId": "s1" }), - json!({ "type": "assistant", "message": { "role": "assistant", "content": [{ "type": "text", "text": "hello" }] } }), - json!({ "type": "summary", "summary": "greeting" }), - ]; - assert!(!looks_codex(&recs)); - } - - #[test] - fn old_envelope_less_rollout_still_parses() { - let recs = vec![ - json!({ "id": "old-1", "timestamp": "2025-05-01T00:00:00Z", "instructions": "x", "cwd": "/tmp/old" }), - json!({ "type": "message", "role": "user", "content": [{ "type": "input_text", "text": "hello old codex" }] }), - json!({ "type": "message", "role": "assistant", "content": [{ "type": "output_text", "text": "hi" }] }), - ]; - assert!(looks_codex(&recs)); - let n = normalize(&recs); - assert_eq!(n.session_id.as_deref(), Some("old-1")); - assert_eq!(n.cwd.as_deref(), Some("/tmp/old")); - assert_eq!(n.messages.len(), 2); - assert_eq!(crate::history::first_user_text(&n.messages), "hello old codex"); - } - - #[test] - fn turn_aborted_and_web_search_render() { - let recs: Vec = vec![ - serde_json::from_str(&line("2026-01-01T00:00:00Z", "response_item", json!({ - "type": "web_search_call", "id": "ws_1", "action": { "type": "search", "query": "rust serde" } - }))).unwrap(), - serde_json::from_str(&line("2026-01-01T00:00:01Z", "event_msg", json!({ "type": "turn_aborted", "reason": "interrupted" }))).unwrap(), - ]; - let n = normalize(&recs); - assert_eq!(n.messages[0]["content"][0]["name"], "WebSearch"); - assert_eq!(n.messages[0]["content"][0]["input"]["query"], "rust serde"); - assert!(n.messages[1]["content"][0]["text"].as_str().unwrap().starts_with("[Request interrupted")); - } - - #[test] - fn maps_code_mode_exec_scripts() { - // canonical single exec_command + print plumbing → Bash card (command + workdir) - let (n, i) = map_exec_script( - "const r = await tools.exec_command({\"cmd\":\"ls -la\",\"workdir\":\"/tmp/p\",\"yield_time_ms\":10000,\"max_output_tokens\":30000});\ntext(r.output);", - ); - assert_eq!(n, "Bash"); - assert_eq!(i["command"], "ls -la"); - assert_eq!(i["description"], "/tmp/p"); - // bare JS object keys (code-mode literal) parse via the quoting retry; braces/quotes - // inside the command string stay opaque - let (n, i) = map_exec_script( - "const r = await tools.exec_command({cmd:\"awk '{print: $1}' a.txt\",workdir:\"/w\"});\ntext(JSON.stringify(r));", - ); - assert_eq!(n, "Bash"); - assert_eq!(i["command"], "awk '{print: $1}' a.txt"); - // SESSION_ID echo tail is still plumbing - let (n, _) = map_exec_script( - "const r = await tools.exec_command({\"cmd\":\"sleep 1\"});\ntext(r.output);\nif (r.session_id) text(`SESSION_ID=${r.session_id}`);", - ); - assert_eq!(n, "Bash"); - // write_stdin / multi-call orchestration keep the script verbatim - let (n, i) = map_exec_script("const r = await tools.write_stdin({\"session_id\":40352,\"chars\":\"\"});\ntext(r.output);"); - assert_eq!(n, "Script"); - assert!(i["code"].as_str().unwrap().contains("write_stdin")); - let (n, _) = map_exec_script( - "const a = await Promise.all([tools.exec_command({\"cmd\":\"x\"}), tools.exec_command({\"cmd\":\"y\"})]);\ntext(a.map(r => r.output).join());", - ); - assert_eq!(n, "Script"); - } - - #[test] - fn shapes_code_mode_block_array_outputs() { - let recs = vec![ - json!({ "type": "custom_tool_call", "name": "exec", "call_id": "c1", - "input": "const r = await tools.exec_command({\"cmd\":\"ls\"});\ntext(r.output);" }), - json!({ "type": "custom_tool_call_output", "call_id": "c1", "output": [ - { "type": "input_text", "text": "Script completed\nWall time 0.1 seconds\nOutput:\n" }, - { "type": "input_text", "text": "a.txt\n" }, - { "type": "input_image", "image_url": "data:image/png;base64,QUJD", "detail": "high" } - ] }), - json!({ "type": "custom_tool_call", "name": "exec", "call_id": "c2", - "input": "const r = await tools.exec_command({\"cmd\":\"boom\"});\ntext(r.output);" }), - json!({ "type": "custom_tool_call_output", "call_id": "c2", "output": [ - { "type": "input_text", "text": "Script failed\nWall time 0.0 seconds\nOutput:\nerr" } - ] }), - ]; - let n = normalize(&recs); - assert_eq!(n.messages.len(), 4); - let tu = &n.messages[0]["content"][0]; - assert_eq!(tu["name"], "Bash"); - assert_eq!(tu["input"]["command"], "ls"); - // text chunks concatenate VERBATIM (no injected separators); screenshot rides along - let tr = &n.messages[1]["content"][0]; - assert_eq!(tr["tool_use_id"], "c1"); - assert_eq!(tr["content"][0]["text"], "Script completed\nWall time 0.1 seconds\nOutput:\na.txt\n"); - assert_eq!(tr["content"][1]["type"], "image"); - assert_eq!(tr["content"][1]["source"]["data"], "QUJD"); - assert!(tr.get("is_error").is_none()); - // "Script failed" header marks the result as an error; plain-text output stays a string - let tr2 = &n.messages[3]["content"][0]; - assert_eq!(tr2["content"].as_str().unwrap(), "Script failed\nWall time 0.0 seconds\nOutput:\nerr"); - assert_eq!(tr2["is_error"], true); - } -} diff --git a/src-tauri/src/codex/exec.rs b/src-tauri/src/codex/exec.rs new file mode 100644 index 0000000..465b9a2 --- /dev/null +++ b/src-tauri/src/codex/exec.rs @@ -0,0 +1,189 @@ +use serde_json::{json, Value}; + +// ---- code-mode `exec` scripts (custom_tool_call name "exec") ---- +// +// Codex code-mode (gpt-*-sol) emits one custom tool named `exec` whose input is JavaScript +// calling `tools.*` (exec_command / write_stdin / …). The dominant shape by far is a single +// `tools.exec_command({cmd, workdir, …})` plus print plumbing (`text(r.output);` and friends) — +// semantically just a shell run, so it renders as the familiar Bash card (command + workdir). +// Anything else (write_stdin, Promise.all batches, real orchestration code) keeps the whole +// script as a `Script` card the renderer shows as highlighted JavaScript. Extraction is +// conservative: any parse doubt falls back to the Script card, never to a wrong command. + +/// First `{…}` object literal at/after `from`, brace-matched with double-quoted strings (and +/// their escapes) treated as opaque — shell commands are full of braces and quotes. +fn extract_object(s: &str, from: usize) -> Option<(usize, usize)> { + let start = from + s[from..].find('{')?; + let (mut depth, mut in_str, mut esc) = (0i32, false, false); + for (i, &b) in s.as_bytes().iter().enumerate().skip(start) { + if in_str { + if esc { + esc = false; + } else if b == b'\\' { + esc = true; + } else if b == b'"' { + in_str = false; + } + continue; + } + match b { + b'"' => in_str = true, + b'{' => depth += 1, + b'}' => { + depth -= 1; + if depth == 0 { + return Some((start, i)); + } + } + _ => {} + } + } + None +} + +/// Quote bare JS object keys (`{cmd: …}` → `{"cmd": …}`) outside string context so serde can +/// parse code-mode's object-literal arguments; double-quoted string contents pass verbatim. +fn quote_js_keys(s: &str) -> String { + let chars: Vec = s.chars().collect(); + let mut out = String::with_capacity(s.len() + 16); + let (mut in_str, mut esc) = (false, false); + let mut i = 0; + while i < chars.len() { + let c = chars[i]; + if in_str { + if esc { + esc = false; + } else if c == '\\' { + esc = true; + } else if c == '"' { + in_str = false; + } + out.push(c); + i += 1; + continue; + } + if c == '"' { + in_str = true; + out.push(c); + i += 1; + continue; + } + if c == '{' || c == ',' { + out.push(c); + i += 1; + while i < chars.len() && chars[i].is_whitespace() { + out.push(chars[i]); + i += 1; + } + let start = i; + while i < chars.len() && (chars[i].is_ascii_alphanumeric() || chars[i] == '_' || chars[i] == '$') { + i += 1; + } + if i > start { + let mut j = i; + while j < chars.len() && chars[j].is_whitespace() { + j += 1; + } + let ident: String = chars[start..i].iter().collect(); + if j < chars.len() && chars[j] == ':' { + out.push('"'); + out.push_str(&ident); + out.push('"'); + } else { + out.push_str(&ident); + } + } + continue; + } + out.push(c); + i += 1; + } + out +} + +/// The `{…}` argument of a tools.* call: strict JSON first (code-mode usually emits JSON), +/// then a bare-key-quoted retry for JS object literals. +fn parse_call_args(obj: &str) -> Option { + serde_json::from_str::(obj) + .ok() + .or_else(|| serde_json::from_str::("e_js_keys(obj)).ok()) + .filter(|v| v.is_object()) +} + +/// Code-mode exec script → renderer tool card (see module comment above). +pub(super) fn map_exec_script(script: &str) -> (String, Value) { + let fallback = || ("Script".to_string(), json!({ "code": script })); + // exactly one tools.* call, and it must be exec_command (a cmd string that itself mentions + // "tools." trips the count — conservative fallback, never a wrong command) + if script.matches("tools.").count() != 1 { + return fallback(); + } + let call = match script.find("tools.exec_command(") { + Some(i) => i, + None => return fallback(), + }; + // prefix must be assignment/await plumbing only: `const r = await` / `let out = await` / `await` + let prefix: Vec<&str> = script[..call].split_whitespace().collect(); + let prefix_ok = match prefix.as_slice() { + [] | ["await"] => true, + [kw, _name, "=", "await"] => matches!(*kw, "const" | "let" | "var"), + _ => false, + }; + if !prefix_ok { + return fallback(); + } + let after = call + "tools.exec_command(".len(); + let (ostart, oend) = match extract_object(script, after) { + Some(span) => span, + None => return fallback(), + }; + if !script[after..ostart].trim().is_empty() { + return fallback(); + } + let args = match parse_call_args(&script[ostart..=oend]) { + Some(a) => a, + None => return fallback(), + }; + let cmd = args + .get("cmd") + .or_else(|| args.get("command")) + .and_then(|v| v.as_str()) + .unwrap_or(""); + if cmd.is_empty() { + return fallback(); + } + // tail must close the call, then carry only print plumbing + let rest = script[oend + 1..].trim_start(); + let rest = match rest.strip_prefix(')') { + Some(r) => r, + None => return fallback(), + }; + let rest = rest.strip_prefix(';').unwrap_or(rest); + let plumbing = rest.lines().all(|l| { + let l = l.trim(); + l.is_empty() || l.starts_with("text(") || l.starts_with("if (") || l.starts_with("//") + }); + if !plumbing { + return fallback(); + } + let mut input = json!({ "command": cmd }); + if let Some(wd) = args.get("workdir").and_then(|v| v.as_str()) { + if !wd.is_empty() { + input["description"] = json!(wd); + } + } + ("Bash".into(), input) +} + +/// Error heuristic for code-mode exec output text: the runner's own status header +/// ("Script failed…" / "Exit code: N…"). +pub(super) fn exec_text_err(text: &str) -> bool { + if text.starts_with("Script failed") { + return true; + } + if let Some(rest) = text.strip_prefix("Exit code: ") { + let digits: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect(); + return digits.parse::().map(|c| c != 0).unwrap_or(false); + } + false +} diff --git a/src-tauri/src/codex/items.rs b/src-tauri/src/codex/items.rs new file mode 100644 index 0000000..731ee7f --- /dev/null +++ b/src-tauri/src/codex/items.rs @@ -0,0 +1,219 @@ +// normalize()'s "response_item" match arm, moved verbatim out of normalize.rs so both files +// stay under the split's size cap. Called once per record from normalize()'s loop; `return` +// here matches the original `continue` (this match was the loop body's last statement), and +// the `with_ts` closure is duplicated verbatim from that loop. + +use crate::history::image_block; +use serde_json::{json, Value}; + +use super::exec::{exec_text_err, map_exec_script}; +use super::records::{is_agents_bootstrap, is_meta_user_text, skill_load_block}; +use super::titles::{joined_text, joined_user_text}; +use super::tools::{join_argv, map_tool, shape_output}; + +pub(super) fn on_response_item(p: &Value, ts: Option<&str>, model: &Option, messages: &mut Vec) { + let with_ts = |mut m: Value| { + if let Some(t) = ts { + m["ts"] = json!(t); + } + m + }; + let it = p.get("type").and_then(|v| v.as_str()).unwrap_or(""); + match it { + "message" => { + let role = p.get("role").and_then(|v| v.as_str()).unwrap_or(""); + let content = p.get("content").cloned().unwrap_or(Value::Null); + if role == "assistant" { + let text = joined_text(&content, &["output_text", "text"]); + if !text.trim().is_empty() { + let mut m = json!({ "role": "assistant", "content": [{ "type": "text", "text": text }] }); + if let Some(md) = &model { + m["modelActual"] = json!(md); + } + messages.push(with_ts(m)); + } + } else if role == "user" { + let text = joined_user_text(&content); + if let Some(skill) = skill_load_block(&text) { + messages.push(with_ts(json!({ + "role": "user", + "_meta": true, + "content": [skill], + }))); + return; // `continue` in the original normalize() loop + } + if is_meta_user_text(&text) { + return; // `continue` in the original normalize() loop + } + let mut blocks: Vec = vec![]; + if !text.trim().is_empty() { + blocks.push(json!({ "type": "text", "text": text })); + } + if let Some(arr) = content.as_array() { + for b in arr { + if b.get("type").and_then(|t| t.as_str()) == Some("input_image") { + if let Some(img) = b + .get("image_url") + .and_then(|u| u.as_str()) + .and_then(image_block) + { + blocks.push(img); + } + } + } + } + if !blocks.is_empty() { + let mut message = json!({ "role": "user", "content": blocks }); + if is_agents_bootstrap(&text) { + message["_meta"] = json!(true); + } + messages.push(with_ts(message)); + } + } // system / developer turns: harness plumbing, not conversation + } + "reasoning" => { + let mut txt = joined_text(&p.get("summary").cloned().unwrap_or(Value::Null), &["summary_text", "text"]); + let extra = joined_text(&p.get("content").cloned().unwrap_or(Value::Null), &["reasoning_text", "text"]); + if !extra.trim().is_empty() { + if !txt.trim().is_empty() { + txt.push_str("\n\n"); + } + txt.push_str(&extra); + } + if !txt.trim().is_empty() { + let mut m = json!({ "role": "assistant", "content": [{ "type": "thinking", "thinking": txt }] }); + if let Some(md) = &model { + m["modelActual"] = json!(md); + } + messages.push(with_ts(m)); + } + } + "function_call" => { + let name = p.get("name").and_then(|v| v.as_str()).unwrap_or("tool"); + let args: Value = p + .get("arguments") + .and_then(|v| v.as_str()) + .and_then(|s| serde_json::from_str(s).ok()) + .unwrap_or_else(|| p.get("arguments").cloned().unwrap_or(json!({}))); + let (tname, input) = map_tool(name, &args); + let id = p + .get("call_id") + .or_else(|| p.get("id")) + .and_then(|v| v.as_str()) + .unwrap_or(""); + let mut m = json!({ + "role": "assistant", + "content": [{ "type": "tool_use", "id": id, "name": tname, "input": input }], + }); + if let Some(md) = &model { + m["modelActual"] = json!(md); + } + messages.push(with_ts(m)); + } + "local_shell_call" => { + let cmd = p + .get("action") + .and_then(|a| a.get("command")) + .cloned() + .unwrap_or(Value::Null); + let id = p + .get("call_id") + .or_else(|| p.get("id")) + .and_then(|v| v.as_str()) + .unwrap_or(""); + let mut m = json!({ + "role": "assistant", + "content": [{ "type": "tool_use", "id": id, "name": "Bash", "input": { "command": join_argv(&cmd) } }], + }); + if let Some(md) = &model { + m["modelActual"] = json!(md); + } + messages.push(with_ts(m)); + } + "custom_tool_call" => { + let name = p.get("name").and_then(|v| v.as_str()).unwrap_or("tool"); + let input_s = p.get("input").and_then(|v| v.as_str()).unwrap_or(""); + let (tname, input) = if name == "apply_patch" { + ("ApplyPatch".to_string(), json!({ "patch": input_s })) + } else if name == "exec" { + map_exec_script(input_s) + } else { + (name.to_string(), json!({ "input": input_s })) + }; + let id = p + .get("call_id") + .or_else(|| p.get("id")) + .and_then(|v| v.as_str()) + .unwrap_or(""); + let mut m = json!({ + "role": "assistant", + "content": [{ "type": "tool_use", "id": id, "name": tname, "input": input }], + }); + if let Some(md) = &model { + m["modelActual"] = json!(md); + } + messages.push(with_ts(m)); + } + "function_call_output" | "custom_tool_call_output" => { + let out = p.get("output").cloned().unwrap_or(Value::Null); + let id = p.get("call_id").and_then(|v| v.as_str()).unwrap_or(""); + // Newer code-mode outputs are block ARRAYS — {input_text} chunks (status + // header + stdout, concatenated verbatim) plus optional {input_image} + // screenshots, which become renderer image blocks. + let (content, err) = if let Some(arr) = out.as_array() { + let text: String = arr + .iter() + .filter(|b| { + matches!( + b.get("type").and_then(|t| t.as_str()), + Some("input_text") | Some("output_text") | Some("text") + ) + }) + .filter_map(|b| b.get("text").and_then(|t| t.as_str())) + .collect(); + let images: Vec = arr + .iter() + .filter(|b| b.get("type").and_then(|t| t.as_str()) == Some("input_image")) + .filter_map(|b| b.get("image_url").and_then(|u| u.as_str()).and_then(image_block)) + .collect(); + let err = exec_text_err(&text); + if images.is_empty() { + (json!(text), err) + } else { + let mut blocks = vec![json!({ "type": "text", "text": text })]; + blocks.extend(images); + (Value::Array(blocks), err) + } + } else { + let (text, err) = shape_output(&out); + (json!(text), err) + }; + let mut tr = json!({ "type": "tool_result", "tool_use_id": id, "content": content }); + if err { + tr["is_error"] = json!(true); + } + messages.push(with_ts(json!({ "role": "user", "content": [tr] }))); + } + "web_search_call" => { + let q = p + .get("action") + .and_then(|a| a.get("query")) + .and_then(|v| v.as_str()) + .unwrap_or(""); + let id = p + .get("id") + .or_else(|| p.get("call_id")) + .and_then(|v| v.as_str()) + .unwrap_or(""); + let mut m = json!({ + "role": "assistant", + "content": [{ "type": "tool_use", "id": id, "name": "WebSearch", "input": { "query": q } }], + }); + if let Some(md) = &model { + m["modelActual"] = json!(md); + } + messages.push(with_ts(m)); + } + _ => {} + } +} diff --git a/src-tauri/src/codex/meta.rs b/src-tauri/src/codex/meta.rs new file mode 100644 index 0000000..6ac3ee8 --- /dev/null +++ b/src-tauri/src/codex/meta.rs @@ -0,0 +1,197 @@ +use crate::history::Norm; +use serde_json::{json, Value}; +use std::fs; +use std::path::Path; + +use super::normalize::normalize; +use super::titles::{first_event_user_title, scan_event_user_title}; + +// ---- sidecar customization (shared store, ~/.ccbud/codex-meta.json, keyed by rollout stem) ---- + +fn stem_of(file: &Path) -> String { + file.file_stem().and_then(|s| s.to_str()).unwrap_or("").to_string() +} + +/// (custom title, tags, deleted) for a codex session, from the sidecar. +fn sidecar_meta(file: &Path) -> (Option, Vec, bool) { + crate::sidecar::meta(&crate::sidecar::codex_file(), &stem_of(file)) +} + +pub fn is_deleted(file: &Path) -> bool { + sidecar_meta(file).2 +} + +/// set_ccbud-equivalent for codex sessions: same patch semantics ({title?, tags?, delete?}), +/// persisted to the sidecar instead of the rollout file (never mutate another tool's data). +pub fn set_meta(file: &str, patch: &Value) -> Value { + let stem = stem_of(Path::new(file)); + if stem.is_empty() { + return json!({ "ok": false, "reason": "empty" }); + } + crate::sidecar::set_meta(&crate::sidecar::codex_file(), &stem, patch) +} + +/// Drop a session's sidecar entry (after its rollout file is deleted forever). +pub fn remove_meta(file: &str) { + crate::sidecar::remove_meta(&crate::sidecar::codex_file(), &stem_of(Path::new(file))); +} + +// ---- list/detail shapes (codex flavors of history.rs session_meta / get_session) ---- + +fn subagent_title(n: &Norm) -> String { + if !n.is_subagent { + return String::new(); + } + let path = n + .agent_path + .as_deref() + .unwrap_or("") + .trim_start_matches('/') + .strip_prefix("root/") + .unwrap_or_else(|| n.agent_path.as_deref().unwrap_or("").trim_start_matches('/')); + let mut parts = Vec::new(); + if let Some(nickname) = n.agent_nickname.as_deref().filter(|value| !value.trim().is_empty()) { + parts.push(nickname.trim()); + } + if !path.is_empty() { + parts.push(path); + } + if parts.is_empty() { + "Codex subagent".to_string() + } else { + parts.join(" · ") + } +} + +fn is_canonical_thread_id(value: &str) -> bool { + let bytes = value.as_bytes(); + bytes.len() == 36 + && [8usize, 13, 18, 23].into_iter().all(|index| bytes[index] == b'-') + && bytes + .iter() + .enumerate() + .all(|(index, byte)| [8usize, 13, 18, 23].contains(&index) || byte.is_ascii_hexdigit()) +} + +/// List-row meta from already-parsed head records. `dir_id` is `__codex__` for the live tree +/// or `__imported__` for snapshots copied into the app store. +pub fn session_meta_from(file: &Path, recs: &[Value], dir_id: &str, dir_label: &str) -> Option { + let meta = fs::metadata(file).ok()?; + let n = normalize(recs); + // Live rollouts customize via the sidecar (never rewrite another tool's files); imported + // COPIES (marked by an .import.json) are our own files, where the standard in-file + // __ccbud__ (written by set_ccbud) applies. + let native = crate::history::read_import_meta(&file.to_string_lossy()).is_none(); + let (cc_title, cc_tags, cc_deleted) = if native { + sidecar_meta(file) + } else { + crate::history::read_ccbud(recs) + }; + let mut transcript_title = crate::history::first_user_text(&n.messages); + if transcript_title.is_empty() { + transcript_title = first_event_user_title(recs); + } + if transcript_title.is_empty() && meta.len() > 131072 { + transcript_title = scan_event_user_title(file); + } + let agent_title = subagent_title(&n); + let auto_title = if agent_title.is_empty() { transcript_title } else { agent_title }; + let stem = stem_of(file); + let mt = meta + .modified() + .ok() + .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|d| d.as_millis() as f64) + .unwrap_or(0.0); + Some(json!({ + // Row ids are UI identities, so include the configured store. A live rollout and an + // imported snapshot can legitimately share the same filename/thread id. + "id": format!("codex:{}:{}", dir_id, stem), + "file": file.to_string_lossy(), + "source": "codex", + "dirId": dir_id, + "dirLabel": dir_label, + "sessionId": n.thread_id.clone().or_else(|| n.session_id.clone()).unwrap_or_else(|| stem.clone()), + "threadId": n.thread_id.clone().or_else(|| n.session_id.clone()).unwrap_or_else(|| stem.clone()), + "canonicalThreadIdValid": n.thread_id.as_deref().is_some_and(is_canonical_thread_id), + "rootSessionId": n.session_id.clone().or_else(|| n.thread_id.clone()).unwrap_or_else(|| stem.clone()), + "parentThreadId": n.parent_thread_id.clone(), + "forkedFromId": n.forked_from_id.clone(), + "cwd": n.cwd.clone(), + "project": n.cwd.as_deref().map(crate::history::base_name).unwrap_or_default(), + "gitBranch": n.git_branch.clone(), + "title": cc_title.clone().unwrap_or_else(|| auto_title.clone()), + "autoTitle": auto_title, + "tags": cc_tags, + "model": n.model, + "isSubagent": n.is_subagent, + "agentPath": n.agent_path.clone(), + "agentNickname": n.agent_nickname.clone(), + "agentRole": n.agent_role.clone(), + "agentDepth": n.agent_depth, + "imported": dir_id == "__imported__", + "deleted": cc_deleted, + "createdAt": crate::history::record_created_ms(recs, file), + "lastActivity": mt, + "sizeKB": (meta.len() as f64 / 1024.0).round() as i64, + })) +} + +/// Full-detail shape from already-parsed records (history.rs get_session routes here). +pub fn session_from_recs(file: &str, recs: &[Value]) -> Value { + let path = Path::new(file); + let n = normalize(recs); + let import_meta = crate::history::read_import_meta(file); + // Same sidecar-vs-in-file split as session_meta_from. + let (cc_title, cc_tags, cc_deleted) = if import_meta.is_none() { + sidecar_meta(path) + } else { + crate::history::read_ccbud(recs) + }; + let mut transcript_title = crate::history::first_user_text(&n.messages); + if transcript_title.is_empty() { + transcript_title = first_event_user_title(recs); + } + let agent_title = subagent_title(&n); + let auto_title = if agent_title.is_empty() { transcript_title } else { agent_title }; + let stem = stem_of(path); + json!({ + "meta": { + "id": format!("codex:{}", stem), + "file": file, + "source": "codex", + "assistant": "Codex", + "title": cc_title.clone().unwrap_or_else(|| auto_title.clone()), + "autoTitle": auto_title, + "tags": cc_tags, + "summary": Value::Null, + "sessionId": n.thread_id.clone().or_else(|| n.session_id.clone()).unwrap_or_else(|| stem.clone()), + "threadId": n.thread_id.clone().or_else(|| n.session_id.clone()).unwrap_or_else(|| stem.clone()), + "canonicalThreadIdValid": n.thread_id.as_deref().is_some_and(is_canonical_thread_id), + "rootSessionId": n.session_id.clone().or_else(|| n.thread_id.clone()).unwrap_or_else(|| stem.clone()), + "parentThreadId": n.parent_thread_id.clone(), + "forkedFromId": n.forked_from_id.clone(), + "cwd": n.cwd.clone(), + "project": n.cwd.as_deref().map(crate::history::base_name).unwrap_or_default(), + "gitBranch": n.git_branch.clone(), + "version": n.version.clone(), + "isSubagent": n.is_subagent, + "agentPath": n.agent_path.clone(), + "agentNickname": n.agent_nickname.clone(), + "agentRole": n.agent_role.clone(), + "agentDepth": n.agent_depth, + "deleted": cc_deleted, + "imported": import_meta.is_some(), + "importedFrom": import_meta.as_ref().and_then(|m| m.get("originalPath")).cloned().unwrap_or(Value::Null), + "importedAt": import_meta.as_ref().and_then(|m| m.get("importedAt")).cloned().unwrap_or(Value::Null), + "model": n.model, + "totals": n.totals, + "messages": n.messages.len(), + "subagentCount": 0, + "firstTs": n.first_ts, + "lastTs": n.last_ts, + }, + "messages": n.messages, + "subagents": {}, + }) +} diff --git a/src-tauri/src/codex/mod.rs b/src-tauri/src/codex/mod.rs new file mode 100644 index 0000000..fcb5061 --- /dev/null +++ b/src-tauri/src/codex/mod.rs @@ -0,0 +1,45 @@ +// Codex CLI session support — reads OpenAI Codex's on-disk rollout logs +// (`~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl`) and normalizes them into the SAME +// session/message shape the renderer consumes for Claude Code history, so the 对话 view +// (list / detail / search / live-follow / export) browses both without renderer forks. +// +// A rollout line is `{timestamp, type, payload}` with type ∈ {session_meta, turn_context, +// response_item, event_msg, compacted}. Conversation content lives in response_item payloads +// (message / reasoning / function_call / function_call_output / local_shell_call / +// custom_tool_call / web_search_call); event_msg mostly duplicates that content, but token_count +// carries usage and user_message supplies a bounded title fallback for image-heavy first turns. +// Very old Codex builds wrote +// payload objects directly per line (no envelope) — handled by treating such a line as its +// own payload. +// +// Tool calls are mapped onto the tool vocabulary the renderer already draws natively: +// shell/exec_command/local_shell_call → Bash, update_plan → TodoWrite, view_image → Read, +// web_search → WebSearch, apply_patch → ApplyPatch (a codex-specific card). +// +// Title/tags/soft-delete: Codex files belong to another tool, so per-conversation +// customization never rewrites them (unlike Claude's in-file `__ccbud__`) — it lives in a +// sidecar map at `~/.ccbud/codex-meta.json`, keyed by the rollout file stem. + +#![allow(dead_code)] + +mod exec; +mod items; +mod meta; +mod normalize; +mod records; +mod roots; +mod titles; +mod tools; +#[cfg(test)] +mod tests; +#[cfg(test)] +mod tests_more; + +pub use meta::{is_deleted, remove_meta, session_from_recs, session_meta_from, set_meta}; +// normalize/sessions_root are exercised by the #[cfg(test)] modules via these re-exports, +// so a non-test `cargo check` sees them as unused — allow that, don't drop the API. +#[allow(unused_imports)] +pub use normalize::normalize; +pub use records::head_ids; +#[allow(unused_imports)] +pub use roots::{codex_label, looks_codex, preferred_rollout_path, root_exists, sessions_root, walk_sessions}; diff --git a/src-tauri/src/codex/normalize.rs b/src-tauri/src/codex/normalize.rs new file mode 100644 index 0000000..67d15c8 --- /dev/null +++ b/src-tauri/src/codex/normalize.rs @@ -0,0 +1,160 @@ +// Rollout -> renderer message model (split from codex.rs). The response_item arm lives in +// items.rs (see on_response_item) to keep both files under the split's size cap. + +use crate::history::Norm; +use serde_json::{json, Value}; + +use super::items::on_response_item; +use super::records::{canonical_thread_meta, split_line}; + +/// Normalize parsed rollout records into the renderer's message model. +pub fn normalize(recs: &[Value]) -> Norm { + let mut messages: Vec = vec![]; + let (mut tin, mut tout, mut tcr, mut turns) = (0i64, 0i64, 0i64, 0i64); + let mut model: Option = None; + let mut cwd: Option = None; + let mut session_id: Option = None; + let mut thread_id: Option = None; + let mut parent_thread_id: Option = None; + let mut forked_from_id: Option = None; + let mut is_subagent = false; + let mut agent_path: Option = None; + let mut agent_nickname: Option = None; + let mut agent_role: Option = None; + let mut agent_depth: Option = None; + let mut saw_session_meta = false; + let mut git_branch: Option = None; + let mut version: Option = None; + + for rec in recs { + let (ty, p, ts) = split_line(rec); + let with_ts = |mut m: Value| { + if let Some(t) = ts { + m["ts"] = json!(t); + } + m + }; + match ty { + "session_meta" => { + if !saw_session_meta { + saw_session_meta = true; + let identity = canonical_thread_meta(p); + thread_id = identity.thread_id; + session_id = identity.root_session_id; + parent_thread_id = identity.parent_thread_id; + forked_from_id = identity.forked_from_id; + is_subagent = identity.is_subagent; + agent_path = identity.agent_path; + agent_nickname = identity.agent_nickname; + agent_role = identity.agent_role; + agent_depth = identity.agent_depth; + } + let sid = p + .get("session_id") + .or_else(|| p.get("id")) + .and_then(|v| v.as_str()); + if session_id.is_none() { + session_id = sid.map(|s| s.to_string()); + } + if cwd.is_none() { + cwd = p.get("cwd").and_then(|v| v.as_str()).map(|s| s.to_string()); + } + if version.is_none() { + version = p.get("cli_version").and_then(|v| v.as_str()).map(|s| s.to_string()); + } + if git_branch.is_none() { + git_branch = p + .get("git") + .and_then(|g| g.get("branch")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + } + } + "turn_context" => { + if let Some(m) = p.get("model").and_then(|v| v.as_str()) { + model = Some(m.to_string()); + } + if cwd.is_none() { + cwd = p.get("cwd").and_then(|v| v.as_str()).map(|s| s.to_string()); + } + } + "compacted" => { + let text = p.get("message").and_then(|v| v.as_str()).unwrap_or("").trim().to_string(); + if !text.is_empty() { + messages.push(with_ts(json!({ "role": "user", "content": [{ "type": "text", "text": text }] }))); + } + } + "event_msg" => match p.get("type").and_then(|v| v.as_str()).unwrap_or("") { + "token_count" => { + let u = p.get("info").and_then(|i| i.get("last_token_usage")); + if let Some(u) = u { + let input = u.get("input_tokens").and_then(|v| v.as_i64()).unwrap_or(0); + let cached = u.get("cached_input_tokens").and_then(|v| v.as_i64()).unwrap_or(0); + let output = u.get("output_tokens").and_then(|v| v.as_i64()).unwrap_or(0); + if input + cached + output > 0 { + let usage = json!({ + "inputTokens": (input - cached).max(0), + "outputTokens": output, + "cacheRead": cached, + "cacheCreation": 0, + }); + tin += (input - cached).max(0); + tout += output; + tcr += cached; + turns += 1; + // Per-turn usage rides the turn's last assistant message (codex emits + // one token_count per model turn). + if let Some(m) = messages + .iter_mut() + .rev() + .find(|m| m.get("role").and_then(|r| r.as_str()) == Some("assistant") && m.get("usage").is_none()) + { + m["usage"] = usage; + } + } + } + } + "turn_aborted" => { + messages.push(with_ts(json!({ + "role": "user", + "content": [{ "type": "text", "text": "[Request interrupted by user]" }], + }))); + } + _ => {} + }, + "response_item" => on_response_item(p, ts, &model, &mut messages), + _ => {} + } + } + + let first_ts = messages + .first() + .and_then(|m| m.get("ts")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + let last_ts = messages + .last() + .and_then(|m| m.get("ts")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + Norm { + messages, + totals: json!({ "in": tin, "out": tout, "cacheRead": tcr, "cacheCreation": 0, "turns": turns }), + model, + first_ts, + last_ts, + cwd, + session_id, + thread_id, + parent_thread_id, + forked_from_id, + is_subagent, + agent_path, + agent_nickname, + agent_role, + agent_depth, + git_branch, + version, + } +} diff --git a/src-tauri/src/codex/records.rs b/src-tauri/src/codex/records.rs new file mode 100644 index 0000000..9ce0eaf --- /dev/null +++ b/src-tauri/src/codex/records.rs @@ -0,0 +1,174 @@ +// Rollout record classification: line envelope split, canonical thread identity, meta/skill +// user-turn detection, and head ids (split from codex.rs). + +use serde_json::{json, Value}; + +/// (type, payload, timestamp) of a rollout line, tolerating the old envelope-less format. +pub(super) fn split_line(rec: &Value) -> (&str, &Value, Option<&str>) { + let ts = rec.get("timestamp").and_then(|v| v.as_str()); + let t = rec.get("type").and_then(|v| v.as_str()).unwrap_or(""); + if let Some(p) = rec.get("payload") { + return (t, p, ts); + } + match t { + "message" | "function_call" | "function_call_output" | "reasoning" | "local_shell_call" + | "custom_tool_call" | "custom_tool_call_output" | "web_search_call" => ("response_item", rec, ts), + // old first line: bare SessionMeta {id, timestamp, instructions, cwd?, git?} + "" if rec.get("id").is_some() && rec.get("timestamp").is_some() => ("session_meta", rec, ts), + _ => (t, rec, ts), + } +} + +#[derive(Default)] +pub(super) struct CanonicalThreadMeta { + pub(super) thread_id: Option, + pub(super) root_session_id: Option, + pub(super) parent_thread_id: Option, + pub(super) forked_from_id: Option, + pub(super) is_subagent: bool, + pub(super) agent_path: Option, + pub(super) agent_nickname: Option, + pub(super) agent_role: Option, + pub(super) agent_depth: Option, +} + +// The first SessionMeta is canonical for the physical rollout. Subagent/fork rollouts can copy +// ancestor SessionMeta records behind it, and every thread in that tree intentionally shares the +// same session_id. The unique thread key is the first meta's id. +pub(super) fn canonical_thread_meta(payload: &Value) -> CanonicalThreadMeta { + let subagent = payload + .get("source") + .and_then(|source| source.get("subagent").or_else(|| source.get("sub_agent"))) + .or_else(|| { + payload + .get("thread_source") + .and_then(|source| source.get("subagent").or_else(|| source.get("sub_agent"))) + }); + let detail = subagent.and_then(|source| { + ["thread_spawn", "review", "compact", "other"] + .iter() + .find_map(|key| source.get(*key).filter(|value| value.is_object())) + .or_else(|| source.as_object().and_then(|object| object.values().find(|value| value.is_object()))) + }); + let string = |value: Option<&Value>| { + value + .and_then(Value::as_str) + .filter(|value| !value.trim().is_empty()) + .map(str::to_string) + }; + let thread_id = string( + payload + .get("id") + .or_else(|| payload.get("thread_id")), + ); + let root_session_id = string(payload.get("session_id")).or_else(|| thread_id.clone()); + let parent_thread_id = string( + payload + .get("parent_thread_id") + .or_else(|| detail.and_then(|value| value.get("parent_thread_id"))), + ); + let is_subagent = subagent.is_some() + || payload.get("thread_source").and_then(Value::as_str) == Some("subagent") + || payload + .get("agent_path") + .and_then(Value::as_str) + .is_some_and(|value| !value.is_empty()) + || payload + .get("agent_nickname") + .and_then(Value::as_str) + .is_some_and(|value| !value.is_empty()) + || (parent_thread_id.is_some() && thread_id != root_session_id); + CanonicalThreadMeta { + thread_id, + root_session_id, + parent_thread_id, + forked_from_id: string(payload.get("forked_from_id")), + is_subagent, + // Current Codex stores the canonical Agent identity on SessionMeta itself. Older + // rollouts only carried it inside source.subagent., so keep that as a fallback. + agent_path: string( + payload + .get("agent_path") + .or_else(|| detail.and_then(|value| value.get("agent_path"))), + ), + agent_nickname: string( + payload + .get("agent_nickname") + .or_else(|| detail.and_then(|value| value.get("agent_nickname"))), + ), + agent_role: string( + payload + .get("agent_role") + .or_else(|| payload.get("agent_type")) + .or_else(|| detail.and_then(|value| value.get("agent_role"))) + .or_else(|| detail.and_then(|value| value.get("agent_type"))), + ), + agent_depth: detail.and_then(|value| value.get("depth")).and_then(|value| value.as_i64()), + } +} + +/// Harness-injected user turns (environment/permissions/instructions wrappers) that aren't +/// human prose — hidden from the timeline, exactly like Claude's isMeta records. +pub(super) fn is_meta_user_text(t: &str) -> bool { + let t = t.trim_start(); + ["", "", " bool { + let source = t.trim_start(); + let Some(heading) = source.strip_prefix('#') else { return false; }; + let heading = heading.trim_start().to_ascii_lowercase(); + heading.starts_with("agents.md instructions for ") + && heading.contains("") +} + +// Codex serializes a loaded Skill as a synthetic user turn. Keep the snapshot embedded in the +// rollout rather than reading the current SKILL.md from disk: historical sessions must show the +// exact instructions that were loaded at the time. Anchoring the whole envelope leaves quoted +// markup in normal user prose untouched. +pub(super) fn skill_load_block(t: &str) -> Option { + static SKILL_ENVELOPE_RE: std::sync::OnceLock = std::sync::OnceLock::new(); + let re = SKILL_ENVELOPE_RE.get_or_init(|| { + regex::Regex::new( + r"(?is)^\s*\s*(.*?)\s*(.*?)(.*)\s*$", + ) + .unwrap() + }); + let captures = re.captures(t)?; + let name = captures.get(1)?.as_str().trim(); + let path = captures.get(2)?.as_str().trim(); + if name.is_empty() || path.is_empty() { + return None; + } + let snapshot = captures.get(3)?.as_str(); + Some(json!({ + "type": "skill_load", + "name": name, + "path": path, + "snapshot": snapshot, + })) +} + +/// (cwd, canonical thread id) from a Codex head — used to name an imported store copy. +pub fn head_ids(recs: &[Value]) -> (Option, Option) { + for rec in recs { + let (ty, p, _) = split_line(rec); + if ty == "session_meta" { + let cwd = p.get("cwd").and_then(|v| v.as_str()).map(|s| s.to_string()); + let sid = p + // Every subagent in a tree shares session_id. The FIRST SessionMeta.id is the + // unique rollout key; using session_id makes sibling imports collide. + .get("id") + .or_else(|| p.get("thread_id")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + return (cwd, sid); + } + } + (None, None) +} diff --git a/src-tauri/src/codex/roots.rs b/src-tauri/src/codex/roots.rs new file mode 100644 index 0000000..8452062 --- /dev/null +++ b/src-tauri/src/codex/roots.rs @@ -0,0 +1,148 @@ +// Session roots/paths/walk + rollout format sniffing (split from codex.rs). + +use rusqlite::{Connection, OpenFlags, OptionalExtension}; +use serde_json::Value; +use std::fs; +use std::path::{Path, PathBuf}; + +/// The DEFAULT config dir as a history-dir entry string (`~/.codex`), used by the one-time +/// startup migration that adds it to `historyDirs`. Honors CODEX_HOME like the codex CLI. +pub fn codex_label() -> String { + let root = sessions_root(); + let dir = root.parent().unwrap_or(&root); + crate::store::collapse_home(&dir.to_string_lossy()) +} + +fn home() -> PathBuf { + std::env::var("HOME").map(PathBuf::from).unwrap_or_else(|_| PathBuf::from(".")) +} + +/// Codex's DEFAULT sessions tree. Honors CODEX_HOME the way the codex CLI does. Only the +/// auto-add migration keys off this — browsing walks `/sessions` of every configured dir. +pub fn sessions_root() -> PathBuf { + match std::env::var("CODEX_HOME") { + Ok(h) if !h.trim().is_empty() => PathBuf::from(h).join("sessions"), + _ => home().join(".codex").join("sessions"), + } +} + +pub fn root_exists() -> bool { + sessions_root().is_dir() +} + +fn codex_home_for_rollout(file: &Path) -> Option { + file.ancestors() + .find(|dir| { + matches!( + dir.file_name().and_then(|name| name.to_str()), + Some("sessions") | Some("archived_sessions") + ) + }) + .and_then(Path::parent) + .map(Path::to_path_buf) +} + +fn resolve_sqlite_home_path(raw: &str, codex_home: &Path) -> Option { + let raw = raw.trim(); + if raw.is_empty() { + return None; + } + if raw == "~" { + return Some(home()); + } + if let Some(rest) = raw.strip_prefix("~/") { + return Some(home().join(rest)); + } + let path = PathBuf::from(raw); + Some(if path.is_absolute() { path } else { codex_home.join(path) }) +} + +fn configured_sqlite_home(codex_home: &Path) -> Option { + let raw = fs::read_to_string(codex_home.join("config.toml")).ok()?; + let doc = raw.parse::().ok()?; + resolve_sqlite_home_path(doc.get("sqlite_home")?.as_str()?, codex_home) +} + +/// Codex treats the completed state DB's rollout_path as authoritative for a canonical thread id. +/// This is intentionally queried only when ccbud has found duplicate physical candidates, so the +/// normal list walk never opens SQLite per row. A missing/stale/incomplete DB simply means callers +/// fall back to validated metadata + mtime, just as Codex does during scan-and-repair. +pub fn preferred_rollout_path(file: &Path, thread_id: &str) -> Option { + let codex_home = codex_home_for_rollout(file)?; + let sqlite_home = configured_sqlite_home(&codex_home) + .or_else(|| { + std::env::var("CODEX_SQLITE_HOME") + .ok() + .and_then(|value| resolve_sqlite_home_path(&value, &codex_home)) + }) + .unwrap_or(codex_home); + let db = sqlite_home.join("state_5.sqlite"); + if !db.is_file() { + return None; + } + let conn = Connection::open_with_flags( + db, + OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX, + ) + .ok()?; + let status = conn + .query_row( + "SELECT status FROM backfill_state WHERE id = 1", + [], + |row| row.get::<_, String>(0), + ) + .optional() + .ok()??; + if status != "complete" { + return None; + } + let rollout = conn + .query_row( + "SELECT rollout_path FROM threads WHERE id = ?1 AND archived = 0", + [thread_id], + |row| row.get::<_, String>(0), + ) + .optional() + .ok()??; + let rollout = PathBuf::from(rollout); + rollout.is_file().then_some(rollout) +} + +/// Walk every rollout .jsonl under a sessions tree (date-sharded YYYY/MM/DD, but walked +/// generically so a layout change doesn't lose sessions). Depth-capped against cycles. +pub fn walk_sessions(root: &Path, mut cb: F) { + fn walk(dir: &Path, depth: u32, cb: &mut F) { + if depth > 6 { + return; + } + let entries = match fs::read_dir(dir) { + Ok(e) => e, + Err(_) => return, + }; + for ent in entries.flatten() { + let p = ent.path(); + if p.is_dir() { + walk(&p, depth + 1, cb); + } else if p.is_file() && p.extension().and_then(|e| e.to_str()) == Some("jsonl") { + cb(p); + } + } + } + walk(root, 0, &mut cb); +} + +/// Format sniff on parsed records — routes files that LOOK like Codex rollouts (incl. copies +/// imported into the app store, where the path no longer says so). Claude Code records never +/// use these type tags, and old-format bare Codex items lack Claude's `.message` wrapper. +pub fn looks_codex(recs: &[Value]) -> bool { + recs.iter().take(8).any(|r| { + match r.get("type").and_then(|v| v.as_str()) { + Some("session_meta") | Some("turn_context") | Some("event_msg") | Some("compacted") => true, + Some("response_item") => r.get("payload").is_some(), + // old envelope-less rollout: response items at the top level + Some("message") | Some("function_call") | Some("function_call_output") + | Some("reasoning") | Some("local_shell_call") => r.get("message").is_none(), + _ => r.get("record_type").is_some(), + } + }) +} diff --git a/src-tauri/src/codex/tests.rs b/src-tauri/src/codex/tests.rs new file mode 100644 index 0000000..05ef593 --- /dev/null +++ b/src-tauri/src/codex/tests.rs @@ -0,0 +1,171 @@ +use super::*; +use serde_json::{json, Value}; +use std::fs; + +fn line(ts: &str, ty: &str, payload: Value) -> String { + serde_json::to_string(&json!({ "timestamp": ts, "type": ty, "payload": payload })).unwrap() +} + +fn fixture() -> Vec { + let lines = vec![ + line("2026-07-04T07:13:08.965Z", "session_meta", json!({ + "session_id": "019f-abc", "id": "019f-abc", "timestamp": "2026-07-04T07:13:07.386Z", + "cwd": "/tmp/projx", "originator": "codex-tui", "cli_version": "0.142.5", + "git": { "branch": "main" } + })), + line("2026-07-04T07:13:08.967Z", "turn_context", json!({ "cwd": "/tmp/projx", "model": "gpt-5.5" })), + line("2026-07-04T07:13:08.967Z", "response_item", json!({ + "type": "message", "role": "user", + "content": [{ "type": "input_text", "text": "\n/tmp/projx\n" }] + })), + line("2026-07-04T07:13:08.969Z", "response_item", json!({ + "type": "message", "role": "user", "content": [{ "type": "input_text", "text": "fix the bug please" }] + })), + line("2026-07-04T07:13:09.100Z", "event_msg", json!({ "type": "user_message", "message": "fix the bug please" })), + line("2026-07-04T07:13:10.000Z", "response_item", json!({ + "type": "reasoning", "summary": [{ "type": "summary_text", "text": "Looking at the repo" }], "encrypted_content": "xxx" + })), + line("2026-07-04T07:13:11.000Z", "response_item", json!({ + "type": "function_call", "name": "exec_command", + "arguments": "{\"cmd\": \"ls -la\", \"yield_time_ms\": 10000}", "call_id": "call_1" + })), + line("2026-07-04T07:13:12.000Z", "response_item", json!({ + "type": "function_call_output", "call_id": "call_1", + "output": "Chunk ID: x\nWall time: 0.1 seconds\nProcess exited with code 0\nOutput:\n---\na.txt\nb.txt" + })), + line("2026-07-04T07:13:13.000Z", "response_item", json!({ + "type": "function_call", "name": "shell", + "arguments": "{\"command\": [\"bash\", \"-lc\", \"cargo test\"], \"workdir\": \"/tmp/projx\"}", "call_id": "call_2" + })), + line("2026-07-04T07:13:14.000Z", "response_item", json!({ + "type": "function_call_output", "call_id": "call_2", + "output": "{\"output\": \"error: it broke\", \"metadata\": {\"exit_code\": 101, \"duration_seconds\": 1.5}}" + })), + line("2026-07-04T07:13:15.000Z", "response_item", json!({ + "type": "function_call", "name": "update_plan", + "arguments": "{\"plan\": [{\"step\": \"read code\", \"status\": \"completed\"}, {\"step\": \"fix bug\", \"status\": \"in_progress\"}]}", + "call_id": "call_3" + })), + line("2026-07-04T07:13:16.000Z", "response_item", json!({ + "type": "custom_tool_call", "name": "apply_patch", "call_id": "call_4", + "input": "*** Begin Patch\n*** Update File: src/a.rs\n@@\n-old\n+new\n*** End Patch" + })), + line("2026-07-04T07:13:17.000Z", "response_item", json!({ + "type": "message", "role": "assistant", + "content": [{ "type": "output_text", "text": "Done — fixed." }], "phase": "final_answer" + })), + line("2026-07-04T07:13:17.500Z", "event_msg", json!({ + "type": "token_count", + "info": { + "total_token_usage": { "input_tokens": 900, "cached_input_tokens": 600, "output_tokens": 80, "total_tokens": 980 }, + "last_token_usage": { "input_tokens": 900, "cached_input_tokens": 600, "output_tokens": 80, "total_tokens": 980 }, + "model_context_window": 258400 + } + })), + ]; + lines + .iter() + .map(|l| serde_json::from_str::(l).unwrap()) + .collect() +} + +#[test] +fn normalizes_rollout_into_renderer_model() { + let recs = fixture(); + assert!(looks_codex(&recs)); + let n = normalize(&recs); + + assert_eq!(n.session_id.as_deref(), Some("019f-abc")); + assert_eq!(n.cwd.as_deref(), Some("/tmp/projx")); + assert_eq!(n.version.as_deref(), Some("0.142.5")); + assert_eq!(n.git_branch.as_deref(), Some("main")); + assert_eq!(n.model.as_deref(), Some("gpt-5.5")); + + // env-context user turn skipped; real prose, reasoning, 4 tool calls, 2 results, final text + let roles: Vec<&str> = n.messages.iter().map(|m| m["role"].as_str().unwrap()).collect(); + assert_eq!(roles, vec!["user", "assistant", "assistant", "user", "assistant", "user", "assistant", "assistant", "assistant"]); + + let title = crate::history::first_user_text(&n.messages); + assert_eq!(title, "fix the bug please"); + + // exec_command → Bash card with the raw command + let tu1 = &n.messages[2]["content"][0]; + assert_eq!(tu1["type"], "tool_use"); + assert_eq!(tu1["name"], "Bash"); + assert_eq!(tu1["input"]["command"], "ls -la"); + // its ok result pairs by call id and is not an error + let tr1 = &n.messages[3]["content"][0]; + assert_eq!(tr1["tool_use_id"], "call_1"); + assert!(tr1.get("is_error").is_none()); + + // shell argv ["bash","-lc","cargo test"] unwraps; exit_code 101 marks the result as error + let tu2 = &n.messages[4]["content"][0]; + assert_eq!(tu2["input"]["command"], "cargo test"); + let tr2 = &n.messages[5]["content"][0]; + assert_eq!(tr2["is_error"], true); + assert_eq!(tr2["content"], "error: it broke"); + + // update_plan → TodoWrite todos + let tu3 = &n.messages[6]["content"][0]; + assert_eq!(tu3["name"], "TodoWrite"); + assert_eq!(tu3["input"]["todos"][1]["status"], "in_progress"); + + // apply_patch custom tool → ApplyPatch {patch} + let tu4 = &n.messages[7]["content"][0]; + assert_eq!(tu4["name"], "ApplyPatch"); + assert!(tu4["input"]["patch"].as_str().unwrap().contains("*** Update File: src/a.rs")); + + // reasoning became a thinking block + assert_eq!(n.messages[1]["content"][0]["type"], "thinking"); + + // token_count landed on the final assistant text turn and rolled into totals + let last = n.messages.last().unwrap(); + assert_eq!(last["usage"]["inputTokens"], 300); // input − cached + assert_eq!(last["usage"]["cacheRead"], 600); + assert_eq!(n.totals["out"], 80); + assert_eq!(n.totals["turns"], 1); + + // timestamps span the emitted messages + assert_eq!(n.first_ts.as_deref(), Some("2026-07-04T07:13:08.969Z")); + assert_eq!(n.last_ts.as_deref(), Some("2026-07-04T07:13:17.000Z")); +} + +// Machine-data smoke: run explicitly with `cargo test --lib -- --ignored` on a machine that +// has real Codex sessions. Verifies every real rollout sniffs + normalizes + shapes. +#[test] +#[ignore] +fn real_codex_sessions_smoke() { + if !root_exists() { + eprintln!("no ~/.codex/sessions — skipping"); + return; + } + let mut n = 0; + let label = codex_label(); + walk_sessions(&sessions_root(), |p| { + let raw = fs::read_to_string(&p).unwrap_or_default(); + let recs = crate::history::parse_lines(&raw); + assert!(looks_codex(&recs), "not sniffed as codex: {:?}", p); + let norm = normalize(&recs); + assert!(norm.session_id.is_some() || norm.messages.is_empty(), "no session id: {:?}", p); + let sess = session_from_recs(&p.to_string_lossy(), &recs); + assert_eq!(sess["meta"]["assistant"], "Codex"); + let listed = session_meta_from(&p, &recs, &label, &label).unwrap(); + assert_eq!(listed["source"], "codex"); + n += 1; + }); + eprintln!("smoke-checked {} real codex sessions", n); +} + +#[test] +fn turn_aborted_and_web_search_render() { + let recs: Vec = vec![ + serde_json::from_str(&line("2026-01-01T00:00:00Z", "response_item", json!({ + "type": "web_search_call", "id": "ws_1", "action": { "type": "search", "query": "rust serde" } + }))).unwrap(), + serde_json::from_str(&line("2026-01-01T00:00:01Z", "event_msg", json!({ "type": "turn_aborted", "reason": "interrupted" }))).unwrap(), + ]; + let n = normalize(&recs); + assert_eq!(n.messages[0]["content"][0]["name"], "WebSearch"); + assert_eq!(n.messages[0]["content"][0]["input"]["query"], "rust serde"); + assert!(n.messages[1]["content"][0]["text"].as_str().unwrap().starts_with("[Request interrupted")); +} diff --git a/src-tauri/src/codex/tests_more.rs b/src-tauri/src/codex/tests_more.rs new file mode 100644 index 0000000..4d3d036 --- /dev/null +++ b/src-tauri/src/codex/tests_more.rs @@ -0,0 +1,93 @@ +use super::*; +use super::exec::map_exec_script; +use serde_json::json; + +#[test] +fn claude_records_do_not_sniff_as_codex() { + let recs = vec![ + json!({ "type": "user", "message": { "role": "user", "content": "hi" }, "cwd": "/x", "sessionId": "s1" }), + json!({ "type": "assistant", "message": { "role": "assistant", "content": [{ "type": "text", "text": "hello" }] } }), + json!({ "type": "summary", "summary": "greeting" }), + ]; + assert!(!looks_codex(&recs)); +} + +#[test] +fn old_envelope_less_rollout_still_parses() { + let recs = vec![ + json!({ "id": "old-1", "timestamp": "2025-05-01T00:00:00Z", "instructions": "x", "cwd": "/tmp/old" }), + json!({ "type": "message", "role": "user", "content": [{ "type": "input_text", "text": "hello old codex" }] }), + json!({ "type": "message", "role": "assistant", "content": [{ "type": "output_text", "text": "hi" }] }), + ]; + assert!(looks_codex(&recs)); + let n = normalize(&recs); + assert_eq!(n.session_id.as_deref(), Some("old-1")); + assert_eq!(n.cwd.as_deref(), Some("/tmp/old")); + assert_eq!(n.messages.len(), 2); + assert_eq!(crate::history::first_user_text(&n.messages), "hello old codex"); +} + +#[test] +fn maps_code_mode_exec_scripts() { + // canonical single exec_command + print plumbing → Bash card (command + workdir) + let (n, i) = map_exec_script( + "const r = await tools.exec_command({\"cmd\":\"ls -la\",\"workdir\":\"/tmp/p\",\"yield_time_ms\":10000,\"max_output_tokens\":30000});\ntext(r.output);", + ); + assert_eq!(n, "Bash"); + assert_eq!(i["command"], "ls -la"); + assert_eq!(i["description"], "/tmp/p"); + // bare JS object keys (code-mode literal) parse via the quoting retry; braces/quotes + // inside the command string stay opaque + let (n, i) = map_exec_script( + "const r = await tools.exec_command({cmd:\"awk '{print: $1}' a.txt\",workdir:\"/w\"});\ntext(JSON.stringify(r));", + ); + assert_eq!(n, "Bash"); + assert_eq!(i["command"], "awk '{print: $1}' a.txt"); + // SESSION_ID echo tail is still plumbing + let (n, _) = map_exec_script( + "const r = await tools.exec_command({\"cmd\":\"sleep 1\"});\ntext(r.output);\nif (r.session_id) text(`SESSION_ID=${r.session_id}`);", + ); + assert_eq!(n, "Bash"); + // write_stdin / multi-call orchestration keep the script verbatim + let (n, i) = map_exec_script("const r = await tools.write_stdin({\"session_id\":40352,\"chars\":\"\"});\ntext(r.output);"); + assert_eq!(n, "Script"); + assert!(i["code"].as_str().unwrap().contains("write_stdin")); + let (n, _) = map_exec_script( + "const a = await Promise.all([tools.exec_command({\"cmd\":\"x\"}), tools.exec_command({\"cmd\":\"y\"})]);\ntext(a.map(r => r.output).join());", + ); + assert_eq!(n, "Script"); +} + +#[test] +fn shapes_code_mode_block_array_outputs() { + let recs = vec![ + json!({ "type": "custom_tool_call", "name": "exec", "call_id": "c1", + "input": "const r = await tools.exec_command({\"cmd\":\"ls\"});\ntext(r.output);" }), + json!({ "type": "custom_tool_call_output", "call_id": "c1", "output": [ + { "type": "input_text", "text": "Script completed\nWall time 0.1 seconds\nOutput:\n" }, + { "type": "input_text", "text": "a.txt\n" }, + { "type": "input_image", "image_url": "data:image/png;base64,QUJD", "detail": "high" } + ] }), + json!({ "type": "custom_tool_call", "name": "exec", "call_id": "c2", + "input": "const r = await tools.exec_command({\"cmd\":\"boom\"});\ntext(r.output);" }), + json!({ "type": "custom_tool_call_output", "call_id": "c2", "output": [ + { "type": "input_text", "text": "Script failed\nWall time 0.0 seconds\nOutput:\nerr" } + ] }), + ]; + let n = normalize(&recs); + assert_eq!(n.messages.len(), 4); + let tu = &n.messages[0]["content"][0]; + assert_eq!(tu["name"], "Bash"); + assert_eq!(tu["input"]["command"], "ls"); + // text chunks concatenate VERBATIM (no injected separators); screenshot rides along + let tr = &n.messages[1]["content"][0]; + assert_eq!(tr["tool_use_id"], "c1"); + assert_eq!(tr["content"][0]["text"], "Script completed\nWall time 0.1 seconds\nOutput:\na.txt\n"); + assert_eq!(tr["content"][1]["type"], "image"); + assert_eq!(tr["content"][1]["source"]["data"], "QUJD"); + assert!(tr.get("is_error").is_none()); + // "Script failed" header marks the result as an error; plain-text output stays a string + let tr2 = &n.messages[3]["content"][0]; + assert_eq!(tr2["content"].as_str().unwrap(), "Script failed\nWall time 0.0 seconds\nOutput:\nerr"); + assert_eq!(tr2["is_error"], true); +} diff --git a/src-tauri/src/codex/titles.rs b/src-tauri/src/codex/titles.rs new file mode 100644 index 0000000..fef8a4f --- /dev/null +++ b/src-tauri/src/codex/titles.rs @@ -0,0 +1,173 @@ +// User-text joining and title extraction from rollout content (split from codex.rs). + +use serde_json::{json, Value}; +use std::fs; +use std::io::{BufRead, BufReader}; +use std::path::Path; + +use super::records::split_line; + +pub(super) fn joined_text(content: &Value, kinds: &[&str]) -> String { + let arr = match content.as_array() { + Some(a) => a, + None => return content.as_str().unwrap_or("").to_string(), + }; + arr.iter() + .filter(|b| kinds.contains(&b.get("type").and_then(|t| t.as_str()).unwrap_or(""))) + .filter_map(|b| b.get("text").and_then(|t| t.as_str())) + .collect::>() + .join("\n") +} + +// Codex surrounds each real input_image with text-only transport tags. Replace the opening tag +// with its safe display name (`[Image #1]`) and drop the closing tag; image_block still carries +// the actual bitmap to the renderer. +fn image_transport_label(text: &str) -> Option { + let source = text.trim(); + let lower = source.to_ascii_lowercase(); + if !lower.starts_with("') { + return None; + } + let boundary = source.as_bytes().get(6).copied(); + if !matches!(boundary, Some(b'>')) && !boundary.map(|b| b.is_ascii_whitespace()).unwrap_or(false) { + return None; + } + + let mut label = None; + if let Some(pos) = lower.find("name") { + let rest = source[pos + 4..].trim_start(); + if let Some(value) = rest.strip_prefix('=') { + let value = value.trim_start(); + label = if let Some(quote) = value.chars().next().filter(|c| *c == '"' || *c == '\'') { + value[quote.len_utf8()..] + .find(quote) + .map(|end| value[quote.len_utf8()..quote.len_utf8() + end].to_string()) + } else if value.starts_with('[') { + value.find(']').map(|end| value[..=end].to_string()) + } else { + Some(value.split(|c: char| c.is_whitespace() || c == '>').next().unwrap_or("").to_string()) + }; + } + } + Some(label.filter(|s| !s.trim().is_empty()).unwrap_or_else(|| "[Image]".to_string())) +} + +pub(super) fn joined_user_text(content: &Value) -> String { + let arr = match content.as_array() { + Some(a) => a, + None => return content.as_str().unwrap_or("").to_string(), + }; + let has_image = arr + .iter() + .any(|b| b.get("type").and_then(|t| t.as_str()) == Some("input_image")); + arr.iter() + .filter(|b| matches!(b.get("type").and_then(|t| t.as_str()), Some("input_text") | Some("text"))) + .filter_map(|b| { + let text = b.get("text").and_then(|t| t.as_str()).unwrap_or(""); + if has_image { + if text.trim().eq_ignore_ascii_case("") { + return None; + } + if let Some(label) = image_transport_label(text) { + return Some(label); + } + } + if text.is_empty() { None } else { Some(text.to_string()) } + }) + .collect::>() + .join("\n") +} + +fn event_user_display_text(payload: &Value) -> String { + let message = payload.get("message").and_then(|v| v.as_str()).unwrap_or("").trim(); + let image_count = payload.get("images").and_then(|v| v.as_array()).map(Vec::len).unwrap_or(0) + + payload.get("local_images").and_then(|v| v.as_array()).map(Vec::len).unwrap_or(0); + let labels = (1..=image_count) + .map(|i| format!("[Image #{}]", i)) + .collect::>() + .join(" "); + format!("{}{}{}", labels, if !labels.is_empty() && !message.is_empty() { " " } else { "" }, message) + .trim() + .to_string() +} + +fn event_user_title_from_record(rec: &Value) -> String { + let (ty, payload, _) = split_line(rec); + if ty != "event_msg" || payload.get("type").and_then(|v| v.as_str()) != Some("user_message") { + return String::new(); + } + let text = event_user_display_text(payload); + if text.is_empty() { + String::new() + } else { + crate::history::first_user_text(&[json!({ + "role": "user", + "content": [{ "type": "text", "text": text }], + })]) + } +} + +pub(super) fn first_event_user_title(recs: &[Value]) -> String { + recs.iter() + .map(event_user_title_from_record) + .find(|title| !title.is_empty()) + .unwrap_or_default() +} + +fn append_scan_segment(line: &mut Vec, dropping: &mut bool, segment: &[u8], max_line: usize) { + if *dropping || segment.is_empty() { + return; + } + if line.len().saturating_add(segment.len()) > max_line { + line.clear(); + *dropping = true; + } else { + line.extend_from_slice(segment); + } +} + +fn event_user_title_from_line(line: &[u8]) -> String { + let line = line.strip_suffix(b"\r").unwrap_or(line); + serde_json::from_slice::(line) + .map(|rec| event_user_title_from_record(&rec)) + .unwrap_or_default() +} + +// List metadata normally parses only the first 128 KiB. If an image-first response_item is a +// larger single JSON line, stream past it and read the following compact user_message event. +pub(super) fn scan_event_user_title(file: &Path) -> String { + const MAX_SCAN: usize = 64 * 1024 * 1024; + const MAX_LINE: usize = 256 * 1024; + let input = match fs::File::open(file) { + Ok(file) => file, + Err(_) => return String::new(), + }; + let mut reader = BufReader::new(input); + let mut line: Vec = vec![]; + let mut dropping = false; + let mut scanned = 0usize; + while scanned < MAX_SCAN { + let available = match reader.fill_buf() { + Ok(buf) if !buf.is_empty() => buf, + Ok(_) | Err(_) => break, + }; + let take = available.len().min(MAX_SCAN - scanned); + let mut start = 0usize; + for i in 0..take { + if available[i] == b'\n' { + append_scan_segment(&mut line, &mut dropping, &available[start..i], MAX_LINE); + let title = if dropping { String::new() } else { event_user_title_from_line(&line) }; + line.clear(); + dropping = false; + if !title.is_empty() { + return title; + } + start = i + 1; + } + } + append_scan_segment(&mut line, &mut dropping, &available[start..take], MAX_LINE); + reader.consume(take); + scanned = scanned.saturating_add(take); + } + if dropping { String::new() } else { event_user_title_from_line(&line) } +} diff --git a/src-tauri/src/codex/tools.rs b/src-tauri/src/codex/tools.rs new file mode 100644 index 0000000..face610 --- /dev/null +++ b/src-tauri/src/codex/tools.rs @@ -0,0 +1,130 @@ +// Codex tool call -> renderer tool card mapping, and tool output shaping (split from codex.rs). + +use serde_json::{json, Value}; + +use super::exec::exec_text_err; + +/// argv → display command: unwrap the ["bash","-lc", script] convention, else shell-ish join. +pub(super) fn join_argv(cmd: &Value) -> String { + if let Some(s) = cmd.as_str() { + return s.to_string(); + } + let arr = match cmd.as_array() { + Some(a) => a, + None => return String::new(), + }; + let parts: Vec = arr.iter().map(|x| x.as_str().unwrap_or_default().to_string()).collect(); + if parts.len() == 3 + && ["bash", "sh", "zsh", "dash"].contains(&parts[0].as_str()) + && ["-lc", "-c"].contains(&parts[1].as_str()) + { + return parts[2].clone(); + } + parts + .iter() + .map(|p| { + if p.is_empty() || p.chars().any(|c| c.is_whitespace() || c == '"' || c == '\'') { + format!("{:?}", p) // debug-quote args with spaces/quotes + } else { + p.clone() + } + }) + .collect::>() + .join(" ") +} + +/// Codex tool name + parsed arguments → (renderer tool name, renderer input). +pub(super) fn map_tool(name: &str, args: &Value) -> (String, Value) { + let s = |k: &str| args.get(k).and_then(|v| v.as_str()).unwrap_or("").to_string(); + match name { + "shell" | "local_shell" | "container.exec" => { + let mut input = json!({ "command": join_argv(args.get("command").unwrap_or(&Value::Null)) }); + let desc = if !s("justification").is_empty() { s("justification") } else { s("workdir") }; + if !desc.is_empty() { + input["description"] = json!(desc); + } + ("Bash".into(), input) + } + "shell_command" => ("Bash".into(), json!({ "command": s("command") })), + "exec_command" => { + let cmd = if !s("cmd").is_empty() { s("cmd") } else { s("command") }; + ("Bash".into(), json!({ "command": cmd })) + } + "apply_patch" => { + let patch = if !s("input").is_empty() { s("input") } else { s("patch") }; + ("ApplyPatch".into(), json!({ "patch": patch })) + } + "update_plan" => { + let todos: Vec = args + .get("plan") + .and_then(|p| p.as_array()) + .map(|a| { + a.iter() + .map(|st| { + json!({ + "content": st.get("step").and_then(|v| v.as_str()).unwrap_or(""), + "status": st.get("status").and_then(|v| v.as_str()).unwrap_or("pending"), + }) + }) + .collect() + }) + .unwrap_or_default(); + ("TodoWrite".into(), json!({ "todos": todos })) + } + "view_image" => ("Read".into(), json!({ "file_path": s("path") })), + "web_search" => ("WebSearch".into(), json!({ "query": s("query") })), + _ => ( + name.to_string(), + if args.is_object() { args.clone() } else { json!({}) }, + ), + } +} + +/// Tool output payload → (display text, is_error). Unwraps codex's JSON-wrapped shell output +/// ({"output","metadata":{exit_code}}) and reads exec_command's "exited with code N" header. +pub(super) fn shape_output(out: &Value) -> (String, bool) { + // structured payload: { content, success? } + if out.is_object() { + let text = out + .get("content") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + .unwrap_or_else(|| serde_json::to_string_pretty(out).unwrap_or_default()); + let err = out.get("success").and_then(|v| v.as_bool()) == Some(false); + return (text, err); + } + let s = out.as_str().unwrap_or("").to_string(); + if let Ok(v) = serde_json::from_str::(&s) { + if v.is_object() { + if let Some(o) = v.get("output").and_then(|x| x.as_str()) { + let code = v + .get("metadata") + .and_then(|m| m.get("exit_code")) + .and_then(|c| c.as_i64()) + .unwrap_or(0); + return (o.to_string(), code != 0); + } + if let Some(c) = v.get("content").and_then(|x| x.as_str()) { + let err = v.get("success").and_then(|x| x.as_bool()) == Some(false); + return (c.to_string(), err); + } + } + } + // code-mode runner header (older builds wrote it as a plain string): "Exit code: N…" / + // "Script failed…" + if exec_text_err(&s) { + return (s, true); + } + // exec_command header: "…\nProcess exited with code N\n…" near the top + let head: String = s.chars().take(240).collect(); + if let Some(pos) = head.find("exited with code ") { + let digits: String = head[pos + "exited with code ".len()..] + .chars() + .take_while(|c| c.is_ascii_digit()) + .collect(); + if let Ok(code) = digits.parse::() { + return (s, code != 0); + } + } + (s, false) +} diff --git a/src-tauri/src/codexconnect.rs b/src-tauri/src/codexconnect.rs deleted file mode 100644 index bb66119..0000000 --- a/src-tauri/src/codexconnect.rs +++ /dev/null @@ -1,232 +0,0 @@ -// Codex CLI integration — point Codex at the local gateway by injecting a custom model provider -// into ~/.codex/config.toml (CODEX_HOME-aware). Mirrors claude.rs's connect/disconnect+backup, but -// for Codex's TOML config: we add a `[model_providers.ccbud]` block (base_url → gateway, a static -// dev bearer token, requires_openai_auth=false so Codex doesn't demand an sk- prefix) and switch -// `model_provider`/`model` to it. The user's prior model/model_provider are backed up into -// config.codexBackup once; Disconnect restores them and removes our block. Editing is done with -// toml_edit so the user's other settings, comments, and formatting survive untouched. -// -// wire_api = "responses": Codex speaks the OpenAI Responses API to the gateway (Codex has -// deprecated wire_api = "chat" and only supports "responses"), and the gateway translates to -// whatever protocol the ACTIVE provider uses (responses passthrough, responses→chat, or -// responses→messages for an Anthropic provider). Config-path override for tests: -// CCBUD_CODEX_CONFIG. - -#![allow(dead_code)] - -use crate::store; -use serde_json::{json, Value}; -use std::fs; -use std::path::PathBuf; -use toml_edit::{value, DocumentMut, Item, Table}; - -const PROVIDER_ID: &str = "ccbud"; - -fn home() -> PathBuf { - std::env::var("HOME").map(PathBuf::from).unwrap_or_else(|_| PathBuf::from(".")) -} - -pub fn config_path() -> PathBuf { - if let Ok(p) = std::env::var("CCBUD_CODEX_CONFIG") { - if !p.is_empty() { - return PathBuf::from(p); - } - } - match std::env::var("CODEX_HOME") { - Ok(h) if !h.trim().is_empty() => PathBuf::from(h).join("config.toml"), - _ => home().join(".codex").join("config.toml"), - } -} - -/// Whether Codex is installed enough to connect (its config dir or config file exists). We don't -/// require the file to pre-exist — connect creates it — but we do want ~/.codex to be present so we -/// don't spuriously offer Codex to users who don't have it. -pub fn is_available() -> bool { - let p = config_path(); - p.exists() || p.parent().map(|d| d.is_dir()).unwrap_or(false) -} - -fn read_doc() -> DocumentMut { - fs::read_to_string(config_path()) - .ok() - .and_then(|s| s.parse::().ok()) - .unwrap_or_default() -} - -fn write_doc(doc: &DocumentMut) -> std::io::Result<()> { - let p = config_path(); - if let Some(dir) = p.parent() { - let _ = fs::create_dir_all(dir); - } - let tmp = p.with_extension("ccbud.tmp"); - fs::write(&tmp, doc.to_string())?; - fs::rename(&tmp, &p) -} - -fn gateway_base(port: u16) -> String { - format!("http://localhost:{}/v1", port) -} - -pub fn is_connected(port: u16) -> bool { - let doc = read_doc(); - doc.get("model_providers") - .and_then(|mp| mp.as_table()) - .and_then(|t| t.get(PROVIDER_ID)) - .and_then(|p| p.as_table()) - .and_then(|t| t.get("base_url")) - .and_then(|b| b.as_str()) - .map(|b| b == gateway_base(port)) - .unwrap_or(false) -} - -/// Connect Codex to the gateway. `model` is the model Codex will request (routed by the gateway); -/// `token` is the bearer written inline (a local placeholder unless the gateway enforces a token). -pub fn connect(port: u16, token: &str, model: &str) { - let mut doc = read_doc(); - - // Back up the user's prior model/model_provider exactly once (before we overwrite them). - let cfg = store::read_config(); - if cfg.get("codexBackup").map(|v| v.is_null()).unwrap_or(true) { - let prior_model = doc.get("model").and_then(|v| v.as_str()).map(|s| s.to_string()); - let prior_provider = doc.get("model_provider").and_then(|v| v.as_str()).map(|s| s.to_string()); - let prior_effort = doc.get("model_reasoning_effort").and_then(|v| v.as_str()).map(|s| s.to_string()); - let backup = json!({ - "model": prior_model.map(Value::String).unwrap_or(Value::Null), - "model_provider": prior_provider.map(Value::String).unwrap_or(Value::Null), - "model_reasoning_effort": prior_effort.map(Value::String).unwrap_or(Value::Null), - }); - let mut next = cfg.clone(); - next["codexBackup"] = backup; - store::write_config(next); - } - - // Point Codex at our provider. - doc["model_provider"] = value(PROVIDER_ID); - if !model.is_empty() { - doc["model"] = value(model); - } - // Default the thinking level to ultra; the gateway/plugin clamps it to what the - // active provider actually supports (e.g. grok caps at "high"). - doc["model_reasoning_effort"] = value("ultra"); - - // Ensure [model_providers] exists as a real table, then set our block. - if !doc.contains_key("model_providers") { - doc["model_providers"] = Item::Table(Table::new()); - } - let mut block = Table::new(); - block.insert("name", value("CC Buddy")); - block.insert("base_url", value(gateway_base(port))); - block.insert("wire_api", value("responses")); - block.insert("requires_openai_auth", value(false)); - block.insert("experimental_bearer_token", value(token)); - if let Some(mp) = doc["model_providers"].as_table_mut() { - mp.insert(PROVIDER_ID, Item::Table(block)); - } - - let _ = write_doc(&doc); -} - -/// Disconnect Codex: restore the backed-up model/model_provider and remove our provider block. -pub fn disconnect() { - let cfg = store::read_config(); - let backup = cfg.get("codexBackup").cloned().unwrap_or(Value::Null); - let mut doc = read_doc(); - - // Remove our provider block. - if let Some(mp) = doc.get_mut("model_providers").and_then(|v| v.as_table_mut()) { - mp.remove(PROVIDER_ID); - // Drop the whole table if it's now empty so we don't leave `[model_providers]` dangling. - if mp.is_empty() { - doc.as_table_mut().remove("model_providers"); - } - } - - if backup.is_object() { - match backup.get("model_provider").cloned().unwrap_or(Value::Null) { - Value::String(s) => doc["model_provider"] = value(s), - _ => { - doc.as_table_mut().remove("model_provider"); - } - } - match backup.get("model").cloned().unwrap_or(Value::Null) { - Value::String(s) => doc["model"] = value(s), - _ => { - doc.as_table_mut().remove("model"); - } - } - match backup.get("model_reasoning_effort").cloned().unwrap_or(Value::Null) { - Value::String(s) => doc["model_reasoning_effort"] = value(s), - _ => { - doc.as_table_mut().remove("model_reasoning_effort"); - } - } - let mut next = cfg.clone(); - next["codexBackup"] = Value::Null; - store::write_config(next); - } else { - // No backup (connected out-of-band): just drop the pointer we would have set. - if doc.get("model_provider").and_then(|v| v.as_str()) == Some(PROVIDER_ID) { - doc.as_table_mut().remove("model_provider"); - } - } - - let _ = write_doc(&doc); -} - -#[cfg(test)] -mod tests { - use super::*; - - // One test (CCBUD_HOME / CCBUD_CODEX_CONFIG are process-global env, so a single sequential test - // avoids racing other tests on them). - #[test] - fn connect_disconnect_round_trip() { - let dir = std::env::temp_dir().join(format!("ccbud-codexconn-{}", std::process::id())); - let _ = fs::remove_dir_all(&dir); - let _ = fs::create_dir_all(&dir); - std::env::set_var("CCBUD_CODEX_CONFIG", dir.join("config.toml")); - std::env::set_var("CCBUD_HOME", dir.join("ccbud-home")); - - // --- case 1: pre-existing config with a comment + unrelated setting must survive --- - fs::write(config_path(), "# my codex config\nmodel = \"gpt-5\"\nmodel_provider = \"openai\"\napproval_policy = \"on-request\"\n").unwrap(); - connect(4321, "ccbud-local", "z-ai/glm-5.2"); - assert!(is_connected(4321)); - let raw = fs::read_to_string(config_path()).unwrap(); - assert!(raw.contains("# my codex config"), "user comment preserved"); - assert!(raw.contains("approval_policy"), "unrelated setting preserved"); - assert!(raw.contains("[model_providers.ccbud]")); - assert!(raw.contains("name = \"CC Buddy\"")); - assert!(raw.contains("base_url = \"http://localhost:4321/v1\"")); - assert!(raw.contains("wire_api = \"responses\""), "codex only supports the responses wire API"); - assert!(raw.contains("requires_openai_auth = false")); - assert!(raw.contains("experimental_bearer_token = \"ccbud-local\"")); - assert!(raw.contains("model_provider = \"ccbud\"")); - assert!(raw.contains("model = \"z-ai/glm-5.2\"")); - assert!(raw.contains("model_reasoning_effort = \"ultra\""), "thinking level defaulted to ultra"); - - disconnect(); - assert!(!is_connected(4321)); - let raw = fs::read_to_string(config_path()).unwrap(); - assert!(!raw.contains("ccbud"), "our block + pointer gone: {}", raw); - assert!(raw.contains("model = \"gpt-5\""), "prior model restored"); - assert!(!raw.contains("model_reasoning_effort"), "effort removed on disconnect (none prior)"); - assert!(raw.contains("model_provider = \"openai\""), "prior provider restored"); - assert!(raw.contains("approval_policy"), "unrelated setting still there"); - - // --- case 2: no config file at all → connect creates one, disconnect leaves no ccbud --- - let _ = fs::remove_file(config_path()); - { - // clear the backup from case 1 so case 2 records its own (none) - let mut c = store::read_config(); - c["codexBackup"] = Value::Null; - store::write_config(c); - } - connect(8788, "tok", "m1"); - assert!(is_connected(8788)); - disconnect(); - let raw = fs::read_to_string(config_path()).unwrap_or_default(); - assert!(!raw.contains("ccbud")); - - let _ = fs::remove_dir_all(&dir); - } -} diff --git a/src-tauri/src/codexconnect/config.rs b/src-tauri/src/codexconnect/config.rs new file mode 100644 index 0000000..60a18e0 --- /dev/null +++ b/src-tauri/src/codexconnect/config.rs @@ -0,0 +1,54 @@ +// Where Codex's config.toml lives (CODEX_HOME / CCBUD_CODEX_CONFIG aware) and how we read and +// write it. toml_edit keeps the user's other settings, comments and formatting untouched; writes +// go through a tmp+rename so a crash mid-write never leaves a torn config. + +use std::fs; +use std::path::PathBuf; +use toml_edit::DocumentMut; + +pub(super) const PROVIDER_ID: &str = "ccbud"; + +pub(super) fn home() -> PathBuf { + std::env::var("HOME").map(PathBuf::from).unwrap_or_else(|_| PathBuf::from(".")) +} + +pub fn config_path() -> PathBuf { + if let Ok(p) = std::env::var("CCBUD_CODEX_CONFIG") { + if !p.is_empty() { + return PathBuf::from(p); + } + } + match std::env::var("CODEX_HOME") { + Ok(h) if !h.trim().is_empty() => PathBuf::from(h).join("config.toml"), + _ => home().join(".codex").join("config.toml"), + } +} + +/// Whether Codex is installed enough to connect (its config dir or config file exists). We don't +/// require the file to pre-exist — connect creates it — but we do want ~/.codex to be present so we +/// don't spuriously offer Codex to users who don't have it. +pub fn is_available() -> bool { + let p = config_path(); + p.exists() || p.parent().map(|d| d.is_dir()).unwrap_or(false) +} + +pub(super) fn read_doc() -> DocumentMut { + fs::read_to_string(config_path()) + .ok() + .and_then(|s| s.parse::().ok()) + .unwrap_or_default() +} + +pub(super) fn write_doc(doc: &DocumentMut) -> std::io::Result<()> { + let p = config_path(); + if let Some(dir) = p.parent() { + let _ = fs::create_dir_all(dir); + } + let tmp = p.with_extension("ccbud.tmp"); + fs::write(&tmp, doc.to_string())?; + fs::rename(&tmp, &p) +} + +pub(super) fn gateway_base(port: u16) -> String { + format!("http://localhost:{}/v1", port) +} diff --git a/src-tauri/src/codexconnect/connect.rs b/src-tauri/src/codexconnect/connect.rs new file mode 100644 index 0000000..015a462 --- /dev/null +++ b/src-tauri/src/codexconnect/connect.rs @@ -0,0 +1,113 @@ +// Connect / disconnect: inject (and later remove) the `[model_providers.ccbud]` block and the +// model/model_provider pointers, backing the user's prior values up into config.codexBackup once. + +use super::config::{gateway_base, read_doc, write_doc, PROVIDER_ID}; +use crate::store; +use serde_json::{json, Value}; +use toml_edit::{value, Item, Table}; + +pub fn is_connected(port: u16) -> bool { + let doc = read_doc(); + doc.get("model_providers") + .and_then(|mp| mp.as_table()) + .and_then(|t| t.get(PROVIDER_ID)) + .and_then(|p| p.as_table()) + .and_then(|t| t.get("base_url")) + .and_then(|b| b.as_str()) + .map(|b| b == gateway_base(port)) + .unwrap_or(false) +} + +/// Connect Codex to the gateway. `model` is the model Codex will request (routed by the gateway); +/// `token` is the bearer written inline (a local placeholder unless the gateway enforces a token). +pub fn connect(port: u16, token: &str, model: &str) { + let mut doc = read_doc(); + + // Back up the user's prior model/model_provider exactly once (before we overwrite them). + let cfg = store::read_config(); + if cfg.get("codexBackup").map(|v| v.is_null()).unwrap_or(true) { + let prior_model = doc.get("model").and_then(|v| v.as_str()).map(|s| s.to_string()); + let prior_provider = doc.get("model_provider").and_then(|v| v.as_str()).map(|s| s.to_string()); + let prior_effort = doc.get("model_reasoning_effort").and_then(|v| v.as_str()).map(|s| s.to_string()); + let backup = json!({ + "model": prior_model.map(Value::String).unwrap_or(Value::Null), + "model_provider": prior_provider.map(Value::String).unwrap_or(Value::Null), + "model_reasoning_effort": prior_effort.map(Value::String).unwrap_or(Value::Null), + }); + let mut next = cfg.clone(); + next["codexBackup"] = backup; + store::write_config(next); + } + + // Point Codex at our provider. + doc["model_provider"] = value(PROVIDER_ID); + if !model.is_empty() { + doc["model"] = value(model); + } + // Default the thinking level to ultra; the gateway/plugin clamps it to what the + // active provider actually supports (e.g. grok caps at "high"). + doc["model_reasoning_effort"] = value("ultra"); + + // Ensure [model_providers] exists as a real table, then set our block. + if !doc.contains_key("model_providers") { + doc["model_providers"] = Item::Table(Table::new()); + } + let mut block = Table::new(); + block.insert("name", value("CC Buddy")); + block.insert("base_url", value(gateway_base(port))); + block.insert("wire_api", value("responses")); + block.insert("requires_openai_auth", value(false)); + block.insert("experimental_bearer_token", value(token)); + if let Some(mp) = doc["model_providers"].as_table_mut() { + mp.insert(PROVIDER_ID, Item::Table(block)); + } + + let _ = write_doc(&doc); +} + +/// Disconnect Codex: restore the backed-up model/model_provider and remove our provider block. +pub fn disconnect() { + let cfg = store::read_config(); + let backup = cfg.get("codexBackup").cloned().unwrap_or(Value::Null); + let mut doc = read_doc(); + + // Remove our provider block. + if let Some(mp) = doc.get_mut("model_providers").and_then(|v| v.as_table_mut()) { + mp.remove(PROVIDER_ID); + // Drop the whole table if it's now empty so we don't leave `[model_providers]` dangling. + if mp.is_empty() { + doc.as_table_mut().remove("model_providers"); + } + } + + if backup.is_object() { + match backup.get("model_provider").cloned().unwrap_or(Value::Null) { + Value::String(s) => doc["model_provider"] = value(s), + _ => { + doc.as_table_mut().remove("model_provider"); + } + } + match backup.get("model").cloned().unwrap_or(Value::Null) { + Value::String(s) => doc["model"] = value(s), + _ => { + doc.as_table_mut().remove("model"); + } + } + match backup.get("model_reasoning_effort").cloned().unwrap_or(Value::Null) { + Value::String(s) => doc["model_reasoning_effort"] = value(s), + _ => { + doc.as_table_mut().remove("model_reasoning_effort"); + } + } + let mut next = cfg.clone(); + next["codexBackup"] = Value::Null; + store::write_config(next); + } else { + // No backup (connected out-of-band): just drop the pointer we would have set. + if doc.get("model_provider").and_then(|v| v.as_str()) == Some(PROVIDER_ID) { + doc.as_table_mut().remove("model_provider"); + } + } + + let _ = write_doc(&doc); +} diff --git a/src-tauri/src/codexconnect/mod.rs b/src-tauri/src/codexconnect/mod.rs new file mode 100644 index 0000000..65f03e0 --- /dev/null +++ b/src-tauri/src/codexconnect/mod.rs @@ -0,0 +1,22 @@ +// Codex CLI integration — point Codex at the local gateway by injecting a custom model provider +// into ~/.codex/config.toml (CODEX_HOME-aware). Mirrors claude.rs's connect/disconnect+backup, but +// for Codex's TOML config: we add a `[model_providers.ccbud]` block (base_url → gateway, a static +// dev bearer token, requires_openai_auth=false so Codex doesn't demand an sk- prefix) and switch +// `model_provider`/`model` to it. The user's prior model/model_provider are backed up into +// config.codexBackup once; Disconnect restores them and removes our block. Editing is done with +// toml_edit so the user's other settings, comments, and formatting survive untouched. +// +// wire_api = "responses": Codex speaks the OpenAI Responses API to the gateway (Codex has +// deprecated wire_api = "chat" and only supports "responses"), and the gateway translates to +// whatever protocol the ACTIVE provider uses (responses passthrough, responses→chat, or +// responses→messages for an Anthropic provider). Config-path override for tests: +// CCBUD_CODEX_CONFIG. +#![allow(dead_code)] + +mod config; +mod connect; +#[cfg(test)] +mod tests; + +pub use config::is_available; +pub use connect::{connect, disconnect, is_connected}; diff --git a/src-tauri/src/codexconnect/tests.rs b/src-tauri/src/codexconnect/tests.rs new file mode 100644 index 0000000..f4c6001 --- /dev/null +++ b/src-tauri/src/codexconnect/tests.rs @@ -0,0 +1,58 @@ +use super::config::config_path; +use super::{connect, disconnect, is_connected}; +use crate::store; +use serde_json::Value; +use std::fs; + +// One test (CCBUD_HOME / CCBUD_CODEX_CONFIG are process-global env, so a single sequential test +// avoids racing other tests on them). +#[test] +fn connect_disconnect_round_trip() { + let dir = std::env::temp_dir().join(format!("ccbud-codexconn-{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + let _ = fs::create_dir_all(&dir); + std::env::set_var("CCBUD_CODEX_CONFIG", dir.join("config.toml")); + std::env::set_var("CCBUD_HOME", dir.join("ccbud-home")); + + // --- case 1: pre-existing config with a comment + unrelated setting must survive --- + fs::write(config_path(), "# my codex config\nmodel = \"gpt-5\"\nmodel_provider = \"openai\"\napproval_policy = \"on-request\"\n").unwrap(); + connect(4321, "ccbud-local", "z-ai/glm-5.2"); + assert!(is_connected(4321)); + let raw = fs::read_to_string(config_path()).unwrap(); + assert!(raw.contains("# my codex config"), "user comment preserved"); + assert!(raw.contains("approval_policy"), "unrelated setting preserved"); + assert!(raw.contains("[model_providers.ccbud]")); + assert!(raw.contains("name = \"CC Buddy\"")); + assert!(raw.contains("base_url = \"http://localhost:4321/v1\"")); + assert!(raw.contains("wire_api = \"responses\""), "codex only supports the responses wire API"); + assert!(raw.contains("requires_openai_auth = false")); + assert!(raw.contains("experimental_bearer_token = \"ccbud-local\"")); + assert!(raw.contains("model_provider = \"ccbud\"")); + assert!(raw.contains("model = \"z-ai/glm-5.2\"")); + assert!(raw.contains("model_reasoning_effort = \"ultra\""), "thinking level defaulted to ultra"); + + disconnect(); + assert!(!is_connected(4321)); + let raw = fs::read_to_string(config_path()).unwrap(); + assert!(!raw.contains("ccbud"), "our block + pointer gone: {}", raw); + assert!(raw.contains("model = \"gpt-5\""), "prior model restored"); + assert!(!raw.contains("model_reasoning_effort"), "effort removed on disconnect (none prior)"); + assert!(raw.contains("model_provider = \"openai\""), "prior provider restored"); + assert!(raw.contains("approval_policy"), "unrelated setting still there"); + + // --- case 2: no config file at all → connect creates one, disconnect leaves no ccbud --- + let _ = fs::remove_file(config_path()); + { + // clear the backup from case 1 so case 2 records its own (none) + let mut c = store::read_config(); + c["codexBackup"] = Value::Null; + store::write_config(c); + } + connect(8788, "tok", "m1"); + assert!(is_connected(8788)); + disconnect(); + let raw = fs::read_to_string(config_path()).unwrap_or_default(); + assert!(!raw.contains("ccbud")); + + let _ = fs::remove_dir_all(&dir); +} diff --git a/src-tauri/src/commands/autoupdate.rs b/src-tauri/src/commands/autoupdate.rs new file mode 100644 index 0000000..4dfe6e5 --- /dev/null +++ b/src-tauri/src/commands/autoupdate.rs @@ -0,0 +1,153 @@ +// Daily auto-update flow (first time the app becomes visible each day), moved verbatim from +// lib.rs. The AUTO_UPDATE_* bookkeeping statics travel with it. + +use serde_json::{json, Value}; + +use crate::popover::now_ms; +use crate::store; +use crate::tray::config_lang; + +use super::update::{ + run_update_check, run_update_download, FlagGuard, UPDATE_LATEST, UPDATE_STAGED, +}; + +// Daily auto-check bookkeeping: the day (local YYYY-MM-DD) whose auto check already completed +// (in-memory mirror of the on-disk stamp), an in-flight guard, and the last attempt time so a +// failed attempt (offline) is retried on a later visibility change instead of on every focus. +static AUTO_UPDATE_DONE_DAY: std::sync::Mutex> = std::sync::Mutex::new(None); +static AUTO_UPDATE_RUNNING: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); +static AUTO_UPDATE_LAST_TRY_MS: std::sync::atomic::AtomicI64 = std::sync::atomic::AtomicI64::new(0); +const AUTO_UPDATE_RETRY_MS: i64 = 10 * 60 * 1000; + +// ---- daily auto update (first time the app becomes visible each day) ---- +// The stamp lives in its own tiny file (NOT config.json) so the daily writer never races the +// renderer's whole-config round-trips through config_save. +fn auto_update_stamp_file() -> std::path::PathBuf { + store::ccbud_home().join("update-check.json") +} +fn today_local() -> String { + chrono::Local::now().format("%Y-%m-%d").to_string() +} +fn last_auto_update_day() -> String { + std::fs::read_to_string(auto_update_stamp_file()) + .ok() + .and_then(|s| serde_json::from_str::(&s).ok()) + .and_then(|v| v.get("lastAutoCheckDay").and_then(|d| d.as_str()).map(|s| s.to_string())) + .unwrap_or_default() +} +fn mark_auto_update_day(day: &str) { + if let Ok(mut g) = AUTO_UPDATE_DONE_DAY.lock() { + *g = Some(day.to_string()); + } + let _ = std::fs::create_dir_all(store::ccbud_home()); + let _ = std::fs::write( + auto_update_stamp_file(), + serde_json::to_vec(&json!({ "lastAutoCheckDay": day })).unwrap_or_default(), + ); +} + +// Native restart prompt after an auto-downloaded update (localized like tray_labels — the main +// window may be hidden when the popover triggered the check, so this can't live in the renderer). +struct UpdatePromptLabels { + title: &'static str, + body: &'static str, // {v} → new version + restart: &'static str, + later: &'static str, +} +fn update_prompt_labels(lang: &str) -> UpdatePromptLabels { + match lang { + "zh" | "zh-CN" => UpdatePromptLabels { title: "更新已就绪", body: "新版本 {v} 已自动下载完成。是否立即重启以应用新版本?", restart: "立即重启", later: "稍后" }, + "zh-TW" => UpdatePromptLabels { title: "更新已就緒", body: "新版本 {v} 已自動下載完成。要立即重新啟動以套用新版本嗎?", restart: "立即重啟", later: "稍後" }, + "ja" => UpdatePromptLabels { title: "アップデートの準備ができました", body: "新しいバージョン {v} のダウンロードが完了しました。今すぐ再起動して適用しますか?", restart: "今すぐ再起動", later: "後で" }, + "ko" => UpdatePromptLabels { title: "업데이트 준비 완료", body: "새 버전 {v} 다운로드가 완료되었습니다. 지금 다시 시작하여 적용할까요?", restart: "지금 다시 시작", later: "나중에" }, + _ => UpdatePromptLabels { title: "Update ready", body: "Version {v} has been downloaded. Restart now to switch to the new version?", restart: "Restart now", later: "Later" }, + } +} +async fn prompt_restart_to_apply(app: &tauri::AppHandle) { + let version = UPDATE_LATEST + .lock() + .ok() + .and_then(|g| g.as_ref().map(|(v, _)| v.clone())) + .unwrap_or_default(); + let l = update_prompt_labels(&config_lang(&store::read_config())); + // On Linux this shells out to zenity; without it rfd logs an error and returns Cancel, + // degrading to the staged update applying on the next launch (About pane shows "restart"). + let res = rfd::AsyncMessageDialog::new() + .set_level(rfd::MessageLevel::Info) + .set_title(l.title) + .set_description(l.body.replace("{v}", &version)) + .set_buttons(rfd::MessageButtons::OkCancelCustom(l.restart.to_string(), l.later.to_string())) + .show() + .await; + if matches!(&res, rfd::MessageDialogResult::Custom(s) if s == l.restart) { + app.restart(); + } +} + +/// Called from every "app became visible" site (main window focus, popover show, launch). +/// The first such moment each day — with autoUpdate.check on — runs one update check; when an +/// update exists and autoUpdate.autoDownload is on it's downloaded, then the user is asked +/// whether to restart into the new version (declining leaves it staged for the next launch). +/// The day is stamped only after a flow that reached the network succeeds, so an offline +/// launch doesn't burn the day's only attempt — the next visibility (≥10 min later) retries. +pub(crate) fn auto_update_on_visible(app: &tauri::AppHandle) { + let today = today_local(); + if AUTO_UPDATE_DONE_DAY + .lock() + .map(|g| g.as_deref() == Some(today.as_str())) + .unwrap_or(false) + { + return; + } + if last_auto_update_day() == today { + // Stamped by a previous run of this process instance or a crashed one — mirror it. + if let Ok(mut g) = AUTO_UPDATE_DONE_DAY.lock() { + *g = Some(today); + } + return; + } + if now_ms() - AUTO_UPDATE_LAST_TRY_MS.load(std::sync::atomic::Ordering::Relaxed) < AUTO_UPDATE_RETRY_MS { + return; + } + if AUTO_UPDATE_RUNNING.swap(true, std::sync::atomic::Ordering::SeqCst) { + return; + } + let running = FlagGuard(&AUTO_UPDATE_RUNNING); + let au = store::read_config().get("autoUpdate").cloned().unwrap_or_else(|| json!({})); + if !au.get("check").and_then(|v| v.as_bool()).unwrap_or(true) { + return; // `running` drops here and clears the flag + } + AUTO_UPDATE_LAST_TRY_MS.store(now_ms(), std::sync::atomic::Ordering::Relaxed); + let auto_dl = au.get("autoDownload").and_then(|v| v.as_bool()).unwrap_or(true); + let app = app.clone(); + tauri::async_runtime::spawn(async move { + let _running = running; // held until the task ends (cleared even on panic/unwind) + match run_update_check(&app).await { + Ok(None) => mark_auto_update_day(&today), + Ok(Some(_)) => { + let staged = UPDATE_STAGED.lock().map(|g| *g).unwrap_or(false); + if staged { + // Downloaded on an earlier day but never restarted — just re-ask. + mark_auto_update_day(&today); + prompt_restart_to_apply(&app).await; + } else if !auto_dl { + mark_auto_update_day(&today); // surfaced in the About pane only + } else { + match run_update_download(&app).await { + Ok(_) => { + mark_auto_update_day(&today); + if UPDATE_STAGED.lock().map(|g| *g).unwrap_or(false) { + prompt_restart_to_apply(&app).await; + } + } + // A manual download is already in flight — the user took over today's + // update (the About pane drives the rest), so the day is done. + Err(e) if e == "busy" => mark_auto_update_day(&today), + Err(_) => {} // download failed → day left unstamped so a later visibility retries + } + } + } + Err(_) => {} // check failed (offline?) → retry on a later visibility + } + }); +} diff --git a/src-tauri/src/commands/config.rs b/src-tauri/src/commands/config.rs new file mode 100644 index 0000000..0547757 --- /dev/null +++ b/src-tauri/src/commands/config.rs @@ -0,0 +1,157 @@ +// Config + provider CRUD commands, moved verbatim from lib.rs. + +use serde_json::{json, Value}; +use tauri::Emitter; + +use crate::tray::update_tray_title; +use crate::{claude, codexconnect, gateway, store, usage}; + +use super::connect::codex_model; +use super::gateway::full_status; +use super::plugins::PluginState; + +// ---- config / providers (real, store.rs) ---- +#[tauri::command] +pub(crate) fn config_get() -> Value { + store::read_config() +} +/// Last gateway start error (e.g. a bad port the user typed). Surfaced via server:status so the +/// renderer can show the failure banner. Mirrors main.js lastStartError. +pub(super) static LAST_START_ERROR: std::sync::Mutex> = std::sync::Mutex::new(None); +#[tauri::command] +pub(crate) async fn config_save( + app: tauri::AppHandle, + gw: tauri::State<'_, std::sync::Arc>, + cfg: Value, +) -> Result { + let prev = store::read_config(); + let prev_port = prev.get("port").and_then(|v| v.as_u64()).unwrap_or(8788) as u16; + let next_port = cfg + .get("port") + .and_then(|v| v.as_u64()) + .map(|p| p as u16) + .unwrap_or(prev_port); + let was_connected = claude::is_connected(prev_port); + let codex_was_connected = codexconnect::is_connected(prev_port); + let prev_dirs = prev.get("historyDirs").cloned(); + + // If the gateway is running and the port changed, bind the NEW port BEFORE committing so a bad + // port can never lock the user out — roll back to the old port and report on failure. + if next_port != prev_port && gw.current_port().await.is_some() { + gw.stop().await; + if let Err(e) = gw.start(next_port).await { + let _ = gw.start(prev_port).await; + let msg = format!("端口 {} 启动失败:{}", next_port, e); + *LAST_START_ERROR.lock().unwrap() = Some(msg.clone()); + gw.emit("gateway:status", full_status(&gw).await); + return Err(msg); + } + *LAST_START_ERROR.lock().unwrap() = None; + } + + let saved = store::write_config(cfg); + use tauri_plugin_autostart::ManagerExt; + let want = saved.get("openAtLogin").and_then(|v| v.as_bool()).unwrap_or(false); + let mgr = app.autolaunch(); + let _ = if want { mgr.enable() } else { mgr.disable() }; + + // Keep each connected CLI's config in sync if connected (port/token may have changed). + if was_connected || codex_was_connected { + let port = saved.get("port").and_then(|v| v.as_u64()).unwrap_or(8788) as u16; + let token = claude::current_token(&saved); + if was_connected { + claude::connect(port, &token); + } + if codex_was_connected { + codexconnect::connect(port, &token, &codex_model(&saved)); + } + } + + // History dirs changed → invalidate + re-warm the usage cache and notify the renderer. + if saved.get("historyDirs").cloned() != prev_dirs { + usage::invalidate_cache(); + let cfg2 = saved.clone(); + std::thread::spawn(move || usage::warm_cache(&cfg2, "all")); + let _ = app.emit("history:changed", json!({ "files": [] })); + } + + update_tray_title(&app); + gw.emit("gateway:status", full_status(&gw).await); + Ok(saved) +} +#[tauri::command] +pub(crate) fn provider_upsert(p: Value) -> Value { + let mut cfg = store::read_config(); + let mut provider = p; + let pid = provider.get("id").and_then(|v| v.as_str()).map(|s| s.to_string()); + { + let provs = cfg["providers"].as_array_mut().unwrap(); + match pid { + Some(id) if !id.is_empty() => { + if let Some(i) = provs + .iter() + .position(|x| x.get("id").and_then(|v| v.as_str()) == Some(id.as_str())) + { + provs[i] = provider; + } else { + provs.push(provider); + } + } + _ => { + let id = store::gen_id(); + provider + .as_object_mut() + .unwrap() + .insert("id".into(), json!(id.clone())); + provs.push(provider); + if cfg["activeProviderId"].is_null() { + cfg["activeProviderId"] = json!(id); + } + } + } + } + store::write_config(cfg) +} +#[tauri::command] +pub(crate) fn provider_delete(id: String) -> Value { + let mut cfg = store::read_config(); + let kept: Vec = cfg["providers"] + .as_array() + .map(|a| { + a.iter() + .filter(|p| p.get("id").and_then(|v| v.as_str()) != Some(id.as_str())) + .cloned() + .collect() + }) + .unwrap_or_default(); + cfg["providers"] = json!(kept); + if cfg["activeProviderId"].as_str() == Some(id.as_str()) { + cfg["activeProviderId"] = cfg["providers"] + .as_array() + .and_then(|a| a.first()) + .and_then(|p| p.get("id").cloned()) + .unwrap_or(Value::Null); + } + store::write_config(cfg) +} +#[tauri::command] +pub(crate) fn provider_set_active(pm: PluginState<'_>, id: String) -> Result { + let cfg = store::read_config(); + // A plugin-backed service can only be activated while its plugin is running — + // otherwise the gateway would forward to a dead port. The UI localizes this code. + if let Some(p) = cfg + .get("providers") + .and_then(|v| v.as_array()) + .and_then(|arr| arr.iter().find(|p| p.get("id").and_then(|v| v.as_str()) == Some(id.as_str()))) + { + if p.get("backend").and_then(|v| v.as_str()) == Some("plugin") { + let plugin_id = p.get("pluginId").and_then(|v| v.as_str()).unwrap_or(""); + if !pm.is_running(plugin_id) { + return Err("pluginNotRunning".into()); + } + } + } + let mut cfg = store::read_config(); + cfg["activeProviderId"] = json!(id); + Ok(store::write_config(cfg)) +} diff --git a/src-tauri/src/commands/connect.rs b/src-tauri/src/commands/connect.rs new file mode 100644 index 0000000..8ff474d --- /dev/null +++ b/src-tauri/src/commands/connect.rs @@ -0,0 +1,195 @@ +// Coding-CLI connect/disconnect commands and the connectTargets helpers, moved verbatim +// from lib.rs. + +use serde_json::{json, Value}; + +use crate::tray::refresh_tray_menu; +use crate::{claude, codexconnect, gateway, store}; + +use super::config::LAST_START_ERROR; +use super::gateway::full_status; + +// ---- coding CLI connect / replay ---- +/// The literal selected CLIs from config `connectTargets` (subset of {claude, codex}, deduped). +/// Empty is a valid state ("nothing connected") — the hero Connect button substitutes a default. +fn connect_targets(cfg: &Value) -> Vec { + let mut out: Vec = vec![]; + if let Some(a) = cfg.get("connectTargets").and_then(|v| v.as_array()) { + for v in a { + if let Some(s) = v.as_str() { + if (s == "claude" || s == "codex") && !out.iter().any(|x| x == s) { + out.push(s.to_string()); + } + } + } + } + out +} + +/// Plan the safe subset of connections to repair on startup. Older releases could persist the +/// then-default `["claude"]` without the user ever connecting, so selection alone is insufficient: +/// a target's compatibility backup is the proof that CC Buddy previously took ownership of it. +pub(crate) fn startup_reconcile_targets(cfg: &Value) -> Vec { + connect_targets(cfg) + .into_iter() + .filter(|target| { + let backup_key = if target == "claude" { + "claudeBackup" + } else { + "codexBackup" + }; + cfg.get(backup_key).map(Value::is_object).unwrap_or(false) + }) + .collect() +} + +/// Repair only previously managed, still-selected targets. This intentionally has no disconnect +/// branch: startup must not restore/consume an unselected target's compatibility backup. Since each +/// connect call is gated on an existing object backup, it also cannot create a first-time backup. +pub(crate) fn reconcile_connections_on_startup(cfg: &Value) { + let selected = startup_reconcile_targets(cfg); + if selected.is_empty() { + return; + } + let port = cfg.get("port").and_then(|v| v.as_u64()).unwrap_or(8788) as u16; + let token = claude::current_token(cfg); + if selected.iter().any(|target| target == "claude") { + claude::connect(port, &token); + } + if selected.iter().any(|target| target == "codex") { + codexconnect::connect(port, &token, &codex_model(cfg)); + } +} + +/// The legacy one-click Connect command still has a useful default even though startup does not: +/// when no target is selected, choose Claude and persist that now-explicit selection. +pub(crate) fn ensure_hero_connect_target(cfg: &mut Value) -> bool { + if connect_targets(cfg).is_empty() { + cfg["connectTargets"] = json!(["claude"]); + true + } else { + false + } +} + +/// The model written into Codex's config. `gpt-5.4` is a stable model identity understood by the +/// current CLI and enables its normal function/custom tool registry for custom providers. The +/// synthetic `gpt-5.6-sol-pro` identity previously used here selected code-mode metadata and made +/// Codex send an empty Responses `tools` array, so the gateway could never drive an agent turn. +pub(super) fn codex_model(_cfg: &Value) -> String { + "gpt-5.4".to_string() +} + +/// Make each CLI's config file match the selected `connectTargets`: write the selected ones to +/// point at the gateway, restore the rest. PURELY a config-file operation — the gateway service +/// itself is an independent switch (`gatewayEnabled`), never started or stopped from here. +fn apply_connections(cfg: &Value) { + let selected = connect_targets(cfg); + let claude_on = selected.iter().any(|t| t == "claude"); + let codex_on = selected.iter().any(|t| t == "codex"); + let port = cfg.get("port").and_then(|v| v.as_u64()).unwrap_or(8788) as u16; + let token = claude::current_token(cfg); + if claude_on { + claude::connect(port, &token); + } else { + claude::disconnect(); + } + if codex_on { + codexconnect::connect(port, &token, &codex_model(cfg)); + } else { + codexconnect::disconnect(); + } +} + +#[tauri::command] +pub(crate) async fn claude_connect( + app: tauri::AppHandle, + gw: tauri::State<'_, std::sync::Arc>, +) -> Result { + let mut cfg = store::read_config(); + let n = cfg.get("providers").and_then(|v| v.as_array()).map(|a| a.len()).unwrap_or(0); + if n == 0 { + return Ok(json!({ "ok": false, "reason": "noProvider" })); + } + // Hero "一键接入" with nothing selected connects Claude Code by default (and persists it, so the + // toggle reflects it). + if ensure_hero_connect_target(&mut cfg) { + cfg = store::write_config(cfg); + } + apply_connections(&cfg); + let status = full_status(&gw).await; + gw.emit("gateway:status", status); + refresh_tray_menu(&app); + Ok(json!({ "ok": true })) +} +#[tauri::command] +pub(crate) async fn claude_disconnect( + app: tauri::AppHandle, + gw: tauri::State<'_, std::sync::Arc>, +) -> Result { + // Master off: restore BOTH CLIs' config files (idempotent). The gateway service keeps its own + // switch — removing the CLI wiring doesn't stop it. + claude::disconnect(); + codexconnect::disconnect(); + let status = full_status(&gw).await; + gw.emit("gateway:status", status); + refresh_tray_menu(&app); + Ok(json!({ "ok": true })) +} + +/// Independent gateway-service switch: persist `gatewayEnabled` and start/stop the localhost +/// server. CLI config files are untouched — connect/disconnect is a separate, config-only action. +#[tauri::command] +pub(crate) async fn gateway_set_enabled( + app: tauri::AppHandle, + gw: tauri::State<'_, std::sync::Arc>, + on: bool, +) -> Result { + let mut cfg = store::read_config(); + cfg["gatewayEnabled"] = json!(on); + let saved = store::write_config(cfg); + if on { + let port = saved.get("port").and_then(|v| v.as_u64()).unwrap_or(8788) as u16; + if let Err(e) = gw.start(port).await { + let msg = format!("port {} failed: {}", port, e); + *LAST_START_ERROR.lock().unwrap() = Some(msg.clone()); + gw.emit("gateway:status", full_status(&gw).await); + refresh_tray_menu(&app); + return Ok(json!({ "ok": false, "reason": "portFailed", "message": msg })); + } + *LAST_START_ERROR.lock().unwrap() = None; + } else { + gw.stop().await; + } + let status = full_status(&gw).await; + gw.emit("gateway:status", status); + refresh_tray_menu(&app); + Ok(json!({ "ok": true })) +} + +/// Live per-CLI switch: flip one target on/off, persist the selection, and immediately write or +/// restore that CLI's config file. Config-only — the gateway service has its own switch. +#[tauri::command] +pub(crate) async fn set_connect_target( + app: tauri::AppHandle, + gw: tauri::State<'_, std::sync::Arc>, + target: String, + on: bool, +) -> Result { + let mut cfg = store::read_config(); + if on && cfg.get("providers").and_then(|v| v.as_array()).map(|a| a.is_empty()).unwrap_or(true) { + return Ok(json!({ "ok": false, "reason": "noProvider" })); + } + let mut targets = connect_targets(&cfg); + targets.retain(|t| t != &target); + if on && (target == "claude" || target == "codex") { + targets.push(target.clone()); + } + cfg["connectTargets"] = json!(targets); + let saved = store::write_config(cfg); + apply_connections(&saved); + let status = full_status(&gw).await; + gw.emit("gateway:status", status); + refresh_tray_menu(&app); + Ok(json!({ "ok": true })) +} diff --git a/src-tauri/src/commands/fmt_tests.rs b/src-tauri/src/commands/fmt_tests.rs new file mode 100644 index 0000000..1ab23a5 --- /dev/null +++ b/src-tauri/src/commands/fmt_tests.rs @@ -0,0 +1,64 @@ +use super::{ensure_hero_connect_target, format_tokens, startup_reconcile_targets}; +use serde_json::json; + +#[test] +fn startup_reconciliation_requires_the_targets_own_backup() { + assert!(startup_reconcile_targets(&json!({})).is_empty()); + assert!(startup_reconcile_targets(&json!({ + "connectTargets": ["claude"], + "claudeBackup": null + })) + .is_empty()); + assert!(startup_reconcile_targets(&json!({ + "connectTargets": ["codex"], + "codexBackup": "not-a-backup" + })) + .is_empty()); + + assert_eq!( + startup_reconcile_targets(&json!({ + "connectTargets": ["claude", "codex"], + "claudeBackup": { "model": null, "env": {} }, + "codexBackup": null + })), + vec!["claude"] + ); + assert_eq!( + startup_reconcile_targets(&json!({ + "connectTargets": ["claude", "codex"], + "claudeBackup": null, + "codexBackup": { + "model": "gpt-5", + "model_provider": "openai", + "model_reasoning_effort": null + } + })), + vec!["codex"] + ); +} + +#[test] +fn hero_connect_defaults_to_claude_without_overriding_a_selection() { + let mut fresh = json!({ "connectTargets": [] }); + assert!(ensure_hero_connect_target(&mut fresh)); + assert_eq!(fresh["connectTargets"], json!(["claude"])); + + let mut selected = json!({ "connectTargets": ["codex"] }); + assert!(!ensure_hero_connect_target(&mut selected)); + assert_eq!(selected["connectTargets"], json!(["codex"])); +} + +#[test] +fn matches_js_format_tokens() { + assert_eq!(format_tokens(0), "0"); + assert_eq!(format_tokens(999), "999"); + assert_eq!(format_tokens(1000), "1K"); + assert_eq!(format_tokens(1234), "1.2K"); + assert_eq!(format_tokens(9999), "10K"); + assert_eq!(format_tokens(12_345), "12K"); + assert_eq!(format_tokens(1_000_000), "1M"); + assert_eq!(format_tokens(4_900_000), "4.9M"); + assert_eq!(format_tokens(12_000_000), "12M"); + assert_eq!(format_tokens(1_000_000_000), "1B"); + assert_eq!(format_tokens(4_892_112_447), "4.9B"); +} diff --git a/src-tauri/src/commands/gateway.rs b/src-tauri/src/commands/gateway.rs new file mode 100644 index 0000000..50710a8 --- /dev/null +++ b/src-tauri/src/commands/gateway.rs @@ -0,0 +1,105 @@ +// Gateway status / usage / monitor / log commands, moved verbatim from lib.rs. + +use serde_json::{json, Value}; + +use crate::{claude, codexconnect, gateway, store, usage}; + +use super::config::LAST_START_ERROR; + +// ---- server / usage / monitor / logs ---- +pub(crate) async fn full_status(gw: &std::sync::Arc) -> Value { + let mut s = gw.status().await; + let port = gw + .current_port() + .await + .unwrap_or_else(|| store::read_config().get("port").and_then(|v| v.as_u64()).unwrap_or(8788) as u16); + if let Some(o) = s.as_object_mut() { + let claude_on = claude::is_connected(port); + let codex_on = codexconnect::is_connected(port); + // `connected` = any CLI wired to the gateway (drives the tray "已接入" indicator). + o.insert("connected".into(), json!(claude_on || codex_on)); + o.insert("connectedClaude".into(), json!(claude_on)); + o.insert("connectedCodex".into(), json!(codex_on)); + o.insert("codexAvailable".into(), json!(codexconnect::is_available())); + o.insert( + "gatewayEnabled".into(), + json!(store::read_config().get("gatewayEnabled").and_then(|v| v.as_bool()).unwrap_or(true)), + ); + o.insert( + "lastStartError".into(), + LAST_START_ERROR + .lock() + .ok() + .and_then(|g| g.clone()) + .map(Value::String) + .unwrap_or(Value::Null), + ); + o.insert("claudePath".into(), json!(claude::settings_path().to_string_lossy())); + } + s +} +#[tauri::command] +pub(crate) async fn server_status( + gw: tauri::State<'_, std::sync::Arc>, +) -> Result { + Ok(full_status(&gw).await) +} +#[tauri::command] +pub(crate) fn usage_get(range: Option) -> Value { + let t = std::time::Instant::now(); + let cfg = store::read_config(); + // Usage surfaces (popover heatmap/stats, hero) always aggregate EVERY configured dir — the + // conversations-page directory switcher must not silently filter the calendar down to one CLI. + let r = usage::usage_get(&cfg, "all", range.as_deref().unwrap_or("7d")); + eprintln!( + "[TIMING] usage_get(range={}) {}ms", + range.as_deref().unwrap_or("7d"), + t.elapsed().as_millis() + ); + r +} + +/// Compact token count (mirror of usage.js `formatTokens`): 1234→"1.2K", 4.9e9→"4.9B". +pub(crate) fn format_tokens(n: i64) -> String { + let n = n.max(0); + if n < 1000 { + return n.to_string(); + } + let strip = |s: String| s.strip_suffix(".0").map(|p| p.to_string()).unwrap_or(s); + if n < 1_000_000 { + let v = n as f64 / 1e3; + let s = if n < 10_000 { format!("{:.1}", v) } else { format!("{:.0}", v) }; + return format!("{}K", strip(s)); + } + if n < 1_000_000_000 { + let v = n as f64 / 1e6; + let s = if n < 10_000_000 { format!("{:.1}", v) } else { format!("{:.0}", v) }; + return format!("{}M", strip(s)); + } + let v = n as f64 / 1e9; + format!("{}B", strip(format!("{:.1}", v))) +} +#[tauri::command] +pub(crate) async fn monitor_get( + gw: tauri::State<'_, std::sync::Arc>, + id: Value, +) -> Result { + let idn = id.as_i64().or_else(|| id.as_str().and_then(|s| s.parse().ok())).unwrap_or(-1); + Ok(gw.monitor_get(idn).await) +} +#[tauri::command] +pub(crate) async fn monitor_clear( + gw: tauri::State<'_, std::sync::Arc>, +) -> Result { + gw.monitor_clear().await; + Ok(json!(true)) +} +#[tauri::command] +pub(crate) fn logs_get(gw: tauri::State<'_, std::sync::Arc>) -> Value { + gw.logs_snapshot() +} +#[tauri::command] +pub(crate) fn logs_clear(gw: tauri::State<'_, std::sync::Arc>) -> Value { + gw.logs_clear(); + Value::Null +} diff --git a/src-tauri/src/commands/history.rs b/src-tauri/src/commands/history.rs new file mode 100644 index 0000000..8bdf2b2 --- /dev/null +++ b/src-tauri/src/commands/history.rs @@ -0,0 +1,122 @@ +// Conversation-history commands, moved verbatim from lib.rs. + +use serde_json::{json, Value}; +use tauri::Emitter; + +use crate::{history, store}; + +// ---- conversation history ---- +#[tauri::command] +pub(crate) fn history_projects() -> Value { + let cfg = store::read_config(); + let active = cfg.get("historyActive").and_then(|v| v.as_str()).unwrap_or("all").to_string(); + json!(history::list_projects(&cfg, &active)) +} +#[tauri::command] +pub(crate) fn history_list() -> Value { + let cfg = store::read_config(); + let active = cfg.get("historyActive").and_then(|v| v.as_str()).unwrap_or("all").to_string(); + json!(history::list_sessions(&cfg, &active, 400)) +} +#[tauri::command] +pub(crate) fn history_get(file: String) -> Value { + history::get_session(&file) +} +#[tauri::command] +pub(crate) async fn history_search(query: String) -> Result { + let cfg = store::read_config(); + let active = cfg.get("historyActive").and_then(|v| v.as_str()).unwrap_or("all").to_string(); + // Content scan is read/parse heavy — keep it off the IPC thread so the UI stays responsive. + tauri::async_runtime::spawn_blocking(move || json!(history::search_sessions(&cfg, &active, &query, 120))) + .await + .map_err(|e| e.to_string()) +} +#[tauri::command] +pub(crate) fn history_dirs() -> Value { + let cfg = store::read_config(); + let active = cfg.get("historyActive").and_then(|v| v.as_str()).unwrap_or("all").to_string(); + json!({ "dirs": history::dir_stats(&cfg), "active": active }) +} +#[tauri::command] +pub(crate) async fn history_pick_dir() -> Result { + let folder = rfd::AsyncFileDialog::new().set_title("选择工作目录").pick_folder().await; + match folder { + // Return the picked path (home-collapsed to `~/…`) and let the renderer persist it + // via saveConfig, matching the renderer contract. + Some(f) => { + let mut picked = f.path().to_path_buf(); + // If the user drilled into a data subdir (projects/ = Claude, sessions/ = Codex), + // store its parent (the work dir) so both trees are probed correctly. + let name = picked.file_name().and_then(|n| n.to_str()).map(|s| s.to_string()); + if matches!(name.as_deref(), Some("projects") | Some("sessions")) + && !picked.join(name.as_deref().unwrap()).is_dir() + { + if let Some(parent) = picked.parent() { + picked = parent.to_path_buf(); + } + } + let path = store::collapse_home(&picked.to_string_lossy()); + Ok(json!({ "ok": true, "path": path })) + } + None => Ok(json!({ "ok": false, "canceled": true })), + } +} +#[tauri::command] +pub(crate) fn history_set_active(app: tauri::AppHandle, id: String) -> Value { + let mut cfg = store::read_config(); + cfg["historyActive"] = json!(if id.is_empty() { "all".to_string() } else { id }); + let saved = store::write_config(cfg); + let _ = app.emit( + "history:changed", + json!({ "files": [], "active": saved.get("historyActive").cloned().unwrap_or(json!("all")) }), + ); + saved +} +#[tauri::command] +pub(crate) async fn history_import(app: tauri::AppHandle) -> Result { + match rfd::AsyncFileDialog::new().add_filter("对话记录 (.jsonl / .zip)", &["jsonl", "zip"]).set_title("导入对话记录").pick_files().await { + Some(files) => { + let paths: Vec = files.iter().map(|f| f.path().to_string_lossy().to_string()).collect(); + let r = history::import_paths(&paths); + let _ = app.emit("history:changed", json!({ "files": [] })); + Ok(r) + } + None => Ok(json!({ "canceled": true })), + } +} +#[tauri::command] +pub(crate) fn history_import_paths(app: tauri::AppHandle, paths: Value) -> Value { + let list: Vec = paths + .as_array() + .map(|a| a.iter().filter_map(|p| p.as_str().map(|s| s.to_string())).collect()) + .unwrap_or_default(); + let r = history::import_paths(&list); + let _ = app.emit("history:changed", json!({ "files": [] })); + r +} +#[tauri::command] +pub(crate) fn history_remove_import(app: tauri::AppHandle, file: String) -> Value { + let r = history::remove_import(&file); + if r.get("ok").and_then(|v| v.as_bool()).unwrap_or(false) { + let _ = app.emit("history:changed", json!({ "files": [] })); + } + r +} +#[tauri::command] +pub(crate) fn history_set_meta(app: tauri::AppHandle, file: String, patch: Value) -> Value { + let cfg = store::read_config(); + let r = history::set_ccbud(&file, &patch, &cfg); + if r.get("ok").and_then(|v| v.as_bool()).unwrap_or(false) { + let _ = app.emit("history:changed", json!({ "files": [file] })); + } + r +} +#[tauri::command] +pub(crate) fn history_delete_forever(app: tauri::AppHandle, file: String) -> Value { + let cfg = store::read_config(); + let r = history::delete_session_file(&file, &cfg); + if r.get("ok").and_then(|v| v.as_bool()).unwrap_or(false) { + let _ = app.emit("history:changed", json!({ "files": [] })); + } + r +} diff --git a/src-tauri/src/commands/history_export.rs b/src-tauri/src/commands/history_export.rs new file mode 100644 index 0000000..ea3876a --- /dev/null +++ b/src-tauri/src/commands/history_export.rs @@ -0,0 +1,101 @@ +// Conversation export commands (raw .jsonl/.db/.zip and the standalone HTML viewer), moved +// verbatim from lib.rs. + +use serde_json::{json, Value}; + +use crate::{exporthtml, history}; + +use super::util::open_path_native; + +#[tauri::command] +pub(crate) async fn history_export_raw(file: String) -> Result { + let base = exporthtml::export_base_name(&file); + // Antigravity sessions are SQLite DBs (not text) — export the raw bytes as .db so the + // original conversation remains intact. Other foreign sources and Claude/Codex stay + // verbatim text (.jsonl); sessions with subagents keep the existing zip bundle. + let path = std::path::Path::new(&file); + if matches!( + history::foreign_kind(path), + Some(history::Foreign::Antigravity) + ) { + let bytes = std::fs::read(&file).map_err(|e| e.to_string())?; + return match rfd::AsyncFileDialog::new() + .add_filter("SQLite", &["db"]) + .set_file_name(format!("{}.db", base)) + .save_file() + .await + { + Some(d) => { + let p = d.path().to_path_buf(); + std::fs::write(&p, bytes).map_err(|e| e.to_string())?; + Ok(json!({ "canceled": false, "path": p.to_string_lossy(), "bundled": false })) + } + None => Ok(json!({ "canceled": true })), + }; + } + // A session with subagents exports as a .zip bundle (main .jsonl at the top level + subagents/); + // a plain session stays a verbatim .jsonl. import_paths accepts either. + if history::session_has_subagents(&file) { + let bytes = history::export_bundle(&file).map_err(|e| e.to_string())?; + match rfd::AsyncFileDialog::new() + .add_filter("ZIP", &["zip"]) + .set_file_name(format!("{}.zip", base)) + .save_file() + .await + { + Some(d) => { + let p = d.path().to_path_buf(); + std::fs::write(&p, bytes).map_err(|e| e.to_string())?; + Ok(json!({ "canceled": false, "path": p.to_string_lossy(), "bundled": true })) + } + None => Ok(json!({ "canceled": true })), + } + } else { + let data = history::raw_session_bytes(&file).map_err(|e| e.to_string())?; + match rfd::AsyncFileDialog::new() + .add_filter("JSONL", &["jsonl"]) + .set_file_name(format!("{}.jsonl", base)) + .save_file() + .await + { + Some(d) => { + let p = d.path().to_path_buf(); + std::fs::write(&p, data).map_err(|e| e.to_string())?; + Ok(json!({ "canceled": false, "path": p.to_string_lossy(), "bundled": false })) + } + None => Ok(json!({ "canceled": true })), + } + } +} +#[tauri::command] +pub(crate) async fn history_export_html(payload: Value) -> Result { + let file = payload + .get("file") + .and_then(|v| v.as_str()) + .or_else(|| payload.as_str()) + .ok_or("no file")? + .to_string(); + // Build the export data once, then reuse it for both the HTML body and the filename. + let data = exporthtml::build_data(&file); + // An unreadable main transcript surfaces as a command error (renderer reports it) instead of + // silently saving an empty viewer page. + if let Some(error) = data.get("error") { + return Err(error + .get("message") + .and_then(|v| v.as_str()) + .unwrap_or("session read failed") + .to_string()); + } + let html = exporthtml::html_from_data(&data); + let base = exporthtml::export_base_name_from_data(&data); + match rfd::AsyncFileDialog::new().set_file_name(format!("{}.html", base)).save_file().await { + Some(d) => { + let p = d.path().to_path_buf(); + std::fs::write(&p, html).map_err(|e| e.to_string())?; + // Open the freshly-exported viewer in the user's default browser (issue #7). + open_path_native(&p); + Ok(json!({ "canceled": false, "path": p.to_string_lossy() })) + } + None => Ok(json!({ "canceled": true })), + } +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs new file mode 100644 index 0000000..4af43da --- /dev/null +++ b/src-tauri/src/commands/mod.rs @@ -0,0 +1,65 @@ +// The Tauri IPC surface, split by topic. +// +// Every #[tauri::command] the renderer invokes lives in one of the modules below, moved verbatim +// out of lib.rs. Each command is `pub(crate)` and re-exported here so lib.rs can keep listing +// them unqualified in `tauri::generate_handler![…]` via `use commands::*;`. Shared statics +// (LAST_START_ERROR, UPDATE_*, AUTO_UPDATE_*) live with their topic module. + +mod autoupdate; +mod config; +mod connect; +mod gateway; +mod history; +mod history_export; +mod plugins; +mod provider_test; +mod replay; +mod selfcheck; +mod selfcheck_js; +mod update; +mod util; +mod window; +#[cfg(test)] +mod fmt_tests; + +pub(crate) use autoupdate::auto_update_on_visible; +pub(crate) use config::{ + config_get, config_save, provider_delete, provider_set_active, provider_upsert, +}; +pub(crate) use connect::{ + claude_connect, claude_disconnect, gateway_set_enabled, reconcile_connections_on_startup, + set_connect_target, +}; +// Exercised by the #[cfg(test)] module via these re-exports, so a non-test `cargo check` sees +// them as unused — allow that, don't drop the API. +#[allow(unused_imports)] +pub(crate) use connect::{ensure_hero_connect_target, startup_reconcile_targets}; +pub(crate) use gateway::{ + format_tokens, full_status, logs_clear, logs_get, monitor_clear, monitor_get, server_status, + usage_get, +}; +pub(crate) use history::{ + history_delete_forever, history_dirs, history_get, history_import, history_import_paths, + history_list, history_pick_dir, history_projects, history_remove_import, history_search, + history_set_active, history_set_meta, +}; +pub(crate) use history_export::{history_export_html, history_export_raw}; +pub(crate) use plugins::{ + plugin_action, plugin_action_load, plugin_check_update, plugin_install, plugin_install_git, + plugin_list, plugin_open_dir, plugin_set_enabled, plugin_status, plugin_uninstall, + plugin_update, +}; +pub(crate) use provider_test::provider_test; +pub(crate) use replay::{chatgpt_replay, desktop_replay}; +pub(crate) use selfcheck::{ + selfcheck_export, selfcheck_gateway, selfcheck_history, selfcheck_import, selfcheck_popover, + selfcheck_report, selfcheck_routing, +}; +pub(crate) use selfcheck_js::{POPOVER_SELFCHECK_JS, SELFCHECK_JS}; +pub(crate) use update::{ + update_apply, update_check, update_download, update_set_auto, update_state, +}; +pub(crate) use util::{util_copy, util_open_external}; +pub(crate) use window::{ + app_open_main, app_quit, set_dock_visible, window_settings_mode, window_view_min_width, +}; diff --git a/src-tauri/src/commands/plugins.rs b/src-tauri/src/commands/plugins.rs new file mode 100644 index 0000000..e7d5380 --- /dev/null +++ b/src-tauri/src/commands/plugins.rs @@ -0,0 +1,105 @@ +// Plugin (sidecar coding-agent backend) commands, moved verbatim from lib.rs. + +use serde_json::{json, Value}; + +use crate::plugin; + +// ---- plugins (sidecar coding-agent backends, see plugin.rs) ---- +pub(super) type PluginState<'a> = tauri::State<'a, std::sync::Arc>; + +/// List discovered plugins with running + auth status. +#[tauri::command] +pub(crate) async fn plugin_list(pm: PluginState<'_>) -> Result { + Ok(pm.list().await) +} +/// Single plugin status snapshot. +#[tauri::command] +pub(crate) async fn plugin_status(pm: PluginState<'_>, id: String) -> Result { + Ok(pm.status(&id).await) +} +/// Enable (spawn + health-gate + register provider) or disable (stop the process; the service stays until uninstalled) a plugin. +#[tauri::command] +pub(crate) async fn plugin_set_enabled(pm: PluginState<'_>, id: String, enabled: bool) -> Result { + if enabled { + pm.start(&id).await?; + } else { + pm.stop(&id)?; + } + Ok(pm.status(&id).await) +} +/// Run a plugin-declared UI action: forward form `values` to its control plane. +#[tauri::command] +pub(crate) async fn plugin_action(pm: PluginState<'_>, id: String, action: String, values: Value) -> Result { + pm.action(&id, &action, values).await +} +/// Prefill a plugin action form with the plugin's current values. +#[tauri::command] +pub(crate) async fn plugin_action_load(pm: PluginState<'_>, id: String, action: String) -> Result { + pm.action_load(&id, &action).await +} +/// Add a plugin: pick a local folder containing plugin.json and install it. +/// `title` is the localized folder-picker title (supplied by the renderer). +#[tauri::command] +pub(crate) async fn plugin_install(pm: PluginState<'_>, title: Option) -> Result { + let title = title.filter(|t| !t.trim().is_empty()).unwrap_or_else(|| "Select the plugin folder".into()); + let picked = rfd::AsyncFileDialog::new() + .set_title(&title) + .pick_folder() + .await; + let dir = match picked { + Some(f) => f.path().to_path_buf(), + None => return Ok(json!({ "canceled": true })), + }; + let id = pm.install(&dir)?; + Ok(json!({ "ok": true, "id": id })) +} +/// Remove a plugin (the renderer confirms first): stop it, drop its service, delete its files. +#[tauri::command] +pub(crate) async fn plugin_uninstall(pm: PluginState<'_>, id: String) -> Result { + // The confirmation is shown by the renderer (localized confirmDialog) before + // this is called, so we just do the work here. + pm.uninstall(&id)?; + Ok(json!({ "ok": true })) +} +/// Open the plugins folder in the OS file browser. +#[tauri::command] +pub(crate) fn plugin_open_dir() -> bool { + let dir = plugin::plugins_root(); + let _ = std::fs::create_dir_all(&dir); + #[cfg(target_os = "macos")] + { + std::process::Command::new("open").arg(&dir).spawn().is_ok() + } + #[cfg(target_os = "windows")] + { + std::process::Command::new("explorer").arg(&dir).spawn().is_ok() + } + #[cfg(not(any(target_os = "macos", target_os = "windows")))] + { + std::process::Command::new("xdg-open").arg(&dir).spawn().is_ok() + } +} +/// Install a plugin from a git repository (clone + build + install). Runs the +/// blocking git/build work off the async runtime. +#[tauri::command] +pub(crate) async fn plugin_install_git(pm: PluginState<'_>, url: String) -> Result { + let mgr = pm.inner().clone(); + let id = tokio::task::spawn_blocking(move || mgr.install_from_git(&url)) + .await + .map_err(|e| e.to_string())??; + Ok(json!({ "ok": true, "id": id })) +} +/// Check whether a plugin's git source has a newer version. +#[tauri::command] +pub(crate) async fn plugin_check_update(pm: PluginState<'_>, id: String) -> Result { + Ok(pm.check_update(&id).await) +} +/// Update a plugin from its recorded git source (re-clone + build + replace). +#[tauri::command] +pub(crate) async fn plugin_update(pm: PluginState<'_>, id: String) -> Result { + let mgr = pm.inner().clone(); + let id = tokio::task::spawn_blocking(move || mgr.update(&id)) + .await + .map_err(|e| e.to_string())??; + Ok(json!({ "ok": true, "id": id })) +} diff --git a/src-tauri/src/commands/provider_test.rs b/src-tauri/src/commands/provider_test.rs new file mode 100644 index 0000000..7683380 --- /dev/null +++ b/src-tauri/src/commands/provider_test.rs @@ -0,0 +1,142 @@ +// Live provider connection test, moved verbatim from lib.rs. + +use serde_json::{json, Value}; +use tauri::Emitter; + +use crate::store; + +async fn send_provider_probe( + client: &reqwest::Client, + url: &str, + wire: crate::protocol::Wire, + token: &str, + body: &Value, +) -> Result { + let mut request = client + .post(url) + .header("content-type", "application/json") + .header("authorization", format!("Bearer {}", token)); + if wire == crate::protocol::Wire::Anthropic { + request = request.header("anthropic-version", "2023-06-01"); + } + request.json(body).send().await +} + +/// Live connection test: POST a tiny ping to the provider, shaped for its declared wire protocol +/// (Anthropic /messages, OpenAI /chat/completions, or /responses), and report ok/error/timeout. +/// The renderer localizes the result message. +#[tauri::command] +pub(crate) async fn provider_test(app: tauri::AppHandle, p: Value) -> Value { + let base = p.get("baseUrl").and_then(|v| v.as_str()).unwrap_or("").trim(); + if base.is_empty() { + return json!({ "ok": false, "reason": "baseUrlEmpty" }); + } + if !(base.starts_with("http://") || base.starts_with("https://")) { + return json!({ "ok": false, "reason": "baseUrlInvalid" }); + } + // Test against the provider's DECLARED protocol endpoint — an openai-chat provider must be + // pinged at /chat/completions with a Chat body, not the Anthropic /v1/messages default. + let wire = crate::protocol::Wire::from_provider(p.get("protocol").and_then(|v| v.as_str())); + let url = wire.upstream_url(base); + let model = p + .get("defaultModel") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .or_else(|| { + p.get("models") + .and_then(|m| m.as_array()) + .and_then(|a| a.first()) + .and_then(|m| m.get("upstream")) + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + }) + .unwrap_or("claude-3-5-haiku-20241022") + .to_string(); + let token = p.get("authToken").and_then(|v| v.as_str()).unwrap_or("").to_string(); + let insecure = store::read_config() + .get("insecureSkipVerify") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + // Protocol-shaped ping body. + let body = match wire { + crate::protocol::Wire::OpenAiResponses => json!({ "model": model, "max_output_tokens": 16, "input": "ping" }), + crate::protocol::Wire::OpenAiChat => json!({ "model": model, "max_tokens": 16, "messages": [{ "role": "user", "content": "ping" }] }), + crate::protocol::Wire::Anthropic => json!({ "model": model, "max_tokens": 16, "messages": [{ "role": "user", "content": "ping" }] }), + }; + let client = match reqwest::Client::builder() + .danger_accept_invalid_certs(insecure) + .timeout(std::time::Duration::from_secs(30)) + .build() + { + Ok(c) => c, + Err(e) => return json!({ "ok": false, "message": e.to_string() }), + }; + // Auth via Authorization: Bearer only. Sending both authorization and x-api-key trips + // providers that reject having the two auth headers present at once. + let first = send_provider_probe(&client, &url, wire, &token, &body).await; + match first { + Ok(mut r) => { + let mut migrated_base_url: Option = None; + if crate::protocol::should_try_v1_fallback(r.status().as_u16()) { + if let Some(fallback_url) = wire.v1_fallback_url(base) { + if let Ok(candidate) = send_provider_probe(&client, &fallback_url, wire, &token, &body).await { + if candidate.status().is_success() { + r = candidate; + migrated_base_url = Some(format!("{}/v1", base.trim_end_matches('/'))); + } + } + } + } + let status = r.status().as_u16(); + let text = r.text().await.unwrap_or_default(); + let parsed: Option = serde_json::from_str(&text).ok(); + let http_ok = (200..300).contains(&status); + // A well-shaped reply for the tested protocol: Anthropic `type:message`, Chat `choices`, + // Responses `output`/`id`. + let shape_ok = parsed.as_ref().map(|j| match wire { + crate::protocol::Wire::Anthropic => j.get("type").and_then(|v| v.as_str()) == Some("message"), + crate::protocol::Wire::OpenAiChat => j.get("choices").map(|c| c.is_array()).unwrap_or(false), + crate::protocol::Wire::OpenAiResponses => j.get("output").is_some() || j.get("id").is_some(), + }).unwrap_or(false); + if http_ok && shape_ok { + let m = parsed + .as_ref() + .and_then(|j| j.get("model")) + .and_then(|v| v.as_str()) + .unwrap_or(&model); + if let Some(next_base) = migrated_base_url.as_deref() { + if let Some(id) = p.get("id").and_then(Value::as_str) { + if let Some(saved) = store::migrate_provider_base_url_to_v1(id, base) { + let _ = app.emit("config:changed", saved); + return json!({ "ok": true, "status": status, "model": m, "baseUrl": next_base }); + } + } else { + return json!({ "ok": true, "status": status, "model": m, "baseUrl": next_base }); + } + } + return json!({ "ok": true, "status": status, "model": m }); + } + let msg = parsed + .as_ref() + .and_then(|j| j.get("error")) + .and_then(|e| e.get("message")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + .unwrap_or_else(|| { + if !text.is_empty() { + text.chars().take(200).collect() + } else { + format!("HTTP {}", status) + } + }); + json!({ "ok": false, "status": status, "message": msg }) + } + Err(e) => { + if e.is_timeout() { + json!({ "ok": false, "reason": "timeout" }) + } else { + json!({ "ok": false, "message": e.to_string() }) + } + } + } +} diff --git a/src-tauri/src/commands/replay.rs b/src-tauri/src/commands/replay.rs new file mode 100644 index 0000000..4afd917 --- /dev/null +++ b/src-tauri/src/commands/replay.rs @@ -0,0 +1,89 @@ +// Desktop / ChatGPT deep-link replay commands, moved verbatim from lib.rs. + +use serde_json::{json, Value}; + +use crate::history; + +fn pct(s: &str) -> String { + s.bytes() + .map(|b| match b { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => (b as char).to_string(), + _ => format!("%{:02X}", b), + }) + .collect() +} +#[tauri::command] +pub(crate) fn desktop_replay(file: String, prompt: Option) -> Value { + if file.is_empty() { + return json!({ "ok": false, "reason": "noFile" }); + } + if !cfg!(target_os = "macos") { + return json!({ "ok": false, "reason": "unsupported" }); + } + // The full review prompt comes from the renderer's i18n (desktop.replayPrompt) so it stays + // localized; fall back to a minimal default only if the renderer didn't supply one. + let prompt = prompt + .filter(|p| !p.is_empty()) + .unwrap_or_else(|| "请基于这些对话记录在 Claude 桌面版里继续。".to_string()); + // Attach the main session AND every subagent transcript (they live in a separate subagents/ dir), + // each as its own `file=` — the Cowork deep link honors repeated `file=` — so the analysis covers + // subagent runs, not just the main thread. + let mut url = format!("claude://cowork/new?q={}&file={}", pct(&prompt), pct(&file)); + for sub in history::subagent_transcript_paths(&file) { + url.push_str("&file="); + url.push_str(&pct(&sub)); + } + #[cfg(target_os = "macos")] + { + let ok = std::process::Command::new("/usr/bin/open").arg(&url).spawn().is_ok(); + json!({ "ok": ok }) + } + #[cfg(not(target_os = "macos"))] + { + let _ = url; + json!({ "ok": false, "reason": "unsupported" }) + } +} +#[tauri::command] +pub(crate) fn chatgpt_replay(file: String, prompt: Option) -> Value { + if file.is_empty() { + return json!({ "ok": false, "reason": "noFile" }); + } + if !cfg!(target_os = "macos") { + return json!({ "ok": false, "reason": "unsupported" }); + } + // The ChatGPT desktop app (Codex era) keeps the codex:// scheme: codex://new takes + // `prompt` (initial composer text) and `path` (workspace dir). It has no file-attach + // param, so the workspace is pointed at the transcripts' directory and the prompt + // lists the absolute JSONL paths — main session plus every subagent (they live under + // `//subagents/`, inside the same workspace) — for the task to read. + let prompt = prompt + .filter(|p| !p.is_empty()) + .unwrap_or_else(|| "请读取下列 Coding CLI 会话的 JSONL 记录并帮我复盘。".to_string()); + let mut text = prompt; + text.push_str("\n\nTranscripts:\n"); + text.push_str(&file); + for sub in history::subagent_transcript_paths(&file) { + text.push('\n'); + text.push_str(&sub); + } + let mut url = format!("codex://new?prompt={}", pct(&text)); + if let Some(dir) = std::path::Path::new(&file).parent() { + url.push_str("&path="); + url.push_str(&pct(&dir.to_string_lossy())); + } + #[cfg(target_os = "macos")] + { + // `open` exits non-zero when nothing handles the scheme → app not installed. + match std::process::Command::new("/usr/bin/open").arg(&url).status() { + Ok(s) if s.success() => json!({ "ok": true }), + Ok(_) => json!({ "ok": false, "reason": "notInstalled" }), + Err(_) => json!({ "ok": false, "reason": "failed" }), + } + } + #[cfg(not(target_os = "macos"))] + { + let _ = url; + json!({ "ok": false, "reason": "unsupported" }) + } +} diff --git a/src-tauri/src/commands/selfcheck.rs b/src-tauri/src/commands/selfcheck.rs new file mode 100644 index 0000000..39d1ef4 --- /dev/null +++ b/src-tauri/src/commands/selfcheck.rs @@ -0,0 +1,138 @@ +// Debug self-check commands, moved verbatim from lib.rs. + +use serde_json::{json, Value}; +use tauri::Manager; + +use crate::{exporthtml, gateway, history, store}; + +// ---- debug self-check (gated by CCBUD_SELFCHECK env; injected via on_page_load) ---- +#[tauri::command] +pub(crate) fn selfcheck_report(report: Value) { + let line = serde_json::to_string(&report).unwrap_or_default(); + eprintln!("[SELFCHECK] {}", line); + // Also append to a file when CCBUD_SELFCHECK_OUT is set — a GUI-session run + // (open .app via launchd) has no terminal-attached stderr to read. + if let Ok(path) = std::env::var("CCBUD_SELFCHECK_OUT") { + use std::io::Write; + if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(&path) { + let _ = writeln!(f, "{}", line); + } + } +} +#[tauri::command] +pub(crate) fn selfcheck_routing() -> Value { + gateway::routing_selftest() +} +#[tauri::command] +pub(crate) fn selfcheck_history() -> Value { + history::history_selftest(&store::ccbud_home()) +} +#[tauri::command] +pub(crate) fn selfcheck_import() -> Value { + history::import_selftest(&store::ccbud_home()) +} +#[tauri::command] +pub(crate) fn selfcheck_export() -> Value { + let base = store::ccbud_home(); + let _ = history::history_selftest(&base); + let file = base.join("test-claude").join("projects").join("-test-cwd").join("sess1.jsonl"); + let html = exporthtml::build_export_html(&file.to_string_lossy()); + json!({ + "len": html.len(), + "hasConv": html.contains("__CONV__"), + "hasContent": html.contains("hello world from selfcheck"), + "hasSkin": html.contains(""), + "embedded": html.len() > 180000, + "validHtml": html.starts_with(""), + }) +} +#[tauri::command] +pub(crate) fn selfcheck_popover(app: tauri::AppHandle) -> Value { + let pop = match app.get_webview_window("popover") { + Some(p) => p, + None => return json!({ "err": "no popover window" }), + }; + let mon = match pop.current_monitor() { + Ok(Some(m)) => m, + _ => return json!({ "err": "no monitor" }), + }; + let scale = mon.scale_factor(); + let pw = (424.0 * scale) as i32; + let sx = mon.position().x; + let sy = mon.position().y; + let sw = mon.size().width as i32; + let sh = mon.size().height as i32; + // Simulate a tray icon at the top-right of the menu bar, run the same placement + // math as the real tray click, then read back where the window actually lands. + let tray_cx = sx + sw - (12.0 * scale) as i32; + let x = (tray_cx - pw / 2).clamp(sx + 4, sx + sw - pw - 4); + let y = sy + (26.0 * scale) as i32; + // macOS window ops must run on the main thread — the real tray callback already + // does; here we hop onto it explicitly and read back inside the same closure so + // the probe sees the post-move geometry without a cross-thread timing race. + let (tx, rx) = std::sync::mpsc::channel(); + let pop2 = pop.clone(); + let _ = app.run_on_main_thread(move || { + let _ = pop2.show(); + let _ = pop2.set_position(tauri::PhysicalPosition::new(x, y)); + let pos = pop2.outer_position().ok().map(|p| (p.x, p.y)); + let size = pop2.outer_size().ok().map(|s| (s.width as i32, s.height as i32)); + let _ = pop2.hide(); + let _ = tx.send((pos, size)); + }); + let (pos, size) = rx + .recv_timeout(std::time::Duration::from_millis(1500)) + .unwrap_or((None, None)); + let in_screen = match (pos, size) { + (Some((px, py)), Some((sw2, sh2))) => { + px >= sx && py >= sy && (px + sw2) <= (sx + sw + 2) && (py + sh2) <= (sy + sh + 2) + } + _ => false, + }; + json!({ + "scale": scale, + "monitor": [sx, sy, sw, sh], + "computed": [x, y], + "popPos": pos.map(|(a, b)| json!([a, b])), + "popSize": size.map(|(a, b)| json!([a, b])), + "inScreen": in_screen, + }) +} +#[tauri::command] +pub(crate) async fn selfcheck_gateway( + gw: tauri::State<'_, std::sync::Arc>, +) -> Result { + // Mutates config (writes a mock provider) — only ever allowed in a throwaway self-check run. + if std::env::var("CCBUD_SELFCHECK").is_err() { + return Err("selfcheck disabled".into()); + } + let port = gw.current_port().await.unwrap_or(0); + let mut r = gateway::gateway_selftest(port).await; + let sse_ex = gw.monitor_recent().await; // last recorded by gateway_selftest = the SSE exchange + // Exercise HEAD / (mock 404 → gateway fallback 200 → recorded) to verify monitor detail + ms. + let head_status = reqwest::Client::new() + .head(format!("http://127.0.0.1:{}/", port)) + .send() + .await + .map(|x| x.status().as_u16()) + .unwrap_or(0); + tokio::time::sleep(std::time::Duration::from_millis(60)).await; + let head_ex = gw.monitor_recent().await; // now the HEAD exchange + if let Some(o) = r.as_object_mut() { + let req_ok = sse_ex.get("reqBody").and_then(|b| b.get("text")).and_then(|t| t.as_str()).map(|s| !s.is_empty()).unwrap_or(false); + let res_ok = sse_ex.get("resBody").and_then(|b| b.get("text")).and_then(|t| t.as_str()).map(|s| !s.is_empty()).unwrap_or(false); + let redacted = sse_ex.get("reqHeaders").map(|h| h.to_string().contains("已隐藏")).unwrap_or(false); + o.insert("monitorReqBody".into(), json!(req_ok)); + o.insert("monitorResBody".into(), json!(res_ok)); + o.insert("monitorRedacted".into(), json!(redacted)); + o.insert("recordHasMs".into(), json!(sse_ex.get("ms").map(|v| v.is_number()).unwrap_or(false))); + o.insert("headStatus".into(), json!(head_status)); + o.insert( + "headMonitored".into(), + json!(head_ex.get("method").and_then(|m| m.as_str()) == Some("HEAD") + && head_ex.get("reqHeaders").map(|h| h.is_object()).unwrap_or(false) + && head_ex.get("ms").map(|v| v.is_number()).unwrap_or(false)), + ); + } + Ok(r) +} diff --git a/src-tauri/src/commands/selfcheck_js.rs b/src-tauri/src/commands/selfcheck_js.rs new file mode 100644 index 0000000..37b042a --- /dev/null +++ b/src-tauri/src/commands/selfcheck_js.rs @@ -0,0 +1,77 @@ +// The self-check probes injected into the main and popover webviews by run()'s on_page_load +// hook (gated by CCBUD_SELFCHECK). String literals only, moved verbatim from lib.rs. + +pub(crate) const SELFCHECK_JS: &str = r#" +(function(){ + if (window.__ccbud_sc) return; window.__ccbud_sc = 1; + window.__ccbud_errors = []; + window.addEventListener('error', function(e){ try{window.__ccbud_errors.push(String((e&&e.message)||(e&&e.error)||e));}catch(_){} }, true); + window.addEventListener('unhandledrejection', function(e){ try{window.__ccbud_errors.push('promise:'+String((e.reason&&e.reason.message)||e.reason));}catch(_){} }); + function rep(o){ try{ window.__TAURI__.core.invoke('selfcheck_report',{report:o}); }catch(_){} } + setTimeout(async function(){ + var o={}; + try{ + o.hasCcbud=!!window.ccbud; + o.hasTauri=!!(window.__TAURI__&&window.__TAURI__.core); + o.bodyLen=(document.body&&document.body.innerHTML.length)||0; + o.navItems=document.querySelectorAll('.nav-item,[data-view],[data-nav]').length; + o.colorMix=!!(window.CSS&&CSS.supports&&CSS.supports('color','color-mix(in srgb,red,blue)')); + o.highlight=!!(window.CSS&&CSS.highlights); + // store round-trip — self-check runs point CCBUD_HOME at a throwaway dir + try{ + var before=await window.ccbud.getConfig(); + o.provBefore=((before&&before.providers)||[]).length; + var saved=await window.ccbud.upsertProvider({name:'SelfTest',baseUrl:'https://x.test',authToken:'tok',defaultModel:'m1',smallFastModel:'m1',extra:'shouldDrop'}); + o.provAfter=((saved&&saved.providers)||[]).length; + o.savedName=saved&&saved.providers&&saved.providers[0]&&saved.providers[0].name; + o.savedHasId=!!(saved&&saved.providers&&saved.providers[0]&&saved.providers[0].id); + o.savedActiveMatches=!!(saved&&saved.activeProviderId&&saved.providers[0]&&saved.activeProviderId===saved.providers[0].id); + o.droppedExtra=!(saved&&saved.providers&&saved.providers[0]&&('extra' in saved.providers[0])); + var reread=await window.ccbud.getConfig(); + o.rereadProv=((reread&&reread.providers)||[]).length; + }catch(e){ o.storeErr=String(e); } + try{ o.routing=await window.__TAURI__.core.invoke('selfcheck_routing'); }catch(e){ o.routingErr=String(e); } + try{ o.server=await window.ccbud.serverStatus(); }catch(e){ o.serverErr=String(e); } + try{ o.gateway=await window.__TAURI__.core.invoke('selfcheck_gateway'); }catch(e){ o.gatewayErr=String(e); } + try{ + o.histDirs=(await window.ccbud.historyDirs()).dirs.length; + var hl=await window.ccbud.historyList(); + o.histCount=(hl||[]).length; + o.histSample=hl&&hl[0]?{title:String(hl[0].title||'').slice(0,40),project:hl[0].project,hasCwd:!!hl[0].cwd,hasFile:!!hl[0].file}:null; + if(hl&&hl[0]){ var ss=await window.ccbud.historyGet(hl[0].file); o.histMsgs=ss&&ss.messages?ss.messages.length:-1; o.histTotals=ss&&ss.meta?ss.meta.totals:null; } + }catch(e){ o.histErr=String(e); } + try{ var ug=await window.ccbud.usageGet('all'); o.usage={tokens:ug.tokens,requests:ug.requests,fav:ug.favoriteModel,heatmap:(ug.heatmap||[]).length,byModel:(ug.byModel||[]).length,activeDays:ug.activeDays}; }catch(e){ o.usageErr=String(e); } + try{ var cc=await window.ccbud.connect(); var s1=await window.ccbud.serverStatus(); var dd=await window.ccbud.disconnect(); var s2=await window.ccbud.serverStatus(); o.claude={connOk:cc&&cc.ok,connected:s1.connected,discOk:dd&&dd.ok,afterDisc:s2.connected}; }catch(e){ o.claudeErr=String(e); } + try{ o.copyOk=await window.ccbud.copy('selfcheck-clip'); }catch(e){ o.copyErr=String(e); } + try{ o.histMeta=await window.__TAURI__.core.invoke('selfcheck_history'); }catch(e){ o.histMetaErr=String(e); } + try{ o.export=await window.__TAURI__.core.invoke('selfcheck_export'); }catch(e){ o.exportErr=String(e); } + try{ o.import=await window.__TAURI__.core.invoke('selfcheck_import'); }catch(e){ o.importErr=String(e); } + try{ var us=await window.ccbud.updateState(); var sa=await window.ccbud.updateSetAuto({check:false}); o.update={current:us.current,status:us.status,setAutoCheck:sa.check}; }catch(e){ o.updateErr=String(e); } + try{ o.drag={regions:document.querySelectorAll('.drag-region').length,wired:document.querySelectorAll('[data-tauri-drag-region]').length}; }catch(e){ o.dragErr=String(e); } + try{ var cs=getComputedStyle(document.body); o.userSelect=cs.webkitUserSelect||cs.userSelect; }catch(e){} + try{ var ep=document.getElementById('endpoint'); var eb=document.getElementById('exportBlock'); o.epSel=ep?getComputedStyle(ep).webkitUserSelect:'-'; o.ebSel=eb?getComputedStyle(eb).webkitUserSelect:'-'; }catch(e){} + try{ o.popoverPos=await window.__TAURI__.core.invoke('selfcheck_popover'); }catch(e){ o.popoverPosErr=String(e); } + o.errors=window.__ccbud_errors.slice(0,20); + }catch(e){o.fatal=String((e&&e.stack)||e);} + rep(o); + },2200); +})(); +"#; + +pub(crate) const POPOVER_SELFCHECK_JS: &str = r#" +(function(){ + setTimeout(async function(){ + var o={win:"popover"}; + try{ o.hasCcbud=!!window.ccbud; var u=await window.ccbud.usageGet("all"); o.usageTokens=u?u.tokens:"null"; o.heatmapLen=u&&u.heatmap?u.heatmap.length:-1; o.heatmapFilled=u&&u.heatmap?u.heatmap.filter(function(c){return c.level>0;}).length:-1; }catch(e){ o.usageErr=String(e); } + try{ var st=document.getElementById("sTokens"); o.sTokensText=st?st.textContent:"noel"; var hm=document.getElementById("heatmap"); o.heatCells=hm?hm.children.length:-1; }catch(e){} + try{ + o.innerW=window.innerWidth; o.innerH=window.innerHeight; o.scrollH=document.body.scrollHeight; + var st2=document.getElementById("sTokens"); if(st2){var r=st2.getBoundingClientRect(); o.sTokTop=Math.round(r.top); o.sTokVisible=(r.top>=0&&r.bottom<=window.innerHeight);} + var hm2=document.getElementById("heatmap"); if(hm2){var hr=hm2.getBoundingClientRect(); o.hmTop=Math.round(hr.top); o.hmBottom=Math.round(hr.bottom);} + o.bodyBg=getComputedStyle(document.body).backgroundColor; + var root=document.querySelector(".pop-body-root"); o.rootBg=root?getComputedStyle(root).backgroundColor:"noel"; + }catch(e){ o.visErr=String(e); } + try{ window.__TAURI__.core.invoke("selfcheck_report",{report:o}); }catch(_){} + }, 1500); +})(); +"#; diff --git a/src-tauri/src/commands/update.rs b/src-tauri/src/commands/update.rs new file mode 100644 index 0000000..67696e0 --- /dev/null +++ b/src-tauri/src/commands/update.rs @@ -0,0 +1,132 @@ +// In-app update commands and their shared state, moved verbatim from lib.rs. + +use serde_json::{json, Value}; +use tauri::Emitter; + +use crate::store; + +// ---- in-app updates ---- +// In-app update state, mapped to the shape the renderer's about/update pane expects +// (runningVersion / latestVersion / mode / pending). Tauri's updater is in-app full → mode "hot". +pub(super) static UPDATE_LATEST: std::sync::Mutex)>> = + std::sync::Mutex::new(None); +pub(super) static UPDATE_CHECKED: std::sync::Mutex = std::sync::Mutex::new(false); +pub(super) static UPDATE_STAGED: std::sync::Mutex = std::sync::Mutex::new(false); +// A download is in flight (manual or auto) — second caller gets "busy" instead of a duplicate. +pub(super) static UPDATE_DOWNLOADING: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); + +/// Clears an in-flight flag on drop, so a panic/unwind or an early return can never leave +/// UPDATE_DOWNLOADING / AUTO_UPDATE_RUNNING stuck true for the rest of the process. +pub(super) struct FlagGuard(pub(super) &'static std::sync::atomic::AtomicBool); +impl Drop for FlagGuard { + fn drop(&mut self) { + self.0.store(false, std::sync::atomic::Ordering::SeqCst); + } +} + +pub(super) fn build_update_state(app: &tauri::AppHandle) -> Value { + let cfg = store::read_config(); + let current = app.package_info().version.to_string(); + let latest = UPDATE_LATEST.lock().ok().and_then(|g| g.clone()); + let checked = UPDATE_CHECKED.lock().map(|g| *g).unwrap_or(false); + let staged = UPDATE_STAGED.lock().map(|g| *g).unwrap_or(false); + let (latest_v, notes, mode) = match (&latest, checked) { + (Some((v, n)), _) => (json!(v), n.clone().map(Value::String).unwrap_or(Value::Null), "hot"), + (None, true) => (Value::Null, Value::Null, "none"), + (None, false) => (Value::Null, Value::Null, "unknown"), + }; + json!({ + "ok": true, + "runningVersion": current, + "shellVersion": current, + "latestVersion": latest_v, + "mode": mode, + "notes": notes, + "pending": if staged { + json!({ "staged": true, "version": latest.as_ref().map(|(v, _)| v.clone()) }) + } else { + Value::Null + }, + "installMethod": "tauri", + "autoUpdate": cfg.get("autoUpdate").cloned().unwrap_or(json!({ "check": true, "autoDownload": true })), + }) +} +#[tauri::command] +pub(crate) fn update_state(app: tauri::AppHandle) -> Value { + build_update_state(&app) +} +/// Hit the updater endpoint and sync UPDATE_CHECKED/UPDATE_LATEST + the renderer's +/// update:state. Shared by the manual update_check command and the daily auto check. +pub(super) async fn run_update_check(app: &tauri::AppHandle) -> Result, String> { + use tauri_plugin_updater::UpdaterExt; + *UPDATE_CHECKED.lock().unwrap() = true; + let result = match app.updater() { + Ok(updater) => updater.check().await, + Err(e) => Err(e), + }; + match result { + Ok(found) => { + *UPDATE_LATEST.lock().unwrap() = + found.as_ref().map(|u| (u.version.clone(), u.body.clone())); + let _ = app.emit("update:state", build_update_state(app)); + Ok(found) + } + Err(e) => Err(e.to_string()), + } +} +#[tauri::command] +pub(crate) async fn update_check(app: tauri::AppHandle) -> Result { + match run_update_check(&app).await { + Ok(_) => Ok(build_update_state(&app)), + Err(e) => Ok(json!({ + "ok": false, + "error": e, + "runningVersion": app.package_info().version.to_string(), + })), + } +} +/// Download + stage the available update (restart applies it). Shared by the manual +/// update_download command and the daily auto flow; UPDATE_DOWNLOADING dedupes the two. +pub(super) async fn run_update_download(app: &tauri::AppHandle) -> Result { + use tauri_plugin_updater::UpdaterExt; + if UPDATE_DOWNLOADING.swap(true, std::sync::atomic::Ordering::SeqCst) { + return Err("busy".to_string()); + } + let _busy = FlagGuard(&UPDATE_DOWNLOADING); + let updater = app.updater().map_err(|e| e.to_string())?; + match updater.check().await.map_err(|e| e.to_string())? { + Some(u) => { + u.download_and_install(|_chunk, _total| {}, || {}).await.map_err(|e| e.to_string())?; + *UPDATE_STAGED.lock().unwrap() = true; + let st = build_update_state(app); + let _ = app.emit("update:staged", st.clone()); + let _ = app.emit("update:state", st.clone()); + Ok(st) + } + None => Ok(json!({ "ok": true, "mode": "none" })), + } +} +#[tauri::command] +pub(crate) async fn update_download(app: tauri::AppHandle) -> Result { + run_update_download(&app).await +} +#[tauri::command] +pub(crate) fn update_apply(app: tauri::AppHandle) -> Value { + app.restart(); +} +#[tauri::command] +pub(crate) fn update_set_auto(patch: Value) -> Value { + let mut cfg = store::read_config(); + let mut au = cfg.get("autoUpdate").cloned().unwrap_or(json!({ "check": true, "autoDownload": true })); + if let Some(o) = au.as_object_mut() { + if let Some(c) = patch.get("check") { + o.insert("check".into(), c.clone()); + } + if let Some(d) = patch.get("autoDownload") { + o.insert("autoDownload".into(), d.clone()); + } + } + cfg["autoUpdate"] = au.clone(); + store::write_config(cfg); + au +} diff --git a/src-tauri/src/commands/util.rs b/src-tauri/src/commands/util.rs new file mode 100644 index 0000000..7222e07 --- /dev/null +++ b/src-tauri/src/commands/util.rs @@ -0,0 +1,41 @@ +// ---- utilities ---- +#[tauri::command] +pub(crate) fn util_copy(text: String) -> bool { + match arboard::Clipboard::new() { + Ok(mut cb) => cb.set_text(text).is_ok(), + Err(_) => false, + } +} +#[tauri::command] +pub(crate) fn util_open_external(url: String) -> bool { + if !(url.starts_with("http://") || url.starts_with("https://")) { + return false; + } + let spawned = { + #[cfg(target_os = "macos")] + { + std::process::Command::new("open").arg(&url).spawn() + } + #[cfg(target_os = "windows")] + { + std::process::Command::new("cmd").args(["/C", "start", "", &url]).spawn() + } + #[cfg(target_os = "linux")] + { + std::process::Command::new("xdg-open").arg(&url).spawn() + } + }; + spawned.is_ok() +} + +// Open a local file with the OS default handler. Used to pop the freshly-exported HTML viewer in +// the user's browser so they don't have to hunt for it in the filesystem. Best-effort: a spawn +// failure must not fail the export. +pub(super) fn open_path_native(path: &std::path::Path) { + #[cfg(target_os = "macos")] + let _ = std::process::Command::new("open").arg(path).spawn(); + #[cfg(target_os = "windows")] + let _ = std::process::Command::new("cmd").args(["/C", "start", ""]).arg(path).spawn(); + #[cfg(target_os = "linux")] + let _ = std::process::Command::new("xdg-open").arg(path).spawn(); +} diff --git a/src-tauri/src/commands/window.rs b/src-tauri/src/commands/window.rs new file mode 100644 index 0000000..e6a622c --- /dev/null +++ b/src-tauri/src/commands/window.rs @@ -0,0 +1,47 @@ +// Window / app lifecycle commands, moved verbatim from lib.rs. + +use serde_json::Value; +use tauri::Manager; + +// ---- window / app lifecycle ---- +/// macOS Dock icon follows the main window: Regular (Dock shown) while a window is open, +/// Accessory (menu-bar only) when it's closed. The popover floats over fullscreen apps via its +/// NSPanel regardless of this policy, so showing the Dock icon with the main window is safe. +pub(crate) fn set_dock_visible(app: &tauri::AppHandle, visible: bool) { + #[cfg(target_os = "macos")] + { + let app2 = app.clone(); + let _ = app.run_on_main_thread(move || { + let policy = if visible { + tauri::ActivationPolicy::Regular + } else { + tauri::ActivationPolicy::Accessory + }; + let _ = app2.set_activation_policy(policy); + }); + } +} +#[tauri::command] +pub(crate) fn app_open_main(app: tauri::AppHandle) -> Value { + if let Some(win) = app.get_webview_window("main") { + set_dock_visible(&app, true); + let _ = win.show(); + let _ = win.unminimize(); + let _ = win.set_focus(); + } + Value::Null +} +#[tauri::command] +pub(crate) fn app_quit(app: tauri::AppHandle) -> Value { + app.exit(0); + Value::Null +} +#[tauri::command] pub(crate) fn window_settings_mode(on: bool) -> Value { Value::Null } +#[tauri::command] +pub(crate) fn window_view_min_width(app: tauri::AppHandle, w: i64) -> Value { + if let Some(win) = app.get_webview_window("main") { + let min_w = std::cmp::max(600, if w > 0 { w } else { 900 }) as f64; + let _ = win.set_min_size(Some(tauri::Size::Logical(tauri::LogicalSize::new(min_w, 600.0)))); + } + Value::Null +} diff --git a/src-tauri/src/copilot.rs b/src-tauri/src/copilot.rs deleted file mode 100644 index e45151e..0000000 --- a/src-tauri/src/copilot.rs +++ /dev/null @@ -1,437 +0,0 @@ -// GitHub Copilot CLI session support — reads Copilot's on-disk session event logs and -// normalizes them into the SAME session/message shape the renderer consumes (history::Norm). -// -// Two layouts under `~/.copilot/session-state/`: -// new (≥1.0): /events.jsonl + sibling workspace.yaml (id/cwd/name/branch/timestamps, -// flat "key: value" lines — parsed without a YAML dependency) -// old: .jsonl flat files (same event schema; early builds carry no cwd at all, -// so those sessions group under the unknown-project bucket) -// -// An event line is `{type, data, id, timestamp, parentId}`. Conversation content: -// session.start → cwd/session id/version (data.context.cwd on newer builds) -// session.model_change → model (data.newModel) -// user.message → user text (data.content) -// assistant.message → assistant text + tool_use blocks (data.content, -// data.toolRequests[{toolCallId,name,arguments}], data.model) -// tool.execution_complete → tool_result (data.toolCallId, data.success, data.result.content) -// Everything else (session.info, system.*, turn markers, tool.execution_start) is harness -// plumbing and skipped — tool arguments already ride the assistant.message request. -// -// Title/tags/soft-delete live in the shared foreign-CLI sidecar (~/.ccbud/agent-meta.json) -// keyed `copilot:` — the files belong to another tool and are never rewritten. - -#![allow(dead_code)] - -use crate::history::Norm; -use serde_json::{json, Value}; -use std::fs; -use std::path::{Path, PathBuf}; - -fn home() -> PathBuf { - std::env::var("HOME").map(PathBuf::from).unwrap_or_else(|_| PathBuf::from(".")) -} - -/// Copilot's config dir as a history-dir entry string (`~/.copilot`). -pub fn default_root() -> PathBuf { - home().join(".copilot") -} - -pub fn copilot_label() -> String { - crate::store::collapse_home(&default_root().to_string_lossy()) -} - -pub fn root_exists() -> bool { - default_root().join("session-state").is_dir() -} - -/// Walk every session log under a `session-state/` tree: flat `.jsonl` (old) and -/// `/events.jsonl` (new). Dirs without an events.jsonl (created-but-unused sessions, -/// checkpoint-only remnants) hold no conversation and are skipped. -pub fn walk(state_dir: &Path, cb: &mut F) { - let entries = match fs::read_dir(state_dir) { - Ok(e) => e, - Err(_) => return, - }; - for ent in entries.flatten() { - let p = ent.path(); - if p.is_file() && p.extension().and_then(|e| e.to_str()) == Some("jsonl") { - cb(p); - } else if p.is_dir() { - let events = p.join("events.jsonl"); - if events.is_file() { - cb(events); - } - } - } -} - -/// Container-shape test for detail/edit routing: a .jsonl directly in `session-state/`, or an -/// `events.jsonl` whose grandparent is `session-state/`. -pub fn looks_copilot_path(file: &Path) -> bool { - let parent_named = |p: &Path, name: &str| { - p.file_name().and_then(|n| n.to_str()).map(|n| n == name).unwrap_or(false) - }; - match file.file_name().and_then(|n| n.to_str()) { - Some("events.jsonl") => file - .parent() - .and_then(|d| d.parent()) - .map(|gp| parent_named(gp, "session-state")) - .unwrap_or(false), - Some(n) if n.ends_with(".jsonl") => { - file.parent().map(|d| parent_named(d, "session-state")).unwrap_or(false) - } - _ => false, - } -} - -/// The session uuid — the flat file's stem, or the events.jsonl dir name. -fn session_uuid(file: &Path) -> String { - if file.file_name().and_then(|n| n.to_str()) == Some("events.jsonl") { - file.parent() - .and_then(|d| d.file_name()) - .map(|n| n.to_string_lossy().into_owned()) - .unwrap_or_default() - } else { - file.file_stem().and_then(|s| s.to_str()).unwrap_or("").to_string() - } -} - -fn sidecar_key(file: &Path) -> String { - format!("copilot:{}", session_uuid(file)) -} - -fn sidecar_meta(file: &Path) -> (Option, Vec, bool) { - crate::sidecar::meta(&crate::sidecar::agent_file(), &sidecar_key(file)) -} - -pub fn is_deleted(file: &Path) -> bool { - sidecar_meta(file).2 -} - -pub fn set_meta(file: &str, patch: &Value) -> Value { - let key = sidecar_key(Path::new(file)); - if key == "copilot:" { - return json!({ "ok": false, "reason": "empty" }); - } - crate::sidecar::set_meta(&crate::sidecar::agent_file(), &key, patch) -} - -/// Sibling workspace.yaml of an events.jsonl, parsed as flat `key: value` lines (the file is -/// machine-written and flat; no YAML dependency needed). None for old flat sessions. -fn workspace_yaml(file: &Path) -> Option> { - if file.file_name().and_then(|n| n.to_str()) != Some("events.jsonl") { - return None; - } - let text = fs::read_to_string(file.parent()?.join("workspace.yaml")).ok()?; - let mut map = serde_json::Map::new(); - for line in text.lines() { - if let Some((k, v)) = line.split_once(':') { - let (k, v) = (k.trim(), v.trim()); - if !k.is_empty() && !k.starts_with('#') && !v.is_empty() { - map.insert(k.to_string(), json!(v.trim_matches('"').trim_matches('\''))); - } - } - } - Some(map) -} - -/// Copilot tool name + arguments (already an object) → (renderer tool name, renderer input). -fn map_tool(name: &str, args: &Value) -> (String, Value) { - let s = |k: &str| args.get(k).and_then(|v| v.as_str()).unwrap_or("").to_string(); - let keep = |v: &Value| if v.is_object() { v.clone() } else { json!({}) }; - match name { - "bash" => { - let mut input = json!({ "command": s("command") }); - if !s("description").is_empty() { - input["description"] = json!(s("description")); - } - ("Bash".into(), input) - } - "view" => ("Read".into(), json!({ "file_path": s("path") })), - "edit" | "str_replace" => ( - "Edit".into(), - json!({ "file_path": s("path"), "old_string": s("old_str"), "new_string": s("new_str") }), - ), - "create" => ("Write".into(), json!({ "file_path": s("path"), "content": s("file_text") })), - "rg" => { - let mut input = json!({ "pattern": s("pattern") }); - if let Some(p) = args.get("paths") { - input["path"] = if p.is_array() { - json!(p.as_array().unwrap().iter().filter_map(|x| x.as_str()).collect::>().join(" ")) - } else { - p.clone() - }; - } - if !s("glob").is_empty() { - input["glob"] = json!(s("glob")); - } - ("Grep".into(), input) - } - "glob" => ("Glob".into(), json!({ "pattern": s("pattern"), "path": s("paths") })), - "apply_patch" => ("ApplyPatch".into(), json!({ "patch": s("str") })), - _ => (name.to_string(), keep(args)), - } -} - -/// Normalize parsed event records into the renderer's message model. -pub fn normalize(recs: &[Value]) -> Norm { - let mut n = Norm::default(); - for rec in recs { - let ty = rec.get("type").and_then(|v| v.as_str()).unwrap_or(""); - let data = rec.get("data").cloned().unwrap_or(Value::Null); - let ts = rec.get("timestamp").and_then(|v| v.as_str()); - let with_ts = |mut m: Value| { - if let Some(t) = ts { - m["ts"] = json!(t); - } - m - }; - match ty { - "session.start" => { - if n.session_id.is_none() { - n.session_id = data.get("sessionId").and_then(|v| v.as_str()).map(|s| s.to_string()); - } - if n.version.is_none() { - n.version = data.get("copilotVersion").and_then(|v| v.as_str()).map(|s| s.to_string()); - } - if n.cwd.is_none() { - n.cwd = data - .get("context") - .and_then(|c| c.get("cwd")) - .and_then(|v| v.as_str()) - .map(|s| s.to_string()); - } - if n.git_branch.is_none() { - n.git_branch = data - .get("context") - .and_then(|c| c.get("branch")) - .and_then(|v| v.as_str()) - .map(|s| s.to_string()); - } - } - "session.model_change" => { - if let Some(m) = data.get("newModel").and_then(|v| v.as_str()) { - n.model = Some(m.to_string()); - } - } - "user.message" => { - let text = data.get("content").and_then(|v| v.as_str()).unwrap_or(""); - if !text.trim().is_empty() { - n.messages - .push(with_ts(json!({ "role": "user", "content": [{ "type": "text", "text": text }] }))); - } - } - "assistant.message" => { - if let Some(m) = data.get("model").and_then(|v| v.as_str()) { - n.model = Some(m.to_string()); - } - let mut blocks: Vec = vec![]; - let text = data.get("content").and_then(|v| v.as_str()).unwrap_or(""); - if !text.trim().is_empty() { - blocks.push(json!({ "type": "text", "text": text })); - } - if let Some(calls) = data.get("toolRequests").and_then(|c| c.as_array()) { - for call in calls { - let name = call.get("name").and_then(|v| v.as_str()).unwrap_or("tool"); - let args = call.get("arguments").cloned().unwrap_or(json!({})); - let (tname, input) = map_tool(name, &args); - let id = call.get("toolCallId").and_then(|v| v.as_str()).unwrap_or(""); - blocks.push(json!({ "type": "tool_use", "id": id, "name": tname, "input": input })); - } - } - if !blocks.is_empty() { - let mut m = json!({ "role": "assistant", "content": blocks }); - if let Some(md) = &n.model { - m["modelActual"] = json!(md); - } - n.messages.push(with_ts(m)); - } - } - "tool.execution_complete" => { - let id = data.get("toolCallId").and_then(|v| v.as_str()).unwrap_or(""); - let text = data - .get("result") - .and_then(|r| r.get("content")) - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(); - let mut tr = json!({ "type": "tool_result", "tool_use_id": id, "content": text }); - if data.get("success").and_then(|v| v.as_bool()) == Some(false) { - tr["is_error"] = json!(true); - } - n.messages.push(with_ts(json!({ "role": "user", "content": [tr] }))); - } - _ => {} // session.info / system.* / turn markers / execution_start: harness plumbing - } - } - n.first_ts = n.messages.first().and_then(|m| m.get("ts")).and_then(|v| v.as_str()).map(|s| s.to_string()); - n.last_ts = n.messages.last().and_then(|m| m.get("ts")).and_then(|v| v.as_str()).map(|s| s.to_string()); - n -} - -/// List-row meta: workspace.yaml when present (new layout — has copilot's own session name), -/// else the event head (old flat layout). -pub fn session_meta_from(file: &Path, recs: &[Value], dir_id: &str, dir_label: &str) -> Option { - let meta = fs::metadata(file).ok()?; - let ws = workspace_yaml(file); - let n = normalize(recs); - let uuid = session_uuid(file); - let (cc_title, cc_tags, cc_deleted) = sidecar_meta(file); - let ws_str = |k: &str| { - ws.as_ref() - .and_then(|m| m.get(k)) - .and_then(|v| v.as_str()) - .map(|s| s.to_string()) - .filter(|s| !s.is_empty()) - }; - let auto_title = ws_str("name").unwrap_or_else(|| crate::history::first_user_text(&n.messages)); - let cwd = ws_str("cwd").or_else(|| n.cwd.clone()); - let created = ws_str("created_at") - .or_else(|| n.first_ts.clone()) - .and_then(|s| chrono::DateTime::parse_from_rfc3339(&s).ok()) - .map(|d| d.timestamp_millis() as f64) - .unwrap_or_else(|| crate::history::created_ms(file)); - let mt = meta - .modified() - .ok() - .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) - .map(|d| d.as_millis() as f64) - .unwrap_or(0.0); - Some(json!({ - "id": format!("copilot:{}", uuid), - "file": file.to_string_lossy(), - "source": "copilot", - "dirId": dir_id, - "dirLabel": dir_label, - "sessionId": n.session_id.clone().unwrap_or_else(|| uuid.clone()), - "cwd": cwd.clone(), - "project": cwd.as_deref().map(crate::history::base_name).unwrap_or_default(), - "gitBranch": ws_str("branch").or_else(|| n.git_branch.clone()).map(Value::from).unwrap_or(Value::Null), - "title": cc_title.clone().unwrap_or_else(|| auto_title.clone()), - "autoTitle": auto_title, - "tags": cc_tags, - "model": n.model, - "isSubagent": false, - "imported": false, - "deleted": cc_deleted, - "createdAt": created, - "lastActivity": mt, - "sizeKB": (meta.len() as f64 / 1024.0).round() as i64, - })) -} - -/// Full-detail shape (history.rs get_session routes here). -pub fn session_from_recs(file: &str, recs: &[Value]) -> Value { - let path = Path::new(file); - let n = normalize(recs); - let ws = workspace_yaml(path); - let (cc_title, cc_tags, cc_deleted) = sidecar_meta(path); - let ws_name = ws - .as_ref() - .and_then(|m| m.get("name")) - .and_then(|v| v.as_str()) - .map(|s| s.to_string()) - .filter(|s| !s.is_empty()); - let auto_title = ws_name.unwrap_or_else(|| crate::history::first_user_text(&n.messages)); - let uuid = session_uuid(path); - let cwd = ws - .as_ref() - .and_then(|m| m.get("cwd")) - .and_then(|v| v.as_str()) - .map(|s| s.to_string()) - .or_else(|| n.cwd.clone()); - json!({ - "meta": { - "id": format!("copilot:{}", uuid), - "file": file, - "source": "copilot", - "assistant": "Copilot", - "title": cc_title.clone().unwrap_or_else(|| auto_title.clone()), - "autoTitle": auto_title, - "tags": cc_tags, - "summary": Value::Null, - "sessionId": n.session_id.clone().unwrap_or_else(|| uuid.clone()), - "cwd": cwd.clone(), - "project": cwd.as_deref().map(crate::history::base_name).unwrap_or_default(), - "gitBranch": n.git_branch.clone(), - "version": n.version.clone(), - "isSubagent": false, - "deleted": cc_deleted, - "imported": false, - "importedFrom": Value::Null, - "importedAt": Value::Null, - "model": n.model, - "totals": n.totals, - "messages": n.messages.len(), - "subagentCount": 0, - "firstTs": n.first_ts, - "lastTs": n.last_ts, - }, - "messages": n.messages, - "subagents": {}, - }) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn ev(ts: &str, ty: &str, data: Value) -> Value { - json!({ "type": ty, "data": data, "id": "e", "timestamp": ts, "parentId": null }) - } - - fn recs() -> Vec { - vec![ - ev("2026-07-12T07:26:54.363Z", "session.start", json!({ - "sessionId": "d34d-1111", "copilotVersion": "1.0.70", - "context": { "cwd": "/tmp/shhh", "gitRoot": "/tmp/shhh", "branch": "main" } - })), - ev("2026-07-12T07:27:00.685Z", "session.model_change", json!({ "newModel": "gpt-5.6" })), - ev("2026-07-12T07:27:05.000Z", "system.message", json!({ "role": "system", "content": "You are Copilot" })), - ev("2026-07-12T07:27:14.000Z", "user.message", json!({ "content": "修沙盒问题", "attachments": [] })), - ev("2026-07-12T07:27:15.000Z", "assistant.message", json!({ - "messageId": "m1", "model": "gpt-5.6", "content": "我先搜一下。", - "toolRequests": [ - { "toolCallId": "call_A", "name": "rg", "arguments": { "pattern": "sandbox", "paths": ".", "glob": "*.plist" } }, - { "toolCallId": "call_B", "name": "bash", "arguments": { "command": "ls", "description": "List", "mode": "sync", "sessionId": "main" } } - ] - })), - ev("2026-07-12T07:27:16.000Z", "tool.execution_start", json!({ "toolCallId": "call_A", "toolName": "rg" })), - ev("2026-07-12T07:27:17.000Z", "tool.execution_complete", json!({ - "toolCallId": "call_A", "success": true, "result": { "content": "a.plist: sandbox" } - })), - ev("2026-07-12T07:27:18.000Z", "tool.execution_complete", json!({ - "toolCallId": "call_B", "success": false, "result": { "content": "boom" } - })), - ] - } - - #[test] - fn normalizes_events() { - let n = normalize(&recs()); - assert_eq!(n.messages.len(), 4); // user, assistant(+2 tools), 2 results - assert_eq!(n.messages[0]["content"][0]["text"], "修沙盒问题"); - let a = &n.messages[1]; - assert_eq!(a["content"][0]["text"], "我先搜一下。"); - assert_eq!(a["content"][1]["name"], "Grep"); - assert_eq!(a["content"][1]["input"]["pattern"], "sandbox"); - assert_eq!(a["content"][2]["name"], "Bash"); - assert_eq!(n.messages[2]["content"][0]["tool_use_id"], "call_A"); - assert_eq!(n.messages[3]["content"][0]["is_error"], true); - assert_eq!(n.model.as_deref(), Some("gpt-5.6")); - assert_eq!(n.cwd.as_deref(), Some("/tmp/shhh")); - assert_eq!(n.session_id.as_deref(), Some("d34d-1111")); - assert_eq!(n.first_ts.as_deref(), Some("2026-07-12T07:27:14.000Z")); - } - - #[test] - fn detects_paths_and_uuids() { - let new = Path::new("/x/.copilot/session-state/abcd-1/events.jsonl"); - let old = Path::new("/x/.copilot/session-state/abcd-2.jsonl"); - assert!(looks_copilot_path(new)); - assert!(looks_copilot_path(old)); - assert!(!looks_copilot_path(Path::new("/x/projects/-tmp/abcd.jsonl"))); - assert_eq!(session_uuid(new), "abcd-1"); - assert_eq!(session_uuid(old), "abcd-2"); - } -} diff --git a/src-tauri/src/copilot/meta.rs b/src-tauri/src/copilot/meta.rs new file mode 100644 index 0000000..dfa23b2 --- /dev/null +++ b/src-tauri/src/copilot/meta.rs @@ -0,0 +1,57 @@ +// Per-session customization (title / tags / soft delete) via the shared foreign-CLI sidecar, +// plus the sibling workspace.yaml that carries cwd/branch/timestamps for the newer layout. + +use serde_json::{json, Value}; +use std::fs; +use std::path::Path; + +/// The session uuid — the flat file's stem, or the events.jsonl dir name. +pub(super) fn session_uuid(file: &Path) -> String { + if file.file_name().and_then(|n| n.to_str()) == Some("events.jsonl") { + file.parent() + .and_then(|d| d.file_name()) + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_default() + } else { + file.file_stem().and_then(|s| s.to_str()).unwrap_or("").to_string() + } +} + +pub(super) fn sidecar_key(file: &Path) -> String { + format!("copilot:{}", session_uuid(file)) +} + +pub(super) fn sidecar_meta(file: &Path) -> (Option, Vec, bool) { + crate::sidecar::meta(&crate::sidecar::agent_file(), &sidecar_key(file)) +} + +pub fn is_deleted(file: &Path) -> bool { + sidecar_meta(file).2 +} + +pub fn set_meta(file: &str, patch: &Value) -> Value { + let key = sidecar_key(Path::new(file)); + if key == "copilot:" { + return json!({ "ok": false, "reason": "empty" }); + } + crate::sidecar::set_meta(&crate::sidecar::agent_file(), &key, patch) +} + +/// Sibling workspace.yaml of an events.jsonl, parsed as flat `key: value` lines (the file is +/// machine-written and flat; no YAML dependency needed). None for old flat sessions. +pub(super) fn workspace_yaml(file: &Path) -> Option> { + if file.file_name().and_then(|n| n.to_str()) != Some("events.jsonl") { + return None; + } + let text = fs::read_to_string(file.parent()?.join("workspace.yaml")).ok()?; + let mut map = serde_json::Map::new(); + for line in text.lines() { + if let Some((k, v)) = line.split_once(':') { + let (k, v) = (k.trim(), v.trim()); + if !k.is_empty() && !k.starts_with('#') && !v.is_empty() { + map.insert(k.to_string(), json!(v.trim_matches('"').trim_matches('\''))); + } + } + } + Some(map) +} diff --git a/src-tauri/src/copilot/mod.rs b/src-tauri/src/copilot/mod.rs new file mode 100644 index 0000000..b57b165 --- /dev/null +++ b/src-tauri/src/copilot/mod.rs @@ -0,0 +1,35 @@ +// GitHub Copilot CLI session support — reads Copilot's on-disk session event logs and +// normalizes them into the SAME session/message shape the renderer consumes (history::Norm). +// +// Two layouts under `~/.copilot/session-state/`: +// new (≥1.0): /events.jsonl + sibling workspace.yaml (id/cwd/name/branch/timestamps, +// flat "key: value" lines — parsed without a YAML dependency) +// old: .jsonl flat files (same event schema; early builds carry no cwd at all, +// so those sessions group under the unknown-project bucket) +// +// An event line is `{type, data, id, timestamp, parentId}`. Conversation content: +// session.start → cwd/session id/version (data.context.cwd on newer builds) +// session.model_change → model (data.newModel) +// user.message → user text (data.content) +// assistant.message → assistant text + tool_use blocks (data.content, +// data.toolRequests[{toolCallId,name,arguments}], data.model) +// tool.execution_complete → tool_result (data.toolCallId, data.success, data.result.content) +// Everything else (session.info, system.*, turn markers, tool.execution_start) is harness +// plumbing and skipped — tool arguments already ride the assistant.message request. +// +// Title/tags/soft-delete live in the shared foreign-CLI sidecar (~/.ccbud/agent-meta.json) +// keyed `copilot:` — the files belong to another tool and are never rewritten. + +#![allow(dead_code)] + +mod meta; +mod normalize; +mod roots; +mod session; +#[cfg(test)] +mod tests; + +pub use meta::{is_deleted, set_meta}; +pub use normalize::normalize; +pub use roots::{copilot_label, looks_copilot_path, root_exists, walk}; +pub use session::{session_from_recs, session_meta_from}; diff --git a/src-tauri/src/copilot/normalize.rs b/src-tauri/src/copilot/normalize.rs new file mode 100644 index 0000000..d8a6e94 --- /dev/null +++ b/src-tauri/src/copilot/normalize.rs @@ -0,0 +1,139 @@ +// Event log → history::Norm: tool-name mapping onto the renderer's native vocabulary, then +// the per-line walk that builds the message timeline. + +use crate::history::Norm; +use serde_json::{json, Value}; + +/// Copilot tool name + arguments (already an object) → (renderer tool name, renderer input). +fn map_tool(name: &str, args: &Value) -> (String, Value) { + let s = |k: &str| args.get(k).and_then(|v| v.as_str()).unwrap_or("").to_string(); + let keep = |v: &Value| if v.is_object() { v.clone() } else { json!({}) }; + match name { + "bash" => { + let mut input = json!({ "command": s("command") }); + if !s("description").is_empty() { + input["description"] = json!(s("description")); + } + ("Bash".into(), input) + } + "view" => ("Read".into(), json!({ "file_path": s("path") })), + "edit" | "str_replace" => ( + "Edit".into(), + json!({ "file_path": s("path"), "old_string": s("old_str"), "new_string": s("new_str") }), + ), + "create" => ("Write".into(), json!({ "file_path": s("path"), "content": s("file_text") })), + "rg" => { + let mut input = json!({ "pattern": s("pattern") }); + if let Some(p) = args.get("paths") { + input["path"] = if p.is_array() { + json!(p.as_array().unwrap().iter().filter_map(|x| x.as_str()).collect::>().join(" ")) + } else { + p.clone() + }; + } + if !s("glob").is_empty() { + input["glob"] = json!(s("glob")); + } + ("Grep".into(), input) + } + "glob" => ("Glob".into(), json!({ "pattern": s("pattern"), "path": s("paths") })), + "apply_patch" => ("ApplyPatch".into(), json!({ "patch": s("str") })), + _ => (name.to_string(), keep(args)), + } +} + +/// Normalize parsed event records into the renderer's message model. +pub fn normalize(recs: &[Value]) -> Norm { + let mut n = Norm::default(); + for rec in recs { + let ty = rec.get("type").and_then(|v| v.as_str()).unwrap_or(""); + let data = rec.get("data").cloned().unwrap_or(Value::Null); + let ts = rec.get("timestamp").and_then(|v| v.as_str()); + let with_ts = |mut m: Value| { + if let Some(t) = ts { + m["ts"] = json!(t); + } + m + }; + match ty { + "session.start" => { + if n.session_id.is_none() { + n.session_id = data.get("sessionId").and_then(|v| v.as_str()).map(|s| s.to_string()); + } + if n.version.is_none() { + n.version = data.get("copilotVersion").and_then(|v| v.as_str()).map(|s| s.to_string()); + } + if n.cwd.is_none() { + n.cwd = data + .get("context") + .and_then(|c| c.get("cwd")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + } + if n.git_branch.is_none() { + n.git_branch = data + .get("context") + .and_then(|c| c.get("branch")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + } + } + "session.model_change" => { + if let Some(m) = data.get("newModel").and_then(|v| v.as_str()) { + n.model = Some(m.to_string()); + } + } + "user.message" => { + let text = data.get("content").and_then(|v| v.as_str()).unwrap_or(""); + if !text.trim().is_empty() { + n.messages + .push(with_ts(json!({ "role": "user", "content": [{ "type": "text", "text": text }] }))); + } + } + "assistant.message" => { + if let Some(m) = data.get("model").and_then(|v| v.as_str()) { + n.model = Some(m.to_string()); + } + let mut blocks: Vec = vec![]; + let text = data.get("content").and_then(|v| v.as_str()).unwrap_or(""); + if !text.trim().is_empty() { + blocks.push(json!({ "type": "text", "text": text })); + } + if let Some(calls) = data.get("toolRequests").and_then(|c| c.as_array()) { + for call in calls { + let name = call.get("name").and_then(|v| v.as_str()).unwrap_or("tool"); + let args = call.get("arguments").cloned().unwrap_or(json!({})); + let (tname, input) = map_tool(name, &args); + let id = call.get("toolCallId").and_then(|v| v.as_str()).unwrap_or(""); + blocks.push(json!({ "type": "tool_use", "id": id, "name": tname, "input": input })); + } + } + if !blocks.is_empty() { + let mut m = json!({ "role": "assistant", "content": blocks }); + if let Some(md) = &n.model { + m["modelActual"] = json!(md); + } + n.messages.push(with_ts(m)); + } + } + "tool.execution_complete" => { + let id = data.get("toolCallId").and_then(|v| v.as_str()).unwrap_or(""); + let text = data + .get("result") + .and_then(|r| r.get("content")) + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let mut tr = json!({ "type": "tool_result", "tool_use_id": id, "content": text }); + if data.get("success").and_then(|v| v.as_bool()) == Some(false) { + tr["is_error"] = json!(true); + } + n.messages.push(with_ts(json!({ "role": "user", "content": [tr] }))); + } + _ => {} // session.info / system.* / turn markers / execution_start: harness plumbing + } + } + n.first_ts = n.messages.first().and_then(|m| m.get("ts")).and_then(|v| v.as_str()).map(|s| s.to_string()); + n.last_ts = n.messages.last().and_then(|m| m.get("ts")).and_then(|v| v.as_str()).map(|s| s.to_string()); + n +} diff --git a/src-tauri/src/copilot/roots.rs b/src-tauri/src/copilot/roots.rs new file mode 100644 index 0000000..32b3585 --- /dev/null +++ b/src-tauri/src/copilot/roots.rs @@ -0,0 +1,62 @@ +// Where Copilot keeps its sessions and how to walk them: both on-disk layouts (the newer +// `/events.jsonl` directories and the older flat `.jsonl` files). + +use std::fs; +use std::path::{Path, PathBuf}; + +fn home() -> PathBuf { + std::env::var("HOME").map(PathBuf::from).unwrap_or_else(|_| PathBuf::from(".")) +} + +/// Copilot's config dir as a history-dir entry string (`~/.copilot`). +pub fn default_root() -> PathBuf { + home().join(".copilot") +} + +pub fn copilot_label() -> String { + crate::store::collapse_home(&default_root().to_string_lossy()) +} + +pub fn root_exists() -> bool { + default_root().join("session-state").is_dir() +} + +/// Walk every session log under a `session-state/` tree: flat `.jsonl` (old) and +/// `/events.jsonl` (new). Dirs without an events.jsonl (created-but-unused sessions, +/// checkpoint-only remnants) hold no conversation and are skipped. +pub fn walk(state_dir: &Path, cb: &mut F) { + let entries = match fs::read_dir(state_dir) { + Ok(e) => e, + Err(_) => return, + }; + for ent in entries.flatten() { + let p = ent.path(); + if p.is_file() && p.extension().and_then(|e| e.to_str()) == Some("jsonl") { + cb(p); + } else if p.is_dir() { + let events = p.join("events.jsonl"); + if events.is_file() { + cb(events); + } + } + } +} + +/// Container-shape test for detail/edit routing: a .jsonl directly in `session-state/`, or an +/// `events.jsonl` whose grandparent is `session-state/`. +pub fn looks_copilot_path(file: &Path) -> bool { + let parent_named = |p: &Path, name: &str| { + p.file_name().and_then(|n| n.to_str()).map(|n| n == name).unwrap_or(false) + }; + match file.file_name().and_then(|n| n.to_str()) { + Some("events.jsonl") => file + .parent() + .and_then(|d| d.parent()) + .map(|gp| parent_named(gp, "session-state")) + .unwrap_or(false), + Some(n) if n.ends_with(".jsonl") => { + file.parent().map(|d| parent_named(d, "session-state")).unwrap_or(false) + } + _ => false, + } +} diff --git a/src-tauri/src/copilot/session.rs b/src-tauri/src/copilot/session.rs new file mode 100644 index 0000000..6a62c22 --- /dev/null +++ b/src-tauri/src/copilot/session.rs @@ -0,0 +1,110 @@ +// Session list rows and the full session payload the renderer's 对话 view consumes. + +use super::meta::{session_uuid, sidecar_meta, workspace_yaml}; +use super::normalize::normalize; +use serde_json::{json, Value}; +use std::fs; +use std::path::Path; + +/// List-row meta: workspace.yaml when present (new layout — has copilot's own session name), +/// else the event head (old flat layout). +pub fn session_meta_from(file: &Path, recs: &[Value], dir_id: &str, dir_label: &str) -> Option { + let meta = fs::metadata(file).ok()?; + let ws = workspace_yaml(file); + let n = normalize(recs); + let uuid = session_uuid(file); + let (cc_title, cc_tags, cc_deleted) = sidecar_meta(file); + let ws_str = |k: &str| { + ws.as_ref() + .and_then(|m| m.get(k)) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + .filter(|s| !s.is_empty()) + }; + let auto_title = ws_str("name").unwrap_or_else(|| crate::history::first_user_text(&n.messages)); + let cwd = ws_str("cwd").or_else(|| n.cwd.clone()); + let created = ws_str("created_at") + .or_else(|| n.first_ts.clone()) + .and_then(|s| chrono::DateTime::parse_from_rfc3339(&s).ok()) + .map(|d| d.timestamp_millis() as f64) + .unwrap_or_else(|| crate::history::created_ms(file)); + let mt = meta + .modified() + .ok() + .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|d| d.as_millis() as f64) + .unwrap_or(0.0); + Some(json!({ + "id": format!("copilot:{}", uuid), + "file": file.to_string_lossy(), + "source": "copilot", + "dirId": dir_id, + "dirLabel": dir_label, + "sessionId": n.session_id.clone().unwrap_or_else(|| uuid.clone()), + "cwd": cwd.clone(), + "project": cwd.as_deref().map(crate::history::base_name).unwrap_or_default(), + "gitBranch": ws_str("branch").or_else(|| n.git_branch.clone()).map(Value::from).unwrap_or(Value::Null), + "title": cc_title.clone().unwrap_or_else(|| auto_title.clone()), + "autoTitle": auto_title, + "tags": cc_tags, + "model": n.model, + "isSubagent": false, + "imported": false, + "deleted": cc_deleted, + "createdAt": created, + "lastActivity": mt, + "sizeKB": (meta.len() as f64 / 1024.0).round() as i64, + })) +} + +/// Full-detail shape (history.rs get_session routes here). +pub fn session_from_recs(file: &str, recs: &[Value]) -> Value { + let path = Path::new(file); + let n = normalize(recs); + let ws = workspace_yaml(path); + let (cc_title, cc_tags, cc_deleted) = sidecar_meta(path); + let ws_name = ws + .as_ref() + .and_then(|m| m.get("name")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + .filter(|s| !s.is_empty()); + let auto_title = ws_name.unwrap_or_else(|| crate::history::first_user_text(&n.messages)); + let uuid = session_uuid(path); + let cwd = ws + .as_ref() + .and_then(|m| m.get("cwd")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + .or_else(|| n.cwd.clone()); + json!({ + "meta": { + "id": format!("copilot:{}", uuid), + "file": file, + "source": "copilot", + "assistant": "Copilot", + "title": cc_title.clone().unwrap_or_else(|| auto_title.clone()), + "autoTitle": auto_title, + "tags": cc_tags, + "summary": Value::Null, + "sessionId": n.session_id.clone().unwrap_or_else(|| uuid.clone()), + "cwd": cwd.clone(), + "project": cwd.as_deref().map(crate::history::base_name).unwrap_or_default(), + "gitBranch": n.git_branch.clone(), + "version": n.version.clone(), + "isSubagent": false, + "deleted": cc_deleted, + "imported": false, + "importedFrom": Value::Null, + "importedAt": Value::Null, + "model": n.model, + "totals": n.totals, + "messages": n.messages.len(), + "subagentCount": 0, + "firstTs": n.first_ts, + "lastTs": n.last_ts, + }, + "messages": n.messages, + "subagents": {}, + }) +} diff --git a/src-tauri/src/copilot/tests.rs b/src-tauri/src/copilot/tests.rs new file mode 100644 index 0000000..1a39bb7 --- /dev/null +++ b/src-tauri/src/copilot/tests.rs @@ -0,0 +1,64 @@ +use super::meta::session_uuid; +use super::normalize::normalize; +use super::roots::looks_copilot_path; +use serde_json::{json, Value}; +use std::path::Path; + +fn ev(ts: &str, ty: &str, data: Value) -> Value { + json!({ "type": ty, "data": data, "id": "e", "timestamp": ts, "parentId": null }) +} + +fn recs() -> Vec { + vec![ + ev("2026-07-12T07:26:54.363Z", "session.start", json!({ + "sessionId": "d34d-1111", "copilotVersion": "1.0.70", + "context": { "cwd": "/tmp/shhh", "gitRoot": "/tmp/shhh", "branch": "main" } + })), + ev("2026-07-12T07:27:00.685Z", "session.model_change", json!({ "newModel": "gpt-5.6" })), + ev("2026-07-12T07:27:05.000Z", "system.message", json!({ "role": "system", "content": "You are Copilot" })), + ev("2026-07-12T07:27:14.000Z", "user.message", json!({ "content": "修沙盒问题", "attachments": [] })), + ev("2026-07-12T07:27:15.000Z", "assistant.message", json!({ + "messageId": "m1", "model": "gpt-5.6", "content": "我先搜一下。", + "toolRequests": [ + { "toolCallId": "call_A", "name": "rg", "arguments": { "pattern": "sandbox", "paths": ".", "glob": "*.plist" } }, + { "toolCallId": "call_B", "name": "bash", "arguments": { "command": "ls", "description": "List", "mode": "sync", "sessionId": "main" } } + ] + })), + ev("2026-07-12T07:27:16.000Z", "tool.execution_start", json!({ "toolCallId": "call_A", "toolName": "rg" })), + ev("2026-07-12T07:27:17.000Z", "tool.execution_complete", json!({ + "toolCallId": "call_A", "success": true, "result": { "content": "a.plist: sandbox" } + })), + ev("2026-07-12T07:27:18.000Z", "tool.execution_complete", json!({ + "toolCallId": "call_B", "success": false, "result": { "content": "boom" } + })), + ] +} + +#[test] +fn normalizes_events() { + let n = normalize(&recs()); + assert_eq!(n.messages.len(), 4); // user, assistant(+2 tools), 2 results + assert_eq!(n.messages[0]["content"][0]["text"], "修沙盒问题"); + let a = &n.messages[1]; + assert_eq!(a["content"][0]["text"], "我先搜一下。"); + assert_eq!(a["content"][1]["name"], "Grep"); + assert_eq!(a["content"][1]["input"]["pattern"], "sandbox"); + assert_eq!(a["content"][2]["name"], "Bash"); + assert_eq!(n.messages[2]["content"][0]["tool_use_id"], "call_A"); + assert_eq!(n.messages[3]["content"][0]["is_error"], true); + assert_eq!(n.model.as_deref(), Some("gpt-5.6")); + assert_eq!(n.cwd.as_deref(), Some("/tmp/shhh")); + assert_eq!(n.session_id.as_deref(), Some("d34d-1111")); + assert_eq!(n.first_ts.as_deref(), Some("2026-07-12T07:27:14.000Z")); +} + +#[test] +fn detects_paths_and_uuids() { + let new = Path::new("/x/.copilot/session-state/abcd-1/events.jsonl"); + let old = Path::new("/x/.copilot/session-state/abcd-2.jsonl"); + assert!(looks_copilot_path(new)); + assert!(looks_copilot_path(old)); + assert!(!looks_copilot_path(Path::new("/x/projects/-tmp/abcd.jsonl"))); + assert_eq!(session_uuid(new), "abcd-1"); + assert_eq!(session_uuid(old), "abcd-2"); +} diff --git a/src-tauri/src/exporthtml.rs b/src-tauri/src/exporthtml.rs deleted file mode 100644 index 5f8144d..0000000 --- a/src-tauri/src/exporthtml.rs +++ /dev/null @@ -1,952 +0,0 @@ -// Standalone conversation export → a single self-contained .html viewer. Rust port of exportHtml.js. -// -// Embeds the conversation as JSON plus a Claude-design skin (light/dark) + a client runtime -// (render + theme + search + expandable tools/subagents), with marked + highlight.js vendored. -// Heavy content fields are capped so the embedded JSON stays bounded. - -#![allow(dead_code)] - -use serde_json::{json, Value}; -use std::fs; -use std::path::Path; - -const SKIN: &str = include_str!("../../src/main/export-assets/skin.css"); -const RUNTIME: &str = include_str!("../../src/main/export-assets/runtime.js"); -const MARKED: &str = include_str!("../../src/renderer/vendor/marked.umd.js"); -const HLJS: &str = include_str!("../../src/renderer/vendor/highlight.min.js"); -const HLJS_CSS: &str = include_str!("../../src/renderer/vendor/hljs-dark.css"); - -const CAP_TEXT: usize = 24000; -const CAP_THINKING: usize = 16000; -const CAP_RESULT: usize = 24000; -const CAP_PROMPT: usize = 9000; -const CAP_CONTENT: usize = 14000; -const CAP_SKILL_SNAPSHOT: usize = 131072; - -fn cap(s: &str, n: usize) -> String { - if s.chars().count() > n { - let truncated: String = s.chars().take(n).collect(); - let dropped = s.chars().count() - n; - format!("{}\n…[truncated {} chars]", truncated, dropped) - } else { - s.to_string() - } -} - -fn parse_jsonl_result(file: &Path) -> std::io::Result> { - let qoder = crate::qoder::looks_qoder_path(file); - let raw = if qoder { crate::qoder::read_text(file) } else { fs::read_to_string(file) }?; - let records: Vec = raw - .split('\n') - .map(|l| l.trim()) - .filter(|l| !l.is_empty()) - .filter_map(|l| serde_json::from_str::(l).ok()) - .collect(); - Ok(if qoder { - crate::qoder::normalize_records(&records) - } else { - records - }) -} - -/// Skip-on-error variant for subagent sidecars — one broken agent file must not sink the export. -/// The MAIN transcript goes through parse_jsonl_result so a read failure surfaces to the caller -/// instead of exporting an empty page. -fn parse_jsonl(file: &Path) -> Vec { - parse_jsonl_result(file).unwrap_or_default() -} - -fn usage_of(u: &Value) -> Value { - let mut usage = json!({ - "in": u.get("input_tokens").and_then(|v| v.as_i64()).unwrap_or(0), - "out": u.get("output_tokens").and_then(|v| v.as_i64()).unwrap_or(0), - "cacheRead": u.get("cache_read_input_tokens").and_then(|v| v.as_i64()).unwrap_or(0), - "cacheCreation": u.get("cache_creation_input_tokens").and_then(|v| v.as_i64()).unwrap_or(0), - }); - let object = usage.as_object_mut().unwrap(); - for (source, target) in [ - ("credits", "credits"), - ("original_credits", "originalCredits"), - ("context_usage_ratio", "contextUsageRatio"), - ] { - if let Some(value) = u.get(source).filter(|value| value.is_number()) { - object.insert(target.to_string(), value.clone()); - } - } - usage -} - -fn cap_content(content: &Value) -> Value { - if let Some(s) = content.as_str() { - return json!(cap(s, CAP_TEXT)); - } - let arr = match content.as_array() { - Some(a) => a, - None => return content.clone(), - }; - let mapped: Vec = arr - .iter() - .map(|b| { - let ty = b.get("type").and_then(|t| t.as_str()).unwrap_or(""); - match ty { - "text" => json!({ "type": "text", "text": cap(b.get("text").and_then(|t| t.as_str()).unwrap_or(""), CAP_TEXT) }), - "thinking" => json!({ "type": "thinking", "thinking": cap(b.get("thinking").and_then(|t| t.as_str()).unwrap_or(""), CAP_THINKING) }), - "skill_load" => json!({ - "type": "skill_load", - "name": b.get("name").cloned().unwrap_or(Value::Null), - "path": b.get("path").cloned().unwrap_or(Value::Null), - "snapshot": b - .get("snapshot") - .and_then(|v| v.as_str()) - .map(|v| json!(cap(v, CAP_SKILL_SNAPSHOT))) - .unwrap_or(Value::Null), - }), - "tool_use" => { - let mut input = b.get("input").cloned().unwrap_or(json!({})); - if let Some(obj) = input.as_object_mut() { - if let Some(p) = obj.get("prompt").and_then(|v| v.as_str()) { - let c = cap(p, CAP_PROMPT); - obj.insert("prompt".into(), json!(c)); - } - if let Some(p) = obj.get("content").and_then(|v| v.as_str()) { - let c = cap(p, CAP_CONTENT); - obj.insert("content".into(), json!(c)); - } - if let Some(p) = obj.get("patch").and_then(|v| v.as_str()) { - let c = cap(p, CAP_CONTENT); // codex ApplyPatch envelopes can be huge - obj.insert("patch".into(), json!(c)); - } - if let Some(p) = obj.get("code").and_then(|v| v.as_str()) { - let c = cap(p, CAP_CONTENT); // code-mode Script bodies - obj.insert("code".into(), json!(c)); - } - } - json!({ "type": "tool_use", "id": b.get("id").cloned().unwrap_or(Value::Null), "name": b.get("name").cloned().unwrap_or(Value::Null), "input": input }) - } - "tool_result" => { - let c = match b.get("content") { - Some(Value::String(s)) => json!(cap(s, CAP_RESULT)), - Some(Value::Array(ca)) => Value::Array( - ca.iter() - .map(|x| { - if x.get("type").and_then(|t| t.as_str()) == Some("text") { - json!({ "type": "text", "text": cap(x.get("text").and_then(|t| t.as_str()).unwrap_or(""), CAP_RESULT) }) - } else { - x.clone() - } - }) - .collect(), - ), - other => other.cloned().unwrap_or(Value::Null), - }; - json!({ "type": "tool_result", "tool_use_id": b.get("tool_use_id").cloned().unwrap_or(Value::Null), "is_error": b.get("is_error").and_then(|v| v.as_bool()).unwrap_or(false), "content": c }) - } - "image" => { - let oversized = b.get("source").and_then(|s| s.get("data")).and_then(|d| d.as_str()).map(|d| d.len() > 600000).unwrap_or(false); - if oversized { - json!({ "type": "image", "source": { "media_type": b.get("source").and_then(|s| s.get("media_type")).and_then(|m| m.as_str()).unwrap_or("image/png"), "oversized": true } }) - } else { - b.clone() - } - } - _ => b.clone(), - } - }) - .collect(); - Value::Array(mapped) -} - -fn line_to_msg(rec: &Value) -> Option { - let ty = rec.get("type").and_then(|v| v.as_str())?; - if ty != "user" && ty != "assistant" { - return None; - } - let m = rec.get("message")?; - m.get("role").and_then(|v| v.as_str())?; - let mut out = json!({ - "role": m.get("role").cloned().unwrap_or(Value::Null), - "content": cap_content(m.get("content").unwrap_or(&Value::Null)), - "ts": rec.get("timestamp").cloned().unwrap_or(Value::Null), - "meta": rec.get("isMeta").and_then(|v| v.as_bool()).unwrap_or(false), - }); - if ty == "assistant" { - let o = out.as_object_mut().unwrap(); - o.insert( - "model".into(), - m.get("model").cloned().unwrap_or(Value::Null), - ); - o.insert( - "usage".into(), - m.get("usage").map(usage_of).unwrap_or(Value::Null), - ); - o.insert( - "stop".into(), - m.get("stop_reason").cloned().unwrap_or(Value::Null), - ); - } - Some(out) -} - -fn content_text(content: &Value) -> String { - if let Some(s) = content.as_str() { - return s.to_string(); - } - if let Some(arr) = content.as_array() { - return arr - .iter() - .filter(|b| b.get("type").and_then(|t| t.as_str()) == Some("text")) - .filter_map(|b| b.get("text").and_then(|t| t.as_str())) - .collect::>() - .join(" "); - } - String::new() -} -fn command_label(raw: &str) -> String { - let name = raw - .split_once("") - .and_then(|(_, r)| r.split_once("")) - .map(|(n, _)| n.trim().to_string()) - .unwrap_or_default(); - if name.is_empty() { - return String::new(); - } - let args = raw - .split_once("") - .and_then(|(_, r)| r.split_once("")) - .map(|(a, _)| a.trim().to_string()) - .unwrap_or_default(); - format!("{} {}", name, args).trim().to_string() -} -fn first_user_text(messages: &[Value]) -> String { - let mut fallback = String::new(); - for m in messages { - if m.get("role").and_then(|r| r.as_str()) != Some("user") - || m.get("meta").and_then(|v| v.as_bool()).unwrap_or(false) - { - continue; - } - let raw = content_text(m.get("content").unwrap_or(&Value::Null)); - let raw = raw.trim(); - if raw.is_empty() { - continue; - } - if raw.starts_with('<') { - if fallback.is_empty() { - fallback = command_label(raw); - } - continue; - } - let t: String = raw.split_whitespace().collect::>().join(" "); - if t.starts_with("[Request interrupted") || t.starts_with("Caveat:") { - continue; - } - return t.chars().take(100).collect(); - } - fallback.chars().take(100).collect() -} -fn base_name(p: &str) -> String { - p.split('/') - .filter(|s| !s.is_empty()) - .last() - .unwrap_or(p) - .to_string() -} - -struct Shaped { - messages: Vec, - model: Option, - totals: (i64, i64, i64, i64), - cache_creation: i64, - credits: Option, - token_usage_available: bool, - first_ts: Option, - last_ts: Option, -} -fn shape_session(recs: &[Value]) -> Shaped { - let mut messages = vec![]; - let (mut tin, mut tout, mut tcr, mut tcc, mut turns) = (0i64, 0i64, 0i64, 0i64, 0i64); - let mut credits = 0.0f64; - let mut has_credits = false; - let mut model = None; - let mut first_ts = None; - let mut last_ts = None; - for r in recs { - let lm = match line_to_msg(r) { - Some(m) => m, - None => continue, - }; - if lm.get("meta").and_then(|v| v.as_bool()).unwrap_or(false) { - continue; - } - if let Some(ts) = lm.get("ts").and_then(|v| v.as_str()) { - if first_ts.is_none() { - first_ts = Some(ts.to_string()); - } - last_ts = Some(ts.to_string()); - } - if let Some(md) = lm.get("model").and_then(|v| v.as_str()) { - model = Some(md.to_string()); - } - if let Some(u) = lm.get("usage").filter(|u| u.is_object()) { - tin += u.get("in").and_then(|v| v.as_i64()).unwrap_or(0); - tout += u.get("out").and_then(|v| v.as_i64()).unwrap_or(0); - tcr += u.get("cacheRead").and_then(|v| v.as_i64()).unwrap_or(0); - tcc += u.get("cacheCreation").and_then(|v| v.as_i64()).unwrap_or(0); - if let Some(value) = u.get("credits").and_then(|v| v.as_f64()) { - credits += value; - has_credits = true; - } - turns += 1; - } - messages.push(lm); - } - Shaped { - messages, - model, - totals: (tin, tout, tcr, turns), - cache_creation: tcc, - credits: has_credits.then_some(credits), - token_usage_available: !(has_credits && tin == 0 && tout == 0 && tcr == 0 && tcc == 0), - first_ts, - last_ts, - } -} - -fn read_subagents(file: &Path) -> Value { - let qoder = crate::qoder::looks_qoder_path(file); - let dir = file.parent().map(|p| { - p.join(file.file_stem().and_then(|s| s.to_str()).unwrap_or("")) - .join("subagents") - }); - let dir = match dir { - Some(d) => d, - None => return json!({}), - }; - let entries = match fs::read_dir(&dir) { - Ok(e) => e, - Err(_) => return json!({}), - }; - let mut agent_names: Vec = entries - .flatten() - .map(|ent| ent.file_name().to_string_lossy().into_owned()) - .filter(|name| name.starts_with("agent-") && name.ends_with(".jsonl")) - .collect(); - agent_names.sort(); - // A protected qoder session's subagent transcripts + meta sidecars warm in one helper batch. - if qoder { - let mut warm: Vec = vec![]; - for name in &agent_names { - warm.push(dir.join(name)); - let agent_id = name.trim_start_matches("agent-").trim_end_matches(".jsonl"); - warm.push(dir.join(format!("agent-{}.meta.json", agent_id))); - } - crate::qoder::prefetch(&warm); - } - let mut by_tool = serde_json::Map::new(); - for name in agent_names { - let agent_id = name - .trim_start_matches("agent-") - .trim_end_matches(".jsonl") - .to_string(); - let meta_path = dir.join(format!("agent-{}.meta.json", agent_id)); - let meta_raw = if qoder { - crate::qoder::read_text(&meta_path) - } else { - fs::read_to_string(&meta_path) - }; - let meta: Value = meta_raw - .ok() - .and_then(|s| serde_json::from_str(&s).ok()) - .unwrap_or(json!({})); - let recs = parse_jsonl(&dir.join(&name)); - let shaped = shape_session(&recs); - let key = meta - .get("toolUseId") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()) - .unwrap_or_else(|| format!("agent:{}", agent_id)); - by_tool.insert( - key, - json!({ - "agentId": agent_id, - "type": meta.get("agentType").or_else(|| meta.get("subagent_type")).and_then(|v| v.as_str()).unwrap_or("agent"), - "description": meta.get("description").and_then(|v| v.as_str()).unwrap_or(""), - "skill": crate::history::skill_from_recs(&recs), - "count": shaped.messages.len(), - "totals": { - "in": shaped.totals.0, - "out": shaped.totals.1, - "cacheRead": shaped.totals.2, - "cacheCreation": shaped.cache_creation, - "turns": shaped.totals.3, - "credits": shaped.credits, - "tokenUsageAvailable": shaped.token_usage_available, - }, - "messages": shaped.messages, - }), - ); - } - Value::Object(by_tool) -} - -// Non-Claude session detail → the export data shape (messages re-capped + field names the -// viewer runtime reads: model / usage{in,out,cacheRead} / stop). `assistant` labels turns on -// the exported page (Codex / Grok / Copilot / Antigravity). -fn build_from_session(sess: Value, assistant: &str) -> Value { - let m = sess.get("meta").cloned().unwrap_or_else(|| json!({})); - let messages: Vec = sess - .get("messages") - .and_then(|v| v.as_array()) - .map(|arr| { - arr.iter() - .map(|msg| { - let mut out = json!({ - "role": msg.get("role").cloned().unwrap_or(Value::Null), - "content": cap_content(msg.get("content").unwrap_or(&Value::Null)), - "ts": msg.get("ts").cloned().unwrap_or(Value::Null), - "meta": msg - .get("meta") - .or_else(|| msg.get("_meta")) - .and_then(|v| v.as_bool()) - .unwrap_or(false), - }); - let o = out.as_object_mut().unwrap(); - if let Some(md) = msg.get("modelActual") { - o.insert("model".into(), md.clone()); - } - if let Some(u) = msg.get("usage") { - let mut usage = json!({ - "in": u.get("inputTokens").and_then(|v| v.as_i64()).unwrap_or(0), - "out": u.get("outputTokens").and_then(|v| v.as_i64()).unwrap_or(0), - "cacheRead": u.get("cacheRead").and_then(|v| v.as_i64()).unwrap_or(0), - "cacheCreation": u.get("cacheCreation").and_then(|v| v.as_i64()).unwrap_or(0), - }); - let usage_object = usage.as_object_mut().unwrap(); - for field in ["credits", "originalCredits", "contextUsageRatio"] { - if let Some(value) = u.get(field).filter(|value| value.is_number()) { - usage_object.insert(field.to_string(), value.clone()); - } - } - o.insert( - "usage".into(), - usage, - ); - } - out - }) - .collect() - }) - .unwrap_or_default(); - let t = m.get("totals").cloned().unwrap_or_else(|| json!({})); - let title = m - .get("title") - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(); - json!({ - "meta": { - "title": if title.is_empty() { "(conversation)".to_string() } else { title }, - "assistant": assistant, - "model": m.get("model").cloned().unwrap_or(Value::Null), - "project": m.get("project").cloned().unwrap_or(Value::Null), - "cwd": m.get("cwd").cloned().unwrap_or(Value::Null), - "branch": m.get("gitBranch").cloned().unwrap_or(Value::Null), - "sessionId": m.get("sessionId").cloned().unwrap_or(Value::Null), - "version": m.get("version").cloned().unwrap_or(Value::Null), - "count": messages.len(), - "turns": t.get("turns").cloned().unwrap_or(json!(0)), - "inTok": t.get("in").cloned().unwrap_or(json!(0)), - "outTok": t.get("out").cloned().unwrap_or(json!(0)), - "cacheTok": t.get("cacheRead").cloned().unwrap_or(json!(0)), - "credits": t.get("credits").cloned().unwrap_or(Value::Null), - "tokenUsageAvailable": t.get("tokenUsageAvailable").cloned().unwrap_or(json!(true)), - "subagentCount": 0, - "firstTs": m.get("firstTs").cloned().unwrap_or(Value::Null), - "lastTs": m.get("lastTs").cloned().unwrap_or(Value::Null), - }, - "messages": messages, - "subagents": {}, - }) -} - -pub fn build_data(file: &str) -> Value { - let path = Path::new(file); - let qoder = crate::qoder::looks_qoder_path(path); - // Antigravity first — it's SQLite and its shaper opens the DB itself. Every other source - // reads the transcript here, and a failed MAIN read returns the structured error (the export - // command surfaces it) instead of silently exporting an empty page. - if matches!(crate::history::foreign_kind(path), Some(crate::history::Foreign::Antigravity)) { - return build_from_session(crate::antigravity::session_from(file), "Antigravity"); - } - let recs = match parse_jsonl_result(path) { - Ok(recs) => recs, - Err(error) => return crate::history::session_read_error(path, &error), - }; - match crate::history::foreign_kind(path) { - Some(crate::history::Foreign::Grok) => { - return build_from_session(crate::grok::session_from_recs(file, &recs), "Grok"); - } - Some(crate::history::Foreign::Copilot) => { - return build_from_session(crate::copilot::session_from_recs(file, &recs), "Copilot"); - } - _ => {} - } - if crate::codex::looks_codex(&recs) { - return build_from_session(crate::codex::session_from_recs(file, &recs), "Codex"); - } - // Qoder sessions are Claude-format (the shaping below applies as-is) — brand the exported - // page and prefer qoder's own stored title over first-user-text. - let meta_rec = recs - .iter() - .find(|r| r.get("cwd").is_some()) - .or_else(|| recs.iter().find(|r| r.get("sessionId").is_some())); - let s = shape_session(&recs); - let top_level_cwd = meta_rec - .and_then(|r| r.get("cwd")) - .and_then(|v| v.as_str()) - .map(str::to_string); - let cwd = if qoder { - crate::qoder::working_dir_from(&recs).or(top_level_cwd) - } else { - top_level_cwd - }; - let title = { - let t = (if qoder { - crate::qoder::session_title_from(&recs) - } else { - None - }) - .unwrap_or_else(|| first_user_text(&s.messages)); - if t.is_empty() { - "(conversation)".to_string() - } else { - t - } - }; - let model = if qoder { - crate::qoder::model_from(&recs).or(s.model.clone()) - } else { - s.model.clone() - }; - let stem = path.file_stem().and_then(|x| x.to_str()).unwrap_or(""); - let mut subagents = read_subagents(path); - if let Some(map) = subagents.as_object_mut() { - // The spawning Skill tool_use overrides the sentinel fallback (mirrors exportHtml.js). - crate::history::apply_skill_names(&s.messages, map); - } - json!({ - "meta": { - "title": title, - // The viewer runtime labels turns `meta.assistant || 'Claude'`. - "assistant": if qoder { json!("Qoder") } else { Value::Null }, - "model": model, - "project": cwd.as_deref().map(base_name), - "cwd": cwd, - "branch": meta_rec.and_then(|r| r.get("gitBranch")).cloned().unwrap_or(Value::Null), - "sessionId": meta_rec.and_then(|r| r.get("sessionId")).and_then(|v| v.as_str()).unwrap_or(stem), - "version": meta_rec.and_then(|r| r.get("version")).cloned().unwrap_or(Value::Null), - "count": s.messages.len(), - "turns": s.totals.3, - "inTok": s.totals.0, "outTok": s.totals.1, "cacheTok": s.totals.2, - "credits": s.credits, - "tokenUsageAvailable": s.token_usage_available, - "subagentCount": subagents.as_object().map(|o| o.len()).unwrap_or(0), - "firstTs": s.first_ts, "lastTs": s.last_ts, - }, - "messages": s.messages, - "subagents": subagents, - }) -} - -pub fn html_from_data(data: &Value) -> String { - let json = serde_json::to_string(data) - .unwrap_or_default() - .replace('<', "\\u003c"); - // Tab title uses the project name (already public via the export's filename), NOT the - // conversation title: Clarity reports document.title as page metadata that masking can't - // reach, and the conversation title is first-message text. The full title still renders - // in the viewer header, inside the Clarity-masked #app. - let title = data - .get("meta") - .and_then(|m| m.get("project")) - .and_then(|v| v.as_str()) - .unwrap_or("Conversation") - .replace(['<', '>'], ""); - // The exported viewer is a static file opened in a plain browser (no app CSP). A nonce-based - // CSP lets ONLY these four generator-emitted \ -\ -\ -\ -", - csp = csp, - title = title, - skin = SKIN, - hljscss = HLJS_CSS, - marked = MARKED, - hljs = HLJS, - json = json, - version = env!("CARGO_PKG_VERSION"), - runtime = RUNTIME, - ) -} - -pub fn build_export_html(file: &str) -> String { - html_from_data(&build_data(file)) -} - -// ---- export filename ---- -// Default export base name: `--`, both timestamps as YYMMDDHHmm -// (local time). Earlier exports used collision-prone names (UUID-only JSONL or first-message HTML); -// this keeps bulk exports stable and sortable. - -// path/url-hostile chars + whitespace runs collapse to a single `_`; leading/trailing `_ . -` are -// trimmed; result capped at 60 chars. -fn sanitize_name(s: &str) -> String { - let mut out = String::with_capacity(s.len()); - let mut prev_underscore = false; - for ch in s.chars() { - let bad = matches!( - ch, - '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' | '\n' | '\r' - ) || ch.is_whitespace(); - if bad { - if !prev_underscore { - out.push('_'); - prev_underscore = true; - } - } else { - out.push(ch); - prev_underscore = false; - } - } - out.trim_matches(|c| c == '_' || c == '.' || c == '-') - .chars() - .take(60) - .collect() -} - -// Parse an ISO-8601 `ts` and render it as YYMMDDHHmm in local time (matches `new Date(ts)` + the -// Date's local getters used by the original). -fn fmt_ts_local(ts: &str) -> Option { - chrono::DateTime::parse_from_rfc3339(ts).ok().map(|dt| { - dt.with_timezone(&chrono::Local) - .format("%y%m%d%H%M") - .to_string() - }) -} - -// Derive the base name from already-built export `data` (avoids re-parsing for the HTML path). -pub fn export_base_name_from_data(data: &Value) -> String { - let meta = data.get("meta"); - let project = meta - .and_then(|m| m.get("project")) - .and_then(|v| v.as_str()) - .map(sanitize_name) - .filter(|s| !s.is_empty()) - .unwrap_or_else(|| "conversation".to_string()); - let conv_part = meta - .and_then(|m| m.get("firstTs")) - .and_then(|v| v.as_str()) - .and_then(fmt_ts_local) - .unwrap_or_else(|| "unknown".to_string()); - let exported_at = chrono::Local::now().format("%y%m%d%H%M").to_string(); - format!("{}-{}-{}", project, conv_part, exported_at) -} - -// Build + shape the file, then derive the base name (JSONL export path, which has no `data` yet). -pub fn export_base_name(file: &str) -> String { - export_base_name_from_data(&build_data(file)) -} - -#[cfg(test)] -mod tests { - use super::*; - use std::path::PathBuf; - use std::time::{SystemTime, UNIX_EPOCH}; - - fn qoder_fixture(name: &str) -> (PathBuf, PathBuf) { - let nonce = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_nanos(); - let root = std::env::temp_dir().join(format!( - "ccbud-exporthtml-{name}-{}-{nonce}", - std::process::id() - )); - let file = root - .join(".qoder") - .join("projects") - .join("-work-project") - .join("session.jsonl"); - fs::create_dir_all(file.parent().unwrap()).unwrap(); - (root, file) - } - - fn write_jsonl(path: &Path, records: &[Value]) { - let raw = records - .iter() - .map(|record| serde_json::to_string(record).unwrap()) - .collect::>() - .join("\n"); - fs::write(path, format!("{raw}\n")).unwrap(); - } - - fn streamed_assistant_records() -> Vec { - vec![ - json!({ - "type": "assistant", - "uuid": "wrap-1", - "message": { - "id": "msg-1", - "role": "assistant", - "model": "", - "content": [{ "type": "thinking", "thinking": "plan" }], - "stop_reason": null, - }, - }), - json!({ - "type": "assistant", - "uuid": "wrap-2", - "message": { - "id": "msg-1", - "role": "assistant", - "model": "ultimate", - "content": [{ "type": "redacted_thinking", "data": "opaque" }], - "stop_reason": null, - }, - }), - json!({ - "type": "assistant", - "uuid": "wrap-3", - "message": { - "id": "msg-1", - "role": "assistant", - "model": "ultimate", - "content": [{ - "type": "tool_use", - "id": "tool-1", - "name": "Read", - "input": { "file_path": "/work/a" }, - }], - "usage": { "input_tokens": 7, "output_tokens": 3 }, - "stop_reason": "tool_use", - }, - }), - ] - } - - #[test] - fn qoder_jsonl_normalizes_streamed_assistant_records() { - let (root, file) = qoder_fixture("normalize"); - write_jsonl(&file, &streamed_assistant_records()); - - let records = parse_jsonl(&file); - assert_eq!(records.len(), 1); - assert_eq!( - records[0].get("uuid").and_then(Value::as_str), - Some("wrap-1") - ); - let message = records[0].get("message").unwrap(); - assert_eq!( - message.get("model").and_then(Value::as_str), - Some("ultimate") - ); - assert_eq!( - message.get("stop_reason").and_then(Value::as_str), - Some("tool_use") - ); - assert_eq!( - message - .get("usage") - .and_then(|usage| usage.get("input_tokens")) - .and_then(Value::as_i64), - Some(7) - ); - let content = message.get("content").and_then(Value::as_array).unwrap(); - assert_eq!(content.len(), 2); - assert_eq!( - content[0].get("type").and_then(Value::as_str), - Some("thinking") - ); - assert_eq!( - content[1].get("type").and_then(Value::as_str), - Some("tool_use") - ); - - fs::remove_dir_all(root).unwrap(); - } - - #[test] - fn ordinary_jsonl_keeps_streamed_records_unchanged() { - let (root, _) = qoder_fixture("ordinary"); - let file = root.join("ordinary.jsonl"); - write_jsonl(&file, &streamed_assistant_records()); - - let records = parse_jsonl(&file); - assert_eq!(records.len(), 3); - - fs::remove_dir_all(root).unwrap(); - } - - #[test] - fn qoder_export_uses_record_metadata_and_reads_subagent_meta() { - let (root, file) = qoder_fixture("metadata"); - write_jsonl( - &file, - &[ - json!({ - "type": "workspace-directories", - "sessionId": "session", - "directories": ["/work/project"], - }), - json!({ - "type": "runtime-config", - "sessionId": "session", - "model": "ultimate", - }), - json!({ - "type": "ai-title", - "sessionId": "session", - "aiTitle": "Inline Qoder title", - }), - json!({ - "type": "user", - "uuid": "user-1", - "timestamp": "2026-08-04T08:00:00Z", - "message": { "role": "user", "content": "User fallback title" }, - }), - json!({ - "type": "assistant", - "uuid": "assistant-1", - "timestamp": "2026-08-04T08:01:00Z", - "message": { - "id": "answer-1", - "role": "assistant", - "model": "message-model", - "content": [{ "type": "text", "text": "Done" }], - "usage": { "input_tokens": 2, "output_tokens": 1 }, - "stop_reason": "end_turn", - }, - }), - ], - ); - fs::write( - file.parent().unwrap().join("session-session.json"), - r#"{"title":"stale companion title","working_dir":"/stale/path"}"#, - ) - .unwrap(); - - let subagent_dir = file.parent().unwrap().join("session").join("subagents"); - fs::create_dir_all(&subagent_dir).unwrap(); - write_jsonl( - &subagent_dir.join("agent-a.jsonl"), - &[ - json!({ - "type": "user", - "uuid": "sub-user", - "message": { "role": "user", "content": "Investigate" }, - }), - json!({ - "type": "assistant", - "uuid": "sub-assistant", - "message": { - "id": "sub-answer", - "role": "assistant", - "model": "ultimate", - "content": [{ "type": "text", "text": "Found it" }], - }, - }), - ], - ); - fs::write( - subagent_dir.join("agent-a.meta.json"), - r#"{"toolUseId":"tool-a","agentType":"Explore","description":"trace"}"#, - ) - .unwrap(); - - let data = build_data(&file.to_string_lossy()); - let meta = data.get("meta").unwrap(); - assert_eq!(meta.get("assistant").and_then(Value::as_str), Some("Qoder")); - assert_eq!( - meta.get("title").and_then(Value::as_str), - Some("Inline Qoder title") - ); - assert_eq!( - meta.get("cwd").and_then(Value::as_str), - Some("/work/project") - ); - assert_eq!(meta.get("project").and_then(Value::as_str), Some("project")); - assert_eq!(meta.get("model").and_then(Value::as_str), Some("ultimate")); - assert_eq!(meta.get("subagentCount").and_then(Value::as_u64), Some(1)); - let subagent = data - .get("subagents") - .and_then(|subagents| subagents.get("tool-a")) - .unwrap(); - assert_eq!( - subagent.get("type").and_then(Value::as_str), - Some("Explore") - ); - assert_eq!( - subagent.get("description").and_then(Value::as_str), - Some("trace") - ); - assert_eq!(subagent.get("count").and_then(Value::as_u64), Some(2)); - - fs::remove_dir_all(root).unwrap(); - } - - #[test] - fn qoder_export_falls_back_to_top_level_cwd_and_assistant_model() { - let (root, file) = qoder_fixture("metadata-fallback"); - write_jsonl( - &file, - &[ - json!({ - "type": "user", - "uuid": "user-1", - "sessionId": "session", - "cwd": "/legacy/work", - "message": { "role": "user", "content": "Fallback title" }, - }), - json!({ - "type": "assistant", - "uuid": "assistant-1", - "message": { - "id": "answer-1", - "role": "assistant", - "model": "legacy-model", - "content": [{ "type": "text", "text": "Done" }], - }, - }), - ], - ); - - let data = build_data(&file.to_string_lossy()); - let meta = data.get("meta").unwrap(); - assert_eq!( - meta.get("cwd").and_then(Value::as_str), - Some("/legacy/work") - ); - assert_eq!(meta.get("project").and_then(Value::as_str), Some("work")); - assert_eq!( - meta.get("model").and_then(Value::as_str), - Some("legacy-model") - ); - - fs::remove_dir_all(root).unwrap(); - } -} diff --git a/src-tauri/src/exporthtml/assets.rs b/src-tauri/src/exporthtml/assets.rs new file mode 100644 index 0000000..4544c83 --- /dev/null +++ b/src-tauri/src/exporthtml/assets.rs @@ -0,0 +1,24 @@ +// The embedded viewer assets (skin, runtime parts, vendored marked/highlight.js) and the +// per-field size caps. Moved verbatim from exporthtml.rs — the include_str! paths gained one +// `../` because this file sits one directory deeper. + +pub(super) const SKIN: &str = include_str!("../../../src/main/export-assets/skin.css"); +// The viewer runtime ships as four source parts (each within the repo's module-size limit) +// concatenated verbatim into one \ +\ +\ +\ +", + csp = csp, + title = title, + skin = SKIN, + hljscss = HLJS_CSS, + marked = MARKED, + hljs = HLJS, + json = json, + version = env!("CARGO_PKG_VERSION"), + runtime = RUNTIME, + ) +} + +pub fn build_export_html(file: &str) -> String { + html_from_data(&build_data(file)) +} diff --git a/src-tauri/src/exporthtml/mod.rs b/src-tauri/src/exporthtml/mod.rs new file mode 100644 index 0000000..6af8e25 --- /dev/null +++ b/src-tauri/src/exporthtml/mod.rs @@ -0,0 +1,22 @@ +// Standalone conversation export → a single self-contained .html viewer. Rust port of exportHtml.js. +// +// Embeds the conversation as JSON plus a Claude-design skin (light/dark) + a client runtime +// (render + theme + search + expandable tools/subagents), with marked + highlight.js vendored. +// Heavy content fields are capped so the embedded JSON stays bounded. + +#![allow(dead_code)] +mod assets; +mod build; +mod html; +mod name; +mod parse; +mod session; +mod shape; +#[cfg(test)] +mod tests; +#[cfg(test)] +mod tests_more; + +pub use build::build_data; +pub use html::{build_export_html, html_from_data}; +pub use name::{export_base_name, export_base_name_from_data}; diff --git a/src-tauri/src/exporthtml/name.rs b/src-tauri/src/exporthtml/name.rs new file mode 100644 index 0000000..ea7951d --- /dev/null +++ b/src-tauri/src/exporthtml/name.rs @@ -0,0 +1,69 @@ +// The default export filename. Moved verbatim from exporthtml.rs. + +use serde_json::Value; + +use super::build::build_data; + +// ---- export filename ---- +// Default export base name: `--`, both timestamps as YYMMDDHHmm +// (local time). Earlier exports used collision-prone names (UUID-only JSONL or first-message HTML); +// this keeps bulk exports stable and sortable. + +// path/url-hostile chars + whitespace runs collapse to a single `_`; leading/trailing `_ . -` are +// trimmed; result capped at 60 chars. +pub(super) fn sanitize_name(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + let mut prev_underscore = false; + for ch in s.chars() { + let bad = matches!( + ch, + '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' | '\n' | '\r' + ) || ch.is_whitespace(); + if bad { + if !prev_underscore { + out.push('_'); + prev_underscore = true; + } + } else { + out.push(ch); + prev_underscore = false; + } + } + out.trim_matches(|c| c == '_' || c == '.' || c == '-') + .chars() + .take(60) + .collect() +} + +// Parse an ISO-8601 `ts` and render it as YYMMDDHHmm in local time (matches `new Date(ts)` + the +// Date's local getters used by the original). +pub(super) fn fmt_ts_local(ts: &str) -> Option { + chrono::DateTime::parse_from_rfc3339(ts).ok().map(|dt| { + dt.with_timezone(&chrono::Local) + .format("%y%m%d%H%M") + .to_string() + }) +} + +// Derive the base name from already-built export `data` (avoids re-parsing for the HTML path). +pub fn export_base_name_from_data(data: &Value) -> String { + let meta = data.get("meta"); + let project = meta + .and_then(|m| m.get("project")) + .and_then(|v| v.as_str()) + .map(sanitize_name) + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| "conversation".to_string()); + let conv_part = meta + .and_then(|m| m.get("firstTs")) + .and_then(|v| v.as_str()) + .and_then(fmt_ts_local) + .unwrap_or_else(|| "unknown".to_string()); + let exported_at = chrono::Local::now().format("%y%m%d%H%M").to_string(); + format!("{}-{}-{}", project, conv_part, exported_at) +} + +// Build + shape the file, then derive the base name (JSONL export path, which has no `data` yet). +pub fn export_base_name(file: &str) -> String { + export_base_name_from_data(&build_data(file)) +} diff --git a/src-tauri/src/exporthtml/parse.rs b/src-tauri/src/exporthtml/parse.rs new file mode 100644 index 0000000..440b335 --- /dev/null +++ b/src-tauri/src/exporthtml/parse.rs @@ -0,0 +1,143 @@ +// Transcript reading and the per-field capping that keeps the embedded JSON bounded. Moved +// verbatim from exporthtml.rs. + +use serde_json::{json, Value}; +use std::fs; +use std::path::Path; + +use super::assets::{ + CAP_CONTENT, CAP_PROMPT, CAP_RESULT, CAP_SKILL_SNAPSHOT, CAP_TEXT, CAP_THINKING, +}; + +fn cap(s: &str, n: usize) -> String { + if s.chars().count() > n { + let truncated: String = s.chars().take(n).collect(); + let dropped = s.chars().count() - n; + format!("{}\n…[truncated {} chars]", truncated, dropped) + } else { + s.to_string() + } +} + +pub(super) fn parse_jsonl_result(file: &Path) -> std::io::Result> { + let qoder = crate::qoder::looks_qoder_path(file); + let raw = if qoder { crate::qoder::read_text(file) } else { fs::read_to_string(file) }?; + let records: Vec = raw + .split('\n') + .map(|l| l.trim()) + .filter(|l| !l.is_empty()) + .filter_map(|l| serde_json::from_str::(l).ok()) + .collect(); + Ok(if qoder { + crate::qoder::normalize_records(&records) + } else { + records + }) +} + +/// Skip-on-error variant for subagent sidecars — one broken agent file must not sink the export. +/// The MAIN transcript goes through parse_jsonl_result so a read failure surfaces to the caller +/// instead of exporting an empty page. +pub(super) fn parse_jsonl(file: &Path) -> Vec { + parse_jsonl_result(file).unwrap_or_default() +} + +pub(super) fn usage_of(u: &Value) -> Value { + let mut usage = json!({ + "in": u.get("input_tokens").and_then(|v| v.as_i64()).unwrap_or(0), + "out": u.get("output_tokens").and_then(|v| v.as_i64()).unwrap_or(0), + "cacheRead": u.get("cache_read_input_tokens").and_then(|v| v.as_i64()).unwrap_or(0), + "cacheCreation": u.get("cache_creation_input_tokens").and_then(|v| v.as_i64()).unwrap_or(0), + }); + let object = usage.as_object_mut().unwrap(); + for (source, target) in [ + ("credits", "credits"), + ("original_credits", "originalCredits"), + ("context_usage_ratio", "contextUsageRatio"), + ] { + if let Some(value) = u.get(source).filter(|value| value.is_number()) { + object.insert(target.to_string(), value.clone()); + } + } + usage +} + +pub(super) fn cap_content(content: &Value) -> Value { + if let Some(s) = content.as_str() { + return json!(cap(s, CAP_TEXT)); + } + let arr = match content.as_array() { + Some(a) => a, + None => return content.clone(), + }; + let mapped: Vec = arr + .iter() + .map(|b| { + let ty = b.get("type").and_then(|t| t.as_str()).unwrap_or(""); + match ty { + "text" => json!({ "type": "text", "text": cap(b.get("text").and_then(|t| t.as_str()).unwrap_or(""), CAP_TEXT) }), + "thinking" => json!({ "type": "thinking", "thinking": cap(b.get("thinking").and_then(|t| t.as_str()).unwrap_or(""), CAP_THINKING) }), + "skill_load" => json!({ + "type": "skill_load", + "name": b.get("name").cloned().unwrap_or(Value::Null), + "path": b.get("path").cloned().unwrap_or(Value::Null), + "snapshot": b + .get("snapshot") + .and_then(|v| v.as_str()) + .map(|v| json!(cap(v, CAP_SKILL_SNAPSHOT))) + .unwrap_or(Value::Null), + }), + "tool_use" => { + let mut input = b.get("input").cloned().unwrap_or(json!({})); + if let Some(obj) = input.as_object_mut() { + if let Some(p) = obj.get("prompt").and_then(|v| v.as_str()) { + let c = cap(p, CAP_PROMPT); + obj.insert("prompt".into(), json!(c)); + } + if let Some(p) = obj.get("content").and_then(|v| v.as_str()) { + let c = cap(p, CAP_CONTENT); + obj.insert("content".into(), json!(c)); + } + if let Some(p) = obj.get("patch").and_then(|v| v.as_str()) { + let c = cap(p, CAP_CONTENT); // codex ApplyPatch envelopes can be huge + obj.insert("patch".into(), json!(c)); + } + if let Some(p) = obj.get("code").and_then(|v| v.as_str()) { + let c = cap(p, CAP_CONTENT); // code-mode Script bodies + obj.insert("code".into(), json!(c)); + } + } + json!({ "type": "tool_use", "id": b.get("id").cloned().unwrap_or(Value::Null), "name": b.get("name").cloned().unwrap_or(Value::Null), "input": input }) + } + "tool_result" => { + let c = match b.get("content") { + Some(Value::String(s)) => json!(cap(s, CAP_RESULT)), + Some(Value::Array(ca)) => Value::Array( + ca.iter() + .map(|x| { + if x.get("type").and_then(|t| t.as_str()) == Some("text") { + json!({ "type": "text", "text": cap(x.get("text").and_then(|t| t.as_str()).unwrap_or(""), CAP_RESULT) }) + } else { + x.clone() + } + }) + .collect(), + ), + other => other.cloned().unwrap_or(Value::Null), + }; + json!({ "type": "tool_result", "tool_use_id": b.get("tool_use_id").cloned().unwrap_or(Value::Null), "is_error": b.get("is_error").and_then(|v| v.as_bool()).unwrap_or(false), "content": c }) + } + "image" => { + let oversized = b.get("source").and_then(|s| s.get("data")).and_then(|d| d.as_str()).map(|d| d.len() > 600000).unwrap_or(false); + if oversized { + json!({ "type": "image", "source": { "media_type": b.get("source").and_then(|s| s.get("media_type")).and_then(|m| m.as_str()).unwrap_or("image/png"), "oversized": true } }) + } else { + b.clone() + } + } + _ => b.clone(), + } + }) + .collect(); + Value::Array(mapped) +} diff --git a/src-tauri/src/exporthtml/session.rs b/src-tauri/src/exporthtml/session.rs new file mode 100644 index 0000000..dde3666 --- /dev/null +++ b/src-tauri/src/exporthtml/session.rs @@ -0,0 +1,146 @@ +// Whole-session shaping (messages + totals + timestamps) and the subagent transcript scan. +// Moved verbatim from exporthtml.rs. + +use serde_json::{json, Value}; +use std::fs; +use std::path::Path; + +use super::parse::parse_jsonl; +use super::shape::line_to_msg; + +pub(super) struct Shaped { + pub(super) messages: Vec, + pub(super) model: Option, + pub(super) totals: (i64, i64, i64, i64), + pub(super) cache_creation: i64, + pub(super) credits: Option, + pub(super) token_usage_available: bool, + pub(super) first_ts: Option, + pub(super) last_ts: Option, +} +pub(super) fn shape_session(recs: &[Value]) -> Shaped { + let mut messages = vec![]; + let (mut tin, mut tout, mut tcr, mut tcc, mut turns) = (0i64, 0i64, 0i64, 0i64, 0i64); + let mut credits = 0.0f64; + let mut has_credits = false; + let mut model = None; + let mut first_ts = None; + let mut last_ts = None; + for r in recs { + let lm = match line_to_msg(r) { + Some(m) => m, + None => continue, + }; + if lm.get("meta").and_then(|v| v.as_bool()).unwrap_or(false) { + continue; + } + if let Some(ts) = lm.get("ts").and_then(|v| v.as_str()) { + if first_ts.is_none() { + first_ts = Some(ts.to_string()); + } + last_ts = Some(ts.to_string()); + } + if let Some(md) = lm.get("model").and_then(|v| v.as_str()) { + model = Some(md.to_string()); + } + if let Some(u) = lm.get("usage").filter(|u| u.is_object()) { + tin += u.get("in").and_then(|v| v.as_i64()).unwrap_or(0); + tout += u.get("out").and_then(|v| v.as_i64()).unwrap_or(0); + tcr += u.get("cacheRead").and_then(|v| v.as_i64()).unwrap_or(0); + tcc += u.get("cacheCreation").and_then(|v| v.as_i64()).unwrap_or(0); + if let Some(value) = u.get("credits").and_then(|v| v.as_f64()) { + credits += value; + has_credits = true; + } + turns += 1; + } + messages.push(lm); + } + Shaped { + messages, + model, + totals: (tin, tout, tcr, turns), + cache_creation: tcc, + credits: has_credits.then_some(credits), + token_usage_available: !(has_credits && tin == 0 && tout == 0 && tcr == 0 && tcc == 0), + first_ts, + last_ts, + } +} + +pub(super) fn read_subagents(file: &Path) -> Value { + let qoder = crate::qoder::looks_qoder_path(file); + let dir = file.parent().map(|p| { + p.join(file.file_stem().and_then(|s| s.to_str()).unwrap_or("")) + .join("subagents") + }); + let dir = match dir { + Some(d) => d, + None => return json!({}), + }; + let entries = match fs::read_dir(&dir) { + Ok(e) => e, + Err(_) => return json!({}), + }; + let mut agent_names: Vec = entries + .flatten() + .map(|ent| ent.file_name().to_string_lossy().into_owned()) + .filter(|name| name.starts_with("agent-") && name.ends_with(".jsonl")) + .collect(); + agent_names.sort(); + // A protected qoder session's subagent transcripts + meta sidecars warm in one helper batch. + if qoder { + let mut warm: Vec = vec![]; + for name in &agent_names { + warm.push(dir.join(name)); + let agent_id = name.trim_start_matches("agent-").trim_end_matches(".jsonl"); + warm.push(dir.join(format!("agent-{}.meta.json", agent_id))); + } + crate::qoder::prefetch(&warm); + } + let mut by_tool = serde_json::Map::new(); + for name in agent_names { + let agent_id = name + .trim_start_matches("agent-") + .trim_end_matches(".jsonl") + .to_string(); + let meta_path = dir.join(format!("agent-{}.meta.json", agent_id)); + let meta_raw = if qoder { + crate::qoder::read_text(&meta_path) + } else { + fs::read_to_string(&meta_path) + }; + let meta: Value = meta_raw + .ok() + .and_then(|s| serde_json::from_str(&s).ok()) + .unwrap_or(json!({})); + let recs = parse_jsonl(&dir.join(&name)); + let shaped = shape_session(&recs); + let key = meta + .get("toolUseId") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + .unwrap_or_else(|| format!("agent:{}", agent_id)); + by_tool.insert( + key, + json!({ + "agentId": agent_id, + "type": meta.get("agentType").or_else(|| meta.get("subagent_type")).and_then(|v| v.as_str()).unwrap_or("agent"), + "description": meta.get("description").and_then(|v| v.as_str()).unwrap_or(""), + "skill": crate::history::skill_from_recs(&recs), + "count": shaped.messages.len(), + "totals": { + "in": shaped.totals.0, + "out": shaped.totals.1, + "cacheRead": shaped.totals.2, + "cacheCreation": shaped.cache_creation, + "turns": shaped.totals.3, + "credits": shaped.credits, + "tokenUsageAvailable": shaped.token_usage_available, + }, + "messages": shaped.messages, + }), + ); + } + Value::Object(by_tool) +} diff --git a/src-tauri/src/exporthtml/shape.rs b/src-tauri/src/exporthtml/shape.rs new file mode 100644 index 0000000..4b4f397 --- /dev/null +++ b/src-tauri/src/exporthtml/shape.rs @@ -0,0 +1,102 @@ +// One transcript line → one viewer message, plus the small text helpers the title and the +// filename derive from. Moved verbatim from exporthtml.rs. + +use serde_json::{json, Value}; + +use super::parse::{cap_content, usage_of}; + +pub(super) fn line_to_msg(rec: &Value) -> Option { + let ty = rec.get("type").and_then(|v| v.as_str())?; + if ty != "user" && ty != "assistant" { + return None; + } + let m = rec.get("message")?; + m.get("role").and_then(|v| v.as_str())?; + let mut out = json!({ + "role": m.get("role").cloned().unwrap_or(Value::Null), + "content": cap_content(m.get("content").unwrap_or(&Value::Null)), + "ts": rec.get("timestamp").cloned().unwrap_or(Value::Null), + "meta": rec.get("isMeta").and_then(|v| v.as_bool()).unwrap_or(false), + }); + if ty == "assistant" { + let o = out.as_object_mut().unwrap(); + o.insert( + "model".into(), + m.get("model").cloned().unwrap_or(Value::Null), + ); + o.insert( + "usage".into(), + m.get("usage").map(usage_of).unwrap_or(Value::Null), + ); + o.insert( + "stop".into(), + m.get("stop_reason").cloned().unwrap_or(Value::Null), + ); + } + Some(out) +} + +fn content_text(content: &Value) -> String { + if let Some(s) = content.as_str() { + return s.to_string(); + } + if let Some(arr) = content.as_array() { + return arr + .iter() + .filter(|b| b.get("type").and_then(|t| t.as_str()) == Some("text")) + .filter_map(|b| b.get("text").and_then(|t| t.as_str())) + .collect::>() + .join(" "); + } + String::new() +} +fn command_label(raw: &str) -> String { + let name = raw + .split_once("") + .and_then(|(_, r)| r.split_once("")) + .map(|(n, _)| n.trim().to_string()) + .unwrap_or_default(); + if name.is_empty() { + return String::new(); + } + let args = raw + .split_once("") + .and_then(|(_, r)| r.split_once("")) + .map(|(a, _)| a.trim().to_string()) + .unwrap_or_default(); + format!("{} {}", name, args).trim().to_string() +} +pub(super) fn first_user_text(messages: &[Value]) -> String { + let mut fallback = String::new(); + for m in messages { + if m.get("role").and_then(|r| r.as_str()) != Some("user") + || m.get("meta").and_then(|v| v.as_bool()).unwrap_or(false) + { + continue; + } + let raw = content_text(m.get("content").unwrap_or(&Value::Null)); + let raw = raw.trim(); + if raw.is_empty() { + continue; + } + if raw.starts_with('<') { + if fallback.is_empty() { + fallback = command_label(raw); + } + continue; + } + let t: String = raw.split_whitespace().collect::>().join(" "); + if t.starts_with("[Request interrupted") || t.starts_with("Caveat:") { + continue; + } + return t.chars().take(100).collect(); + } + fallback.chars().take(100).collect() +} +pub(super) fn base_name(p: &str) -> String { + p.split('/') + .filter(|s| !s.is_empty()) + .last() + .unwrap_or(p) + .to_string() +} diff --git a/src-tauri/src/exporthtml/tests.rs b/src-tauri/src/exporthtml/tests.rs new file mode 100644 index 0000000..9680e63 --- /dev/null +++ b/src-tauri/src/exporthtml/tests.rs @@ -0,0 +1,129 @@ +use super::parse::parse_jsonl; +use serde_json::{json, Value}; +use std::fs; +use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; + +pub(super) fn qoder_fixture(name: &str) -> (PathBuf, PathBuf) { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let root = std::env::temp_dir().join(format!( + "ccbud-exporthtml-{name}-{}-{nonce}", + std::process::id() + )); + let file = root + .join(".qoder") + .join("projects") + .join("-work-project") + .join("session.jsonl"); + fs::create_dir_all(file.parent().unwrap()).unwrap(); + (root, file) +} + +pub(super) fn write_jsonl(path: &Path, records: &[Value]) { + let raw = records + .iter() + .map(|record| serde_json::to_string(record).unwrap()) + .collect::>() + .join("\n"); + fs::write(path, format!("{raw}\n")).unwrap(); +} + +pub(super) fn streamed_assistant_records() -> Vec { + vec![ + json!({ + "type": "assistant", + "uuid": "wrap-1", + "message": { + "id": "msg-1", + "role": "assistant", + "model": "", + "content": [{ "type": "thinking", "thinking": "plan" }], + "stop_reason": null, + }, + }), + json!({ + "type": "assistant", + "uuid": "wrap-2", + "message": { + "id": "msg-1", + "role": "assistant", + "model": "ultimate", + "content": [{ "type": "redacted_thinking", "data": "opaque" }], + "stop_reason": null, + }, + }), + json!({ + "type": "assistant", + "uuid": "wrap-3", + "message": { + "id": "msg-1", + "role": "assistant", + "model": "ultimate", + "content": [{ + "type": "tool_use", + "id": "tool-1", + "name": "Read", + "input": { "file_path": "/work/a" }, + }], + "usage": { "input_tokens": 7, "output_tokens": 3 }, + "stop_reason": "tool_use", + }, + }), + ] +} + +#[test] +fn qoder_jsonl_normalizes_streamed_assistant_records() { + let (root, file) = qoder_fixture("normalize"); + write_jsonl(&file, &streamed_assistant_records()); + + let records = parse_jsonl(&file); + assert_eq!(records.len(), 1); + assert_eq!( + records[0].get("uuid").and_then(Value::as_str), + Some("wrap-1") + ); + let message = records[0].get("message").unwrap(); + assert_eq!( + message.get("model").and_then(Value::as_str), + Some("ultimate") + ); + assert_eq!( + message.get("stop_reason").and_then(Value::as_str), + Some("tool_use") + ); + assert_eq!( + message + .get("usage") + .and_then(|usage| usage.get("input_tokens")) + .and_then(Value::as_i64), + Some(7) + ); + let content = message.get("content").and_then(Value::as_array).unwrap(); + assert_eq!(content.len(), 2); + assert_eq!( + content[0].get("type").and_then(Value::as_str), + Some("thinking") + ); + assert_eq!( + content[1].get("type").and_then(Value::as_str), + Some("tool_use") + ); + + fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn ordinary_jsonl_keeps_streamed_records_unchanged() { + let (root, _) = qoder_fixture("ordinary"); + let file = root.join("ordinary.jsonl"); + write_jsonl(&file, &streamed_assistant_records()); + + let records = parse_jsonl(&file); + assert_eq!(records.len(), 3); + + fs::remove_dir_all(root).unwrap(); +} diff --git a/src-tauri/src/exporthtml/tests_more.rs b/src-tauri/src/exporthtml/tests_more.rs new file mode 100644 index 0000000..eb831f9 --- /dev/null +++ b/src-tauri/src/exporthtml/tests_more.rs @@ -0,0 +1,152 @@ +use super::build::build_data; +use super::tests::{qoder_fixture, write_jsonl}; +use serde_json::{json, Value}; +use std::fs; + +#[test] +fn qoder_export_uses_record_metadata_and_reads_subagent_meta() { + let (root, file) = qoder_fixture("metadata"); + write_jsonl( + &file, + &[ + json!({ + "type": "workspace-directories", + "sessionId": "session", + "directories": ["/work/project"], + }), + json!({ + "type": "runtime-config", + "sessionId": "session", + "model": "ultimate", + }), + json!({ + "type": "ai-title", + "sessionId": "session", + "aiTitle": "Inline Qoder title", + }), + json!({ + "type": "user", + "uuid": "user-1", + "timestamp": "2026-08-04T08:00:00Z", + "message": { "role": "user", "content": "User fallback title" }, + }), + json!({ + "type": "assistant", + "uuid": "assistant-1", + "timestamp": "2026-08-04T08:01:00Z", + "message": { + "id": "answer-1", + "role": "assistant", + "model": "message-model", + "content": [{ "type": "text", "text": "Done" }], + "usage": { "input_tokens": 2, "output_tokens": 1 }, + "stop_reason": "end_turn", + }, + }), + ], + ); + fs::write( + file.parent().unwrap().join("session-session.json"), + r#"{"title":"stale companion title","working_dir":"/stale/path"}"#, + ) + .unwrap(); + + let subagent_dir = file.parent().unwrap().join("session").join("subagents"); + fs::create_dir_all(&subagent_dir).unwrap(); + write_jsonl( + &subagent_dir.join("agent-a.jsonl"), + &[ + json!({ + "type": "user", + "uuid": "sub-user", + "message": { "role": "user", "content": "Investigate" }, + }), + json!({ + "type": "assistant", + "uuid": "sub-assistant", + "message": { + "id": "sub-answer", + "role": "assistant", + "model": "ultimate", + "content": [{ "type": "text", "text": "Found it" }], + }, + }), + ], + ); + fs::write( + subagent_dir.join("agent-a.meta.json"), + r#"{"toolUseId":"tool-a","agentType":"Explore","description":"trace"}"#, + ) + .unwrap(); + + let data = build_data(&file.to_string_lossy()); + let meta = data.get("meta").unwrap(); + assert_eq!(meta.get("assistant").and_then(Value::as_str), Some("Qoder")); + assert_eq!( + meta.get("title").and_then(Value::as_str), + Some("Inline Qoder title") + ); + assert_eq!( + meta.get("cwd").and_then(Value::as_str), + Some("/work/project") + ); + assert_eq!(meta.get("project").and_then(Value::as_str), Some("project")); + assert_eq!(meta.get("model").and_then(Value::as_str), Some("ultimate")); + assert_eq!(meta.get("subagentCount").and_then(Value::as_u64), Some(1)); + let subagent = data + .get("subagents") + .and_then(|subagents| subagents.get("tool-a")) + .unwrap(); + assert_eq!( + subagent.get("type").and_then(Value::as_str), + Some("Explore") + ); + assert_eq!( + subagent.get("description").and_then(Value::as_str), + Some("trace") + ); + assert_eq!(subagent.get("count").and_then(Value::as_u64), Some(2)); + + fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn qoder_export_falls_back_to_top_level_cwd_and_assistant_model() { + let (root, file) = qoder_fixture("metadata-fallback"); + write_jsonl( + &file, + &[ + json!({ + "type": "user", + "uuid": "user-1", + "sessionId": "session", + "cwd": "/legacy/work", + "message": { "role": "user", "content": "Fallback title" }, + }), + json!({ + "type": "assistant", + "uuid": "assistant-1", + "message": { + "id": "answer-1", + "role": "assistant", + "model": "legacy-model", + "content": [{ "type": "text", "text": "Done" }], + }, + }), + ], + ); + + let data = build_data(&file.to_string_lossy()); + let meta = data.get("meta").unwrap(); + assert_eq!( + meta.get("cwd").and_then(Value::as_str), + Some("/legacy/work") + ); + assert_eq!(meta.get("project").and_then(Value::as_str), Some("work")); + assert_eq!( + meta.get("model").and_then(Value::as_str), + Some("legacy-model") + ); + + fs::remove_dir_all(root).unwrap(); +} diff --git a/src-tauri/src/gateway.rs b/src-tauri/src/gateway.rs deleted file mode 100644 index d201c78..0000000 --- a/src-tauri/src/gateway.rs +++ /dev/null @@ -1,3286 +0,0 @@ -// Gateway core. -// -// Implements deterministic model routing and the localhost reverse proxy: header sanitizing, -// upstream forwarding, 429 retry, SSE streaming with model rewrite + usage sniffing, buffered-JSON -// model rewrite, /v1/models merge/synthesize, count_tokens fallback, HEAD / fallback, and bounded -// monitor exchange capture. -#![allow(dead_code)] - -use axum::{ - body::{to_bytes, Body}, - extract::State, - http::{HeaderMap, HeaderValue, Method, StatusCode, Uri}, - response::Response, - Router, -}; -use bytes::Bytes; -use futures_util::StreamExt; -use serde_json::{json, Value}; -use std::collections::{HashMap, HashSet, VecDeque}; -use std::sync::atomic::{AtomicU64, Ordering}; -use std::sync::Arc; -use tauri::Emitter; -use tokio::sync::{oneshot, Mutex}; - -use crate::protocol::codex_history::{HistoryResolution, ResponseOrigin}; -use crate::store; - -/// Default Claude tier models ccbud advertises to Claude-family clients (Claude Code). -pub const CLAUDE_TIER_MODELS: &[&str] = &[ - "claude-fable-5", - "claude-opus-4-8", - "claude-sonnet-5", - "claude-haiku-4-5", - "claude-haiku-4-5-20251001", -]; - -/// Stable Codex model identities advertised by the gateway. These names are understood by the -/// current Codex CLI and keep its ordinary function/custom tool registry enabled; synthetic -/// `gpt-5.6-sol*` identities select Codex's code-mode metadata and produce an empty Responses -/// `tools` array against a generic custom provider. -pub const CODEX_TIER_MODELS: &[&str] = &["gpt-5.4", "gpt-5.4-mini"]; - -/// Which coding-agent family a model name belongs to. Claude Code sends `claude-*`, -/// Codex sends `gpt-*`; each names its primary vs fast tier differently. -enum ModelFamily { - Claude, - Codex, - Other, -} -fn model_family(name: &str) -> ModelFamily { - let n = name.to_ascii_lowercase(); - if n.starts_with("claude-") || n.starts_with("claude_") { - ModelFamily::Claude - } else if n.starts_with("gpt-") || n.starts_with("gpt_") { - ModelFamily::Codex - } else { - ModelFamily::Other - } -} -/// Claude fast/light tier = the haiku models; fable/opus/sonnet (and any other -/// claude-*) route to the primary model. -fn is_claude_fast(name: &str) -> bool { - name.to_ascii_lowercase().contains("haiku") -} -/// The stable auto-connect identity and legacy `sol` / `terra` aliases route to primary. Explicit -/// small-model identities route to fast; other foreign `gpt-*` names retain the historical fast -/// fallback instead of unexpectedly consuming the primary provider model. -fn is_codex_primary(name: &str) -> bool { - let lower = name.to_ascii_lowercase(); - if lower == "gpt-5.4" { - return true; - } - let segments = lower - .split(|c| c == '-' || c == '_') - .collect::>(); - !segments - .iter() - .any(|seg| matches!(*seg, "mini" | "nano" | "luna" | "spark")) - && segments.iter().any(|seg| matches!(*seg, "sol" | "terra")) -} -/// True if the request comes from a Codex/OpenAI-family client (vs Claude), detected by -/// the client's self-reported identity — User-Agent, or Codex's `originator` header. -fn client_is_codex(h: &HeaderMap) -> bool { - let field = |k: &str| h.get(k).and_then(|v| v.to_str().ok()).unwrap_or("").to_ascii_lowercase(); - field("user-agent").contains("codex") || field("originator").contains("codex") -} - -#[derive(Debug, Clone)] -pub struct Routing { - pub provider_id: String, - pub outgoing_model: Option, - pub client_facing_model: Option, -} - -// Claude Code rebuilds assistant tool_use history from its known fields and drops provider -// metadata, so Gemini's signature cannot round-trip through the Anthropic wire. Keep a bounded, -// session-scoped server-side copy and restore it before the next Google/OpenAI-compatible request. -const THOUGHT_SIGNATURE_TTL_MS: i64 = 6 * 60 * 60 * 1000; -const THOUGHT_SIGNATURE_CACHE_MAX: usize = 2048; -const GEMINI_SIGNATURE_FALLBACK: &str = "skip_thought_signature_validator"; - -#[derive(Clone, Debug, PartialEq, Eq)] -struct CachedToolCall { - call_id: String, - name: String, - arguments: String, - signature: Option, -} - -#[derive(Clone, Debug)] -struct ThoughtSignatureBatch { - calls: Vec, - touched_at: i64, -} - -#[derive(Default)] -struct ThoughtSignatureCache { - batches: HashMap<(String, String), ThoughtSignatureBatch>, -} - -fn canonical_tool_arguments(arguments: &str) -> String { - if arguments.trim().is_empty() { - return "{}".to_string(); - } - serde_json::from_str::(arguments) - .map(|v| v.to_string()) - .unwrap_or_else(|_| arguments.to_string()) -} - -/// Codex records a model-emitted function call even when the host cannot parse its arguments, then -/// sends that failed call back on the next Responses turn beside the router error. OpenAI accepts -/// the arguments as an opaque string, but stricter chat providers (notably Gemini) parse every -/// historical `tool_calls[].function.arguments` value and reject the whole request when a model -/// appended prose or a second object. Preserve valid object arguments byte-for-byte so cached -/// thought signatures still match; otherwise salvage the first complete object, or wrap the raw -/// text in a valid object as a last resort. -fn provider_safe_history_tool_arguments(arguments: &str) -> Option { - let trimmed = arguments.trim(); - if trimmed.is_empty() { - return Some("{}".to_string()); - } - match serde_json::from_str::(arguments) { - Ok(Value::Object(_)) => return None, - Ok(value) => return Some(json!({ "_ccbuddy_value": value }).to_string()), - Err(_) => {} - } - if let Some(Ok(Value::Object(object))) = serde_json::Deserializer::from_str(trimmed) - .into_iter::() - .next() - { - return Some(Value::Object(object).to_string()); - } - Some(json!({ "_ccbuddy_raw_arguments": arguments }).to_string()) -} - -fn sanitize_provider_history_tool_arguments( - request: &mut llm_connector::types::ChatRequest, -) -> usize { - let mut repaired = 0usize; - for message in &mut request.messages { - let Some(calls) = message.tool_calls.as_mut() else { continue }; - for call in calls { - let Some(arguments) = provider_safe_history_tool_arguments(&call.function.arguments) - else { continue }; - call.function.arguments = arguments; - // A provider signature authenticates the exact call payload. Repaired arguments must - // use the documented synthetic-history fallback instead of a now-stale real signature. - call.thought_signature = None; - call.function.thought_signature = None; - repaired += 1; - } - } - repaired -} - -fn current_tool_turn_start(request: &llm_connector::types::ChatRequest) -> usize { - request.messages.iter() - .rposition(|message| message.role == llm_connector::types::Role::User) - .map(|index| index + 1) - .unwrap_or(0) -} - -impl ThoughtSignatureCache { - fn prune(&mut self, now: i64) { - self.batches.retain(|_, batch| { - now.saturating_sub(batch.touched_at) <= THOUGHT_SIGNATURE_TTL_MS - }); - } - - fn remember( - &mut self, - provider_id: &str, - session_id: Option<&str>, - captured_calls: &[crate::protocol::stream::CapturedToolCall], - ) { - let now = now_ms(); - self.prune(now); - let Some(session_id) = session_id else { return }; - let calls: Vec = captured_calls.iter().map(|call| CachedToolCall { - call_id: call.call_id.clone(), - name: call.name.clone(), - arguments: canonical_tool_arguments(&call.arguments), - signature: call.thought_signature.as_deref() - .filter(|signature| !signature.is_empty()) - .map(str::to_string), - }).collect(); - let key = (provider_id.to_string(), session_id.to_string()); - if !calls.iter().any(|call| call.signature.is_some()) { - if !calls.is_empty() { - self.batches.remove(&key); - } - return; - } - - if self.batches.len() >= THOUGHT_SIGNATURE_CACHE_MAX && !self.batches.contains_key(&key) { - if let Some(oldest) = self.batches.iter() - .min_by_key(|(_, batch)| batch.touched_at) - .map(|(key, _)| key.clone()) - { - self.batches.remove(&oldest); - } - } - // Replacing the latest batch also makes terminal/EOF observations idempotent. - self.batches.insert(key, ThoughtSignatureBatch { - calls, - touched_at: now, - }); - } - - fn restore( - &mut self, - provider_id: &str, - session_id: Option<&str>, - request: &mut llm_connector::types::ChatRequest, - ) -> usize { - let now = now_ms(); - self.prune(now); - let Some(session_id) = session_id else { return 0 }; - let current_turn = current_tool_turn_start(request); - let Some(message_index) = request.messages.iter() - .enumerate() - .skip(current_turn) - .rev() - .find_map(|(message_index, message)| { - message.tool_calls.as_ref() - .filter(|calls| !calls.is_empty()) - .map(|_| message_index) - }) - else { return 0 }; - let Some(calls) = request.messages[message_index].tool_calls.as_mut() else { return 0 }; - let key = (provider_id.to_string(), session_id.to_string()); - let Some(batch) = self.batches.get_mut(&key) else { return 0 }; - if batch.calls.len() != calls.len() - || !batch.calls.iter().zip(calls.iter()).all(|(cached, current)| { - cached.call_id == current.id - && cached.name == current.function.name - && cached.arguments == canonical_tool_arguments(¤t.function.arguments) - }) - { - return 0; - } - batch.touched_at = now; - let mut restored = 0usize; - for (call, cached) in calls.iter_mut().zip(&batch.calls) { - if crate::protocol::tool_call_thought_signature(call).is_none() { - if let Some(signature) = &cached.signature { - call.thought_signature = Some(signature.clone()); - restored += 1; - } - } - } - restored - } -} - -/// Google documents this sentinel for function-call history that did not originate from the -/// current API response (transferred/synthetic history). We use it only when Claude stripped the -/// real signature and the session cache cannot recover it. For parallel calls, only the first call -/// in a model step gets a signature, matching Gemini's validation contract. -fn apply_gemini_signature_fallback(request: &mut llm_connector::types::ChatRequest) -> usize { - let mut applied = 0usize; - // Gemini validates only the current turn: everything after the most recent ordinary user - // message. Tool results decode as Role::Tool, so sequential tool steps remain in this slice. - let current_turn = current_tool_turn_start(request); - for message in request.messages.iter_mut().skip(current_turn) { - let Some(calls) = message.tool_calls.as_mut() else { continue }; - if calls.is_empty() - || crate::protocol::tool_call_thought_signature(&calls[0]).is_some() - { - continue; - } - calls[0].thought_signature = Some(GEMINI_SIGNATURE_FALLBACK.to_string()); - applied += 1; - } - applied -} - -fn request_session_id(body: &Value) -> Option { - // Claude Code: metadata.user_id is a JSON string carrying session_id. - if let Some(raw) = body.pointer("/metadata/user_id").and_then(Value::as_str) { - if let Ok(metadata) = serde_json::from_str::(raw.trim()) { - if let Some(session) = metadata.get("session_id").and_then(Value::as_str) - .filter(|session| !session.is_empty()) - { - return Some(session.to_string()); - } - } - } - // Codex (Responses client): prompt_cache_key carries the conversation id. - body.get("prompt_cache_key").and_then(Value::as_str) - .map(str::trim) - .filter(|session| !session.is_empty()) - .map(str::to_string) -} - -fn codex_history_scope_for_session(request_session: Option<&str>) -> String { - request_session.unwrap_or("").to_string() -} - -fn response_tool_calls( - response: &llm_connector::types::ChatResponse, -) -> Vec { - response.choices.first().and_then(|choice| choice.message.tool_calls.as_ref()) - .map(|calls| calls.iter().map(|call| { - crate::protocol::stream::CapturedToolCall { - call_id: call.id.clone(), - name: call.function.name.clone(), - arguments: call.function.arguments.clone(), - thought_signature: crate::protocol::tool_call_thought_signature(call), - } - }).collect()) - .unwrap_or_default() -} - -fn response_tool_calls_with_client_ids( - response: &llm_connector::types::ChatResponse, - encoded_response: &Value, -) -> Vec { - let mut captured = response_tool_calls(response); - let client_ids = encoded_response - .get("output") - .and_then(Value::as_array) - .into_iter() - .flatten() - .filter(|item| { - matches!( - item.get("type").and_then(Value::as_str), - Some("function_call" | "custom_tool_call" | "tool_search_call") - ) - }) - .filter_map(|item| item.get("call_id").and_then(Value::as_str)); - for (call, client_id) in captured.iter_mut().zip(client_ids) { - call.call_id = client_id.to_string(); - } - captured -} - -/// Decide how to route a request and translate its model name. Mirrors proxy.js `resolveRouting`. -pub fn resolve_routing( - requested_model: Option<&str>, - config: &Value, - known_models: Option<&HashSet>, -) -> Option { - let providers = config.get("providers")?.as_array()?; - if providers.is_empty() { - return None; - } - let active_id = config.get("activeProviderId").and_then(|v| v.as_str()); - let active = providers - .iter() - .find(|p| p.get("id").and_then(|v| v.as_str()) == active_id) - .or_else(|| providers.first())?; - let pid = active.get("id").and_then(|v| v.as_str()).unwrap_or("").to_string(); - - let pass = |m: &str| { - Some(Routing { - provider_id: pid.clone(), - outgoing_model: Some(m.to_string()), - client_facing_model: Some(m.to_string()), - }) - }; - - let requested = match requested_model { - None => { - return Some(Routing { - provider_id: pid.clone(), - outgoing_model: None, - client_facing_model: None, - }) - } - Some(m) => m, - }; - - let primary = active.get("defaultModel").and_then(|v| v.as_str()).unwrap_or(""); - let light = active.get("smallFastModel").and_then(|v| v.as_str()).unwrap_or(""); - let models = active.get("models").and_then(|v| v.as_array()); - - if let Some(ms) = models { - for m in ms { - let alias = m.get("alias").and_then(|v| v.as_str()).unwrap_or(""); - let upstream = m.get("upstream").and_then(|v| v.as_str()).unwrap_or(""); - if !alias.is_empty() && alias == requested && !upstream.is_empty() { - return Some(Routing { - provider_id: pid.clone(), - outgoing_model: Some(upstream.to_string()), - client_facing_model: Some(requested.to_string()), - }); - } - } - } - if requested == primary || requested == light { - return pass(requested); - } - if let Some(ms) = models { - for m in ms { - if m.get("upstream").and_then(|v| v.as_str()) == Some(requested) { - return pass(requested); - } - } - } - if let Some(known) = known_models { - if known.contains(requested) { - return pass(requested); - } - } - // Codex connects with the sentinel model "gpt-5.5-ccbud" — a name Codex's model-family - // detection accepts (gpt-5.5 prefix), so it doesn't warn about an unknown model. Route the - // sentinel to the active provider's PRIMARY model (never the lightweight fallback). - if requested.ends_with("-ccbud") { - let target = if !primary.is_empty() { primary } else { light }; - if !target.is_empty() { - return Some(Routing { - provider_id: pid.clone(), - outgoing_model: Some(target.to_string()), - client_facing_model: Some(requested.to_string()), - }); - } - } - let map_default = active - .get("mapDefaultModels") - .map(|v| v.as_bool().unwrap_or(true)) - .unwrap_or(true); - if !map_default { - return pass(requested); - } - let big = if !primary.is_empty() { primary } else { light }; - let small = if !light.is_empty() { light } else { primary }; - // Claude and Codex name their primary vs fast tiers differently, so classify by - // family: claude-haiku* → fast, other claude-* → primary; gpt-*-sol / gpt-*-terra - // → primary, other gpt-* → fast; anything else → fast. - let target = match model_family(requested) { - ModelFamily::Claude => if is_claude_fast(requested) { small } else { big }, - ModelFamily::Codex => if is_codex_primary(requested) { big } else { small }, - ModelFamily::Other => small, - }; - if !target.is_empty() { - return Some(Routing { - provider_id: pid.clone(), - outgoing_model: Some(target.to_string()), - client_facing_model: Some(requested.to_string()), - }); - } - pass(requested) -} - -// ---------------- gateway runtime ---------------- - -pub struct GatewayState { - app: tauri::AppHandle, - known: Mutex>>, - thought_signatures: Mutex, - codex_history: crate::protocol::codex_history::CodexHistoryStore, - seq: AtomicU64, - running: Mutex>, - // Sync mirror of the bound port (0 = stopped) for callers that can't await (tray refresh). - running_port: std::sync::atomic::AtomicU32, - exchanges: Mutex>, - client: reqwest::Client, - client_insecure: reqwest::Client, - // Ring buffer of recent gateway log lines (seq+ts stamped) so the settings Logs panel can - // backfill on open — mirrors main.js gatewayLogs (cap 80). std Mutex: log() is sync. - logs: std::sync::Mutex>, - log_seq: AtomicU64, -} -struct RunningServer { - port: u16, - shutdown: oneshot::Sender<()>, -} - -impl GatewayState { - pub fn new(app: tauri::AppHandle) -> Arc { - let client = reqwest::Client::builder() - .build() - .unwrap_or_else(|_| reqwest::Client::new()); - let client_insecure = reqwest::Client::builder() - .danger_accept_invalid_certs(true) - .build() - .unwrap_or_else(|_| reqwest::Client::new()); - Arc::new(Self { - app, - known: Mutex::new(HashMap::new()), - thought_signatures: Mutex::new(ThoughtSignatureCache::default()), - codex_history: crate::protocol::codex_history::CodexHistoryStore::default(), - seq: AtomicU64::new(0), - running: Mutex::new(None), - running_port: std::sync::atomic::AtomicU32::new(0), - exchanges: Mutex::new(VecDeque::new()), - client, - client_insecure, - logs: std::sync::Mutex::new(VecDeque::new()), - log_seq: AtomicU64::new(0), - }) - } - - pub fn log(&self, level: &str, msg: impl AsRef) { - let seq = self.log_seq.fetch_add(1, Ordering::Relaxed) + 1; - let ts = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_millis() as u64) - .unwrap_or(0); - let entry = json!({ "seq": seq, "ts": ts, "level": level, "msg": msg.as_ref() }); - if let Ok(mut buf) = self.logs.lock() { - buf.push_back(entry.clone()); - while buf.len() > 80 { - buf.pop_front(); - } - } - let _ = self.app.emit("gateway:log", entry); - } - - /// Snapshot of the recent-log ring, oldest→newest (logs_get backfill). - pub fn logs_snapshot(&self) -> Value { - self.logs - .lock() - .map(|b| Value::Array(b.iter().cloned().collect())) - .unwrap_or_else(|_| json!([])) - } - pub fn logs_clear(&self) { - if let Ok(mut b) = self.logs.lock() { - b.clear(); - } - } - - pub async fn status(&self) -> Value { - match self.running.lock().await.as_ref() { - Some(rs) => json!({ "running": true, "port": rs.port }), - None => json!({ "running": false, "port": Value::Null }), - } - } - - pub async fn current_port(&self) -> Option { - self.running.lock().await.as_ref().map(|r| r.port) - } - - /// Sync view of the running state (tray menu refresh runs on the main thread, no await). - pub fn port_sync(&self) -> Option { - match self.running_port.load(Ordering::Relaxed) { - 0 => None, - p => Some(p as u16), - } - } - - pub fn emit(&self, event: &str, payload: Value) { - let _ = self.app.emit(event, payload); - } - - pub async fn start(self: &Arc, port: u16) -> Result { - if let Some(rs) = self.running.lock().await.as_ref() { - return Ok(rs.port); - } - let listener = tokio::net::TcpListener::bind(("127.0.0.1", port)) - .await - .map_err(|e| e.to_string())?; - let actual = listener.local_addr().map_err(|e| e.to_string())?.port(); - let (tx, rx) = oneshot::channel::<()>(); - let router = Router::new().fallback(handle).with_state(self.clone()); - tauri::async_runtime::spawn(async move { - let _ = axum::serve(listener, router) - .with_graceful_shutdown(async move { - let _ = rx.await; - }) - .await; - }); - *self.running.lock().await = Some(RunningServer { port: actual, shutdown: tx }); - self.running_port.store(actual as u32, Ordering::Relaxed); - self.log("info", format!("gateway listening on http://127.0.0.1:{}", actual)); - let status = self.status().await; - let _ = self.app.emit("gateway:status", status); - Ok(actual) - } - - pub async fn stop(self: &Arc) { - self.running_port.store(0, Ordering::Relaxed); - let taken = self.running.lock().await.take(); - if let Some(rs) = taken { - let _ = rs.shutdown.send(()); - self.log("info", "gateway stopped"); - } - let status = self.status().await; - let _ = self.app.emit("gateway:status", status); - } - - fn next_id(&self) -> u64 { - self.seq.fetch_add(1, Ordering::Relaxed) + 1 - } - /// Bounded live-debugging capture: keep only the most recent exchanges (matches the monitor - /// stream's 100-row window so every visible row can open its detail). - pub async fn record_exchange(&self, ex: Value) { - let mut buf = self.exchanges.lock().await; - buf.push_back(ex); - while buf.len() > 100 { - buf.pop_front(); - } - } - pub async fn monitor_get(&self, id: i64) -> Value { - let buf = self.exchanges.lock().await; - buf.iter() - .rev() - .find(|e| e.get("id").and_then(|v| v.as_i64()) == Some(id)) - .cloned() - .unwrap_or(Value::Null) - } - pub async fn monitor_clear(&self) { - self.exchanges.lock().await.clear(); - } - pub async fn monitor_recent(&self) -> Value { - self.exchanges.lock().await.back().cloned().unwrap_or(Value::Null) - } - - fn emit_request(&self, id: u64, started: std::time::Instant, method: &Method, path: &str, provider: &str, routing: &Routing, status: u16, usage: Option<&UsageAcc>) { - let (it, ot, cr, cc) = usage - .map(|u| (u.input, u.output, u.cache_read, u.cache_creation)) - .unwrap_or((0, 0, 0, 0)); - let _ = self.app.emit( - "gateway:request", - json!({ - "id": id, - "method": method.as_str(), - "path": path, - "provider": provider, - "requestedModel": routing.client_facing_model, - "outgoingModel": routing.outgoing_model, - "clientFacingModel": routing.client_facing_model, - "status": status, - "ms": started.elapsed().as_millis() as u64, - "inputTokens": it, "outputTokens": ot, "cacheRead": cr, "cacheCreation": cc, - }), - ); - } -} - -#[derive(Default, Clone)] -struct UsageAcc { - input: i64, - output: i64, - cache_read: i64, - cache_creation: i64, - saw: bool, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum ResponsesForwardMode { - Original, - Materialized, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -struct ResponsesHistoryDecision { - forward: ResponsesForwardMode, - descendant_materializable: bool, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum ResponsesHistoryError { - Unavailable, -} - -#[derive(Clone)] -struct NativeResponsesHistoryContext { - scope: String, - request: Value, - provider_id: String, - materializable: bool, -} - -fn decide_responses_history( - provider_wire: crate::protocol::Wire, - provider_id: &str, - resolution: &HistoryResolution, -) -> Result { - if !resolution.had_previous_response_id { - return Ok(ResponsesHistoryDecision { - forward: if resolution.changed > 0 { - ResponsesForwardMode::Materialized - } else { - ResponsesForwardMode::Original - }, - descendant_materializable: true, - }); - } - - if provider_wire != crate::protocol::Wire::OpenAiResponses { - return (resolution.previous_found && resolution.previous_materialized) - .then_some(ResponsesHistoryDecision { - forward: ResponsesForwardMode::Materialized, - descendant_materializable: true, - }) - .ok_or(ResponsesHistoryError::Unavailable); - } - - if !resolution.previous_found { - // Restart compatibility: the selected native provider may still own this id even though - // the gateway cache does not. Keep the id intact, but do not make descendants portable. - return Ok(ResponsesHistoryDecision { - forward: ResponsesForwardMode::Original, - descendant_materializable: false, - }); - } - - let same_native_owner = matches!( - resolution.previous_origin.as_ref(), - Some(ResponseOrigin::Native(owner)) if owner == provider_id - ); - if same_native_owner { - return Ok(ResponsesHistoryDecision { - forward: ResponsesForwardMode::Original, - descendant_materializable: resolution.previous_materialized, - }); - } - - resolution - .previous_materialized - .then_some(ResponsesHistoryDecision { - forward: ResponsesForwardMode::Materialized, - descendant_materializable: true, - }) - .ok_or(ResponsesHistoryError::Unavailable) -} - -fn decide_responses_compact_history( - provider_id: &str, - resolution: &HistoryResolution, -) -> Result { - decide_responses_history( - crate::protocol::Wire::OpenAiResponses, - provider_id, - resolution, - ) - .map(|decision| decision.forward) -} - -fn request_body_with_model(request: &Value, outgoing_model: Option<&str>) -> Option { - let mut request = request.clone(); - if let (Some(object), Some(model)) = (request.as_object_mut(), outgoing_model) { - object.insert("model".to_string(), Value::String(model.to_string())); - } - serde_json::to_vec(&request).ok().map(Bytes::from) -} - -fn apply_responses_chat_request_controls(body: &mut Value, request: &Value) { - if let Some(parallel_tool_calls) = request - .get("parallel_tool_calls") - .and_then(Value::as_bool) - { - body["parallel_tool_calls"] = json!(parallel_tool_calls); - } -} - -/// Makes a streaming request visible in the monitor even when the client aborts mid-stream. -/// The row + exchange record are normally emitted at the END of the response generator; when the -/// client disconnects, axum simply drops the generator and that code never runs — the request -/// vanished from the request stream (Codex users interrupt turns constantly). The generator owns -/// this guard: `complete()` hands back the prepared exchange (bodies filled) for the normal path, -/// and Drop-without-complete emits the row + a record. -/// -/// The response capture buffers live IN the guard rather than in generator locals: a dropped -/// generator then still records whatever already streamed through. This matters beyond real -/// aborts — Responses clients (Codex) tear the connection down the moment the terminal -/// `response.completed` event arrives, before upstream EOF, which used to lose BOTH response -/// bodies on every transcoded turn. When the transcoder has already emitted its terminal event -/// (`finished`), that disconnect is the normal end of a turn and is not flagged `aborted`. -struct StreamAbortGuard { - armed: bool, - st: Arc, - id: u64, - started: std::time::Instant, - method: Method, - path: String, - provider: String, - routing: Routing, - status: u16, - ex: Value, - res_cap: String, - up_cap: Option, - finished: bool, - usage: Option, -} - -/// Raw upstream capture (pre-translation) for transcoded streams: status + headers are fixed at -/// guard construction, text accumulates as chunks arrive. -struct UpCapture { - status: u16, - headers: Value, - text: String, - total: usize, -} - -const RES_CAP_MAX: usize = 2 * 1024 * 1024; -const UP_CAP_MAX: usize = 1024 * 1024; - -impl StreamAbortGuard { - #[allow(clippy::too_many_arguments)] - fn new( - st: Arc, - id: u64, - started: std::time::Instant, - method: Method, - path: String, - provider: String, - routing: Routing, - status: u16, - ex: Value, - upstream: Option<(u16, Value)>, - ) -> Self { - let up_cap = upstream.map(|(status, headers)| UpCapture { status, headers, text: String::new(), total: 0 }); - Self { - armed: true, st, id, started, method, path, provider, routing, status, ex, - res_cap: String::new(), up_cap, finished: false, usage: None, - } - } - - /// Append to the client-facing response capture (the translated stream for transcoded pairs). - fn push_res(&mut self, s: &str) { - if self.res_cap.len() < RES_CAP_MAX { - self.res_cap.push_str(s); - } - } - - /// Append raw upstream bytes (pre-translation) when this guard tracks an upstream capture. - fn push_up(&mut self, raw: &str) { - if let Some(u) = self.up_cap.as_mut() { - u.total += raw.len(); - if u.text.len() < UP_CAP_MAX { - u.text.push_str(raw); - } - } - } - - /// Write the captured bodies into the exchange skeleton — shared by normal and abort paths. - fn fill_bodies(&mut self) { - self.ex["resBody"] = json!({ "text": self.res_cap, "bytes": self.res_cap.len(), "truncated": 0 }); - if let Some(u) = self.up_cap.as_ref() { - self.ex["upstreamRes"] = json!({ "status": u.status, "headers": u.headers, - "body": { "text": u.text, "bytes": u.total, "truncated": u.total.saturating_sub(u.text.len()) } }); - } - } - - /// Normal completion: disarm and hand the exchange (bodies filled) back to the caller (who - /// fills in ms / usage and records it). - fn complete(&mut self) -> Value { - self.armed = false; - self.fill_bodies(); - std::mem::take(&mut self.ex) - } -} - -impl Drop for StreamAbortGuard { - fn drop(&mut self) { - if !self.armed { - return; - } - self.fill_bodies(); - let mut ex = std::mem::take(&mut self.ex); - ex["ms"] = json!(self.started.elapsed().as_millis() as u64); - // A disconnect after the transcoder's terminal event is the normal end of a Responses - // turn — only flag genuinely interrupted streams. - if !self.finished { - ex["aborted"] = json!(true); - } - self.st.emit_request(self.id, self.started, &self.method, &self.path, &self.provider, &self.routing, self.status, self.usage.as_ref()); - let st = self.st.clone(); - // record_exchange is async and Drop is sync — spawn it, tolerating an already-torn-down - // runtime at app quit. - let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(move || { - tauri::async_runtime::spawn(async move { st.record_exchange(ex).await }); - })); - } -} - -fn retry_delay(retry_after: Option<&str>, attempt: i64, base: i64) -> u64 { - let cap = 30_000u64; - if let Some(ra) = retry_after { - let s = ra.trim(); - if let Ok(n) = s.parse::() { - return (n.saturating_mul(1000)).min(cap); - } - // HTTP-date form (RFC 7231 IMF-fixdate) — honor the absolute time the upstream named - // (proxy.js parity). chrono is already a dep, so no extra crate is pulled in for this. - if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(s, "%a, %d %b %Y %H:%M:%S GMT") { - let ms = (dt.and_utc() - chrono::Utc::now()).num_milliseconds().max(0) as u64; - return ms.min(cap); - } - } - let base = if base > 0 { base as u64 } else { 500 }; - base.saturating_mul(2u64.saturating_pow(attempt.clamp(0, 20) as u32)) - .min(8000) -} - -fn model_rewrite_re() -> &'static regex::Regex { - static RE: std::sync::OnceLock = std::sync::OnceLock::new(); - RE.get_or_init(|| regex::Regex::new(r#"("model"\s*:\s*")[^"]*(")"#).unwrap()) -} - -fn absorb_usage_sse(obj: &Value, usage: &mut UsageAcc) { - match obj.get("type").and_then(|v| v.as_str()) { - Some("message_start") => { - if let Some(u) = obj.get("message").and_then(|m| m.get("usage")) { - usage.input += u.get("input_tokens").and_then(|v| v.as_i64()).unwrap_or(0); - usage.cache_read += u.get("cache_read_input_tokens").and_then(|v| v.as_i64()).unwrap_or(0); - usage.cache_creation += u.get("cache_creation_input_tokens").and_then(|v| v.as_i64()).unwrap_or(0); - usage.saw = true; - } - } - Some("message_delta") => { - if let Some(o) = obj.get("usage").and_then(|u| u.get("output_tokens")).and_then(|v| v.as_i64()) { - usage.output = o; - usage.saw = true; - } - } - _ => {} - } -} - -fn process_sse_line(line: &str, rewrite_model: Option<&str>, usage: &mut UsageAcc) -> String { - if line.contains("\"usage\"") { - if let Some(i) = line.find('{') { - if let Ok(obj) = serde_json::from_str::(line[i..].trim()) { - absorb_usage_sse(&obj, usage); - } - } - } - if let Some(m) = rewrite_model { - if line.contains("\"model\"") { - return model_rewrite_re() - .replace_all(line, |caps: ®ex::Captures| format!("{}{}{}", &caps[1], m, &caps[2])) - .into_owned(); - } - } - line.to_string() -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum ResponsesTerminalKind { - Completed, - Incomplete, - Failed, -} - -impl ResponsesTerminalKind { - fn is_resumable(self) -> bool { - matches!(self, Self::Completed | Self::Incomplete) - } -} - -#[derive(Debug, Clone)] -struct ResponsesTerminal { - kind: ResponsesTerminalKind, - response: Option, -} - -fn responses_terminal_event(sse: &str) -> Option { - sse.lines().rev().find_map(|line| { - let payload = line.trim().strip_prefix("data:")?.trim(); - let event: Value = serde_json::from_str(payload).ok()?; - let kind = match event.get("type").and_then(Value::as_str)? { - "response.completed" => ResponsesTerminalKind::Completed, - "response.incomplete" => ResponsesTerminalKind::Incomplete, - "response.failed" => ResponsesTerminalKind::Failed, - _ => return None, - }; - Some(ResponsesTerminal { - kind, - response: event.get("response").cloned(), - }) - }) -} - -fn responses_terminal_object(response: &Value) -> Option { - let kind = match response.get("status").and_then(Value::as_str)? { - "completed" => ResponsesTerminalKind::Completed, - "incomplete" => ResponsesTerminalKind::Incomplete, - "failed" => ResponsesTerminalKind::Failed, - _ => return None, - }; - Some(ResponsesTerminal { - kind, - response: Some(response.clone()), - }) -} - -fn is_responses_compact_path(path: &str) -> bool { - matches!( - path.trim_end_matches('/'), - "/responses/compact" | "/v1/responses/compact" - ) -} - -fn build_target(base_url: &str, uri: &Uri) -> Option { - if base_url.is_empty() { - return None; - } - let base = base_url.trim_end_matches('/'); - let path = uri.path(); - let query = uri.query().map(|q| format!("?{}", q)).unwrap_or_default(); - // If the provider baseUrl already carries a path prefix (e.g. ".../v1") and the - // inbound path repeats it (e.g. "/v1/responses"), collapse the overlap so we don't - // forward to ".../v1/v1/responses". This is what bites an openai-* provider whose - // baseUrl ends in /v1 (incl. the sidecar plugins) on same-protocol passthrough. - // Segment-aware so a "/v1" base won't eat a "/v1beta" path. - let base_path = base_url_path(base).trim_end_matches('/'); - let path_out: &str = if base_path.is_empty() || base_path == "/" { - path - } else if path == base_path { - "" - } else { - match path.strip_prefix(base_path) { - Some(rest) if rest.starts_with('/') => rest, - _ => path, - } - }; - Some(format!("{}{}{}", base, path_out, query)) -} - -/// Resolve one of the three primary API endpoints against the configured base URL. The base is -/// authoritative: an inbound `/v1/...` path does not cause ccbud to insert `/v1` upstream. -fn endpoint_targets(base_url: &str, uri: &Uri) -> Option<(String, Option)> { - if base_url.trim().is_empty() { - return None; - } - let wire = crate::protocol::Wire::from_request_endpoint(uri.path())?; - let with_query = |mut url: String| { - if let Some(query) = uri.query() { - url.push('?'); - url.push_str(query); - } - url - }; - Some(( - with_query(wire.upstream_url_for_request(base_url, uri.path())), - wire.v1_fallback_url_for_request(base_url, uri.path()).map(with_query), - )) -} - -/// Standalone Responses compaction returns a distinct `response.compaction` object whose output -/// is the canonical replacement context window. Chat and Anthropic upstreams cannot provide that -/// contract through the ordinary response transcoder, so fail explicitly instead of turning a -/// compact request into an unrelated model turn. Responses providers keep the passthrough path. -fn cross_wire_compact_error(path: &str, provider_wire: crate::protocol::Wire) -> Option { - (is_responses_compact_path(path) - && provider_wire != crate::protocol::Wire::OpenAiResponses) - .then(|| { - error_response( - StatusCode::NOT_IMPLEMENTED, - "CC Buddy: /v1/responses/compact requires an openai-responses provider; cross-protocol compaction is not supported", - "invalid_request_error", - ) - }) -} - -/// The path component of a base URL (everything after scheme://authority), or "". -fn base_url_path(base: &str) -> &str { - let after_scheme = base.split_once("://").map(|(_, rest)| rest).unwrap_or(base); - match after_scheme.find('/') { - Some(i) => &after_scheme[i..], - None => "", - } -} - -fn error_response(status: StatusCode, msg: &str, etype: &str) -> Response { - json_response(status, &json!({ "type": "error", "error": { "type": etype, "message": msg } })) -} -fn json_response(status: StatusCode, body: &Value) -> Response { - let bytes = serde_json::to_vec(body).unwrap_or_default(); - Response::builder() - .status(status) - .header("content-type", "application/json") - .body(Body::from(bytes)) - .unwrap() -} - -// ---- /v1/models augmentation ---- -fn model_entry(id: &str) -> Value { - json!({ "type": "model", "id": id, "display_name": id, "created_at": "2025-01-01T00:00:00Z" }) -} -fn alias_entries(config: &Value) -> Vec { - let mut out = vec![]; - let mut seen = HashSet::new(); - if let Some(ps) = config.get("providers").and_then(|v| v.as_array()) { - for p in ps { - if let Some(ms) = p.get("models").and_then(|v| v.as_array()) { - for m in ms { - if let Some(a) = m.get("alias").and_then(|v| v.as_str()) { - if !a.is_empty() && seen.insert(a.to_string()) { - out.push(model_entry(a)); - } - } - } - } - } - } - out -} -/// Default tier models for the requesting client's family (Codex → gpt tiers, -/// Claude → claude tiers). -fn tier_entries(is_codex: bool) -> Vec { - if is_codex { - CODEX_TIER_MODELS.iter().map(|n| model_entry(n)).collect() - } else { - CLAUDE_TIER_MODELS.iter().map(|n| model_entry(n)).collect() - } -} -fn merge_models(upstream: &Value, config: &Value, is_codex: bool) -> Value { - let data = upstream.get("data").and_then(|d| d.as_array()).cloned().unwrap_or_default(); - let mut have: HashSet = data - .iter() - .filter_map(|m| m.get("id").and_then(|v| v.as_str()).map(|s| s.to_string())) - .collect(); - let mut adds = vec![]; - for a in alias_entries(config).into_iter().chain(tier_entries(is_codex)) { - let id = a.get("id").and_then(|v| v.as_str()).unwrap_or("").to_string(); - if have.insert(id) { - adds.push(a); - } - } - let mut merged = upstream.clone(); - adds.extend(data); - merged["data"] = json!(adds); - merged -} -fn synthesize_models(config: &Value, is_codex: bool) -> Value { - let mut out = alias_entries(config); - if out.is_empty() { - let ps = config.get("providers").and_then(|v| v.as_array()).cloned().unwrap_or_default(); - let active_id = config.get("activeProviderId").and_then(|v| v.as_str()); - let active = ps - .iter() - .find(|p| p.get("id").and_then(|v| v.as_str()) == active_id) - .or_else(|| ps.first()); - let mut seen = HashSet::new(); - if let Some(a) = active { - for k in ["defaultModel", "smallFastModel"] { - if let Some(id) = a.get(k).and_then(|v| v.as_str()) { - if !id.is_empty() && seen.insert(id.to_string()) { - out.push(model_entry(id)); - } - } - } - } - } - let mut have: HashSet = out - .iter() - .filter_map(|m| m.get("id").and_then(|v| v.as_str()).map(|s| s.to_string())) - .collect(); - for e in tier_entries(is_codex) { - let id = e.get("id").and_then(|v| v.as_str()).unwrap_or("").to_string(); - if have.insert(id) { - out.push(e); - } - } - let first = out.first().and_then(|m| m.get("id").cloned()).unwrap_or(Value::Null); - let last = out.last().and_then(|m| m.get("id").cloned()).unwrap_or(Value::Null); - json!({ "data": out, "has_more": false, "first_id": first, "last_id": last }) -} - -fn redact_value(key: &str, val: &str) -> String { - let k = key.to_ascii_lowercase(); - if matches!(k.as_str(), "authorization" | "x-api-key" | "cookie" | "set-cookie" | "proxy-authorization" | "x-goog-api-key") { - "••••••(已隐藏)".to_string() - } else { - val.to_string() - } -} -fn redact_headers(h: &HeaderMap) -> Value { - let mut o = serde_json::Map::new(); - for (k, v) in h.iter() { - o.insert(k.as_str().to_string(), Value::String(redact_value(k.as_str(), v.to_str().unwrap_or("")))); - } - Value::Object(o) -} -fn vec_headers(pairs: &[(String, String)]) -> Value { - let mut o = serde_json::Map::new(); - for (k, v) in pairs { - o.insert(k.clone(), Value::String(redact_value(k, v))); - } - Value::Object(o) -} -fn cap_text(bytes: &[u8], cap: usize) -> Value { - let total = bytes.len(); - let end = total.min(cap); - json!({ "text": String::from_utf8_lossy(&bytes[..end]), "bytes": total, "truncated": total.saturating_sub(cap) }) -} - -fn now_ms() -> i64 { - std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_millis() as i64).unwrap_or(0) -} - -const HOP_BY_HOP_REQ: &[&str] = &[ - "host", "content-length", "authorization", "x-api-key", "accept-encoding", "cookie", - "proxy-authorization", "connection", "proxy-connection", "transfer-encoding", -]; -const HOP_BY_HOP_RES: &[&str] = &[ - "content-length", "transfer-encoding", "content-encoding", "connection", "keep-alive", - "proxy-authenticate", "proxy-connection", "set-cookie", -]; - -/// The localhost reverse-proxy handler. Mirrors proxy.js `handle`. -async fn handle(State(st): State>, req: axum::extract::Request) -> Response { - let started = std::time::Instant::now(); - let (parts, body) = req.into_parts(); - let method = parts.method; - let uri = parts.uri; - let in_headers = parts.headers; - let req_path = uri.path().to_string(); - let body_bytes = to_bytes(body, 64 * 1024 * 1024).await.unwrap_or_default(); - - let config = store::read_config(); - - // Optional local gateway token (defense in depth; already bound to localhost). - if config.get("requireToken").and_then(|v| v.as_bool()).unwrap_or(false) { - let token = config.get("gatewayToken").and_then(|v| v.as_str()).unwrap_or(""); - if !token.is_empty() { - let auth = in_headers.get("authorization").and_then(|v| v.to_str().ok()).unwrap_or(""); - let bearer = auth - .strip_prefix("Bearer ") - .or_else(|| auth.strip_prefix("bearer ")); - let presented = bearer.unwrap_or_else(|| { - in_headers.get("x-api-key").and_then(|v| v.to_str().ok()).unwrap_or("") - }); - if presented != token { - return error_response(StatusCode::UNAUTHORIZED, "CC Buddy: invalid gateway token", "authentication_error"); - } - } - } - - let is_json = in_headers - .get("content-type") - .and_then(|v| v.to_str().ok()) - .map(|s| s.contains("application/json")) - .unwrap_or(false); - let mut parsed: Option = None; - let mut requested_model: Option = None; - if !body_bytes.is_empty() && is_json { - if let Ok(v) = serde_json::from_slice::(&body_bytes) { - requested_model = v.get("model").and_then(|m| m.as_str()).map(|s| s.to_string()); - parsed = Some(v); - } - } - - let providers = config.get("providers").and_then(|v| v.as_array()).cloned().unwrap_or_default(); - let active_id = config.get("activeProviderId").and_then(|v| v.as_str()); - let active_pid = providers - .iter() - .find(|p| p.get("id").and_then(|v| v.as_str()) == active_id) - .or_else(|| providers.first()) - .and_then(|p| p.get("id").and_then(|v| v.as_str())) - .map(|s| s.to_string()); - let known = match &active_pid { - Some(pid) => st.known.lock().await.get(pid).cloned(), - None => None, - }; - - let routing = match resolve_routing(requested_model.as_deref(), &config, known.as_ref()) { - Some(r) => r, - None => { - st.log("warn", "request rejected: no provider configured"); - return error_response(StatusCode::BAD_GATEWAY, "CC Buddy: no provider configured. Add one in the app.", "api_error"); - } - }; - let provider = match providers.iter().find(|p| p.get("id").and_then(|v| v.as_str()) == Some(routing.provider_id.as_str())) { - Some(p) => p, - None => return error_response(StatusCode::BAD_GATEWAY, "CC Buddy: no provider configured.", "api_error"), - }; - let base_url = provider.get("baseUrl").and_then(|v| v.as_str()).unwrap_or(""); - let auth_token = provider.get("authToken").and_then(|v| v.as_str()).unwrap_or("").to_string(); - let provider_name = provider - .get("name") - .and_then(|v| v.as_str()) - .unwrap_or(routing.provider_id.as_str()) - .to_string(); - - let need_rewrite = match (routing.client_facing_model.as_ref(), routing.outgoing_model.as_ref()) { - (Some(c), Some(o)) => c != o, - _ => false, - }; - let mut out_body = body_bytes.clone(); - if let (Some(p0), Some(out_model)) = (parsed.as_ref(), routing.outgoing_model.as_ref()) { - if Some(out_model) != requested_model.as_ref() { - let mut p = p0.clone(); - p["model"] = json!(out_model); - if let Ok(b) = serde_json::to_vec(&p) { - out_body = Bytes::from(b); - } - } - } - - let endpoint_pair = if method == Method::POST { - endpoint_targets(base_url, &uri) - } else { - None - }; - let (mut target, mut v1_fallback_target) = match endpoint_pair { - Some(pair) => pair, - None => match build_target(base_url, &uri) { - Some(t) => (t, None), - None => return error_response(StatusCode::BAD_GATEWAY, "CC Buddy: invalid provider baseUrl", "api_error"), - }, - }; - let is_models_list = method == Method::GET && (req_path.ends_with("/v1/models") || req_path.ends_with("/v1/models/")); - // Codex and Claude clients both GET /v1/models — tell them apart by client identity - // so each gets its own family's default model list. - let client_codex = client_is_codex(&in_headers); - let is_head_root = method == Method::HEAD && req_path == "/"; - let is_count_tokens = method == Method::POST - && (req_path.ends_with("/v1/messages/count_tokens") || req_path.ends_with("/v1/messages/count_tokens/")); - - // ---- protocol translation ---- - // When the client's wire protocol (inferred from the request path) differs from the provider's - // declared protocol, translate the request into the provider's format and remember to translate - // the response back. Same-protocol requests skip this entirely and keep the verbatim passthrough - // fast path below (so Anthropic→Anthropic behavior is byte-for-byte unchanged). Streaming pairs - // with an incremental transcoder (see protocol::stream::Transcoder) stream token-by-token; the - // rest force the upstream buffered (stream=false) and synthesize the client SSE from the full - // response. - let client_wire = crate::protocol::Wire::from_request_path(&uri); - let provider_wire = - crate::protocol::Wire::from_provider(provider.get("protocol").and_then(|v| v.as_str())); - let is_responses_compact = method == Method::POST && is_responses_compact_path(&req_path); - if method == Method::POST { - if let Some(response) = cross_wire_compact_error(&req_path, provider_wire) { - return response; - } - } - let request_session = parsed.as_ref().and_then(request_session_id); - // Conversation history belongs to the client session, not the provider: users may switch the - // active provider mid-turn and previous_response_id must still restore the same transcript. - // Sessionless requests can use direct response-id lookup, but never call-id fallback because - // call ids are routinely reused across unrelated agent runs. - let codex_history_scope = codex_history_scope_for_session(request_session.as_deref()); - let allow_codex_call_fallback = request_session.is_some(); - let mut prepared_responses_request: Option = None; - let mut native_responses_history: Option = None; - let mut history_localized = false; - if is_responses_compact { - if let Some(request) = parsed.as_ref() { - let previous_response_id = request - .get("previous_response_id") - .and_then(Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(ToString::to_string); - let mut materialized_request = request.clone(); - let resolution = st - .codex_history - .materialize_request_scoped( - &codex_history_scope, - allow_codex_call_fallback, - &mut materialized_request, - ) - .await; - let forward = match decide_responses_compact_history( - &routing.provider_id, - &resolution, - ) { - Ok(forward) => forward, - Err(ResponsesHistoryError::Unavailable) => { - return error_response( - StatusCode::BAD_REQUEST, - &format!( - "CC Buddy cannot compact previous_response_id '{}' with provider '{}': its complete context cannot be materialized; retry with the owning Responses provider", - previous_response_id.as_deref().unwrap_or(""), - provider_name - ), - "invalid_request_error", - ); - } - }; - if forward == ResponsesForwardMode::Materialized { - history_localized = true; - let Some(body) = request_body_with_model( - &materialized_request, - routing.outgoing_model.as_deref(), - ) else { - return error_response( - StatusCode::INTERNAL_SERVER_ERROR, - "CC Buddy failed to serialize locally materialized compact history", - "api_error", - ); - }; - out_body = body; - } - } - } else if client_wire == crate::protocol::Wire::OpenAiResponses && method == Method::POST { - if let Some(request) = parsed.as_ref() { - let previous_response_id = request - .get("previous_response_id") - .and_then(Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(ToString::to_string); - let mut materialized_request = request.clone(); - let resolution = st - .codex_history - .materialize_request_scoped( - &codex_history_scope, - allow_codex_call_fallback, - &mut materialized_request, - ) - .await; - let decision = match decide_responses_history( - provider_wire, - &routing.provider_id, - &resolution, - ) { - Ok(decision) => decision, - Err(ResponsesHistoryError::Unavailable) => { - let detail = if resolution.previous_found { - "is known locally but its complete context cannot be materialized" - } else { - "is not available in local history" - }; - return error_response( - StatusCode::BAD_REQUEST, - &format!( - "CC Buddy cannot continue previous_response_id '{}' through provider '{}': it {}; retry with the owning Responses provider or start a new conversation", - previous_response_id.as_deref().unwrap_or(""), - provider_name, - detail - ), - "invalid_request_error", - ); - } - }; - if decision.forward == ResponsesForwardMode::Materialized { - history_localized = true; - if provider_wire == crate::protocol::Wire::OpenAiResponses { - let Some(body) = request_body_with_model( - &materialized_request, - routing.outgoing_model.as_deref(), - ) else { - return error_response( - StatusCode::INTERNAL_SERVER_ERROR, - "CC Buddy failed to serialize locally materialized Responses history", - "api_error", - ); - }; - out_body = body; - } - } - if provider_wire == crate::protocol::Wire::OpenAiResponses { - native_responses_history = Some(NativeResponsesHistoryContext { - scope: codex_history_scope.clone(), - request: materialized_request.clone(), - provider_id: routing.provider_id.clone(), - materializable: decision.descendant_materializable, - }); - } - prepared_responses_request = Some(materialized_request); - } - } - let is_gemini_upstream = provider_wire == crate::protocol::Wire::OpenAiChat - && routing.outgoing_model.as_deref().unwrap_or("").to_ascii_lowercase().contains("gemini"); - // translate ctx: (client wire, provider wire, client model, wanted stream, incremental, - // request-scoped Responses tool metadata, full translated client request for history, - // client-session history scope) - // `incremental` = we can transcode the upstream stream event-by-event to the client (true - // token-by-token). Otherwise we force the upstream buffered and synthesize the client response. - let mut translate: Option<(crate::protocol::Wire, crate::protocol::Wire, String, bool, bool, - crate::protocol::openai_responses::CodexToolContext, - Value, - String)> = None; - if client_wire != provider_wire && method == Method::POST && !is_models_list && !is_count_tokens { - if let Some(p) = parsed.as_ref() { - let wanted_stream = p.get("stream").and_then(|v| v.as_bool()).unwrap_or(false); - let incremental = wanted_stream - && crate::protocol::can_transcode_stream(provider_wire, client_wire); - let client_model = routing.client_facing_model.clone().unwrap_or_default(); - let outgoing = routing.outgoing_model.clone().unwrap_or_default(); - let request_for_translation = prepared_responses_request - .clone() - .unwrap_or_else(|| p.clone()); - let decoded = if client_wire == crate::protocol::Wire::OpenAiResponses { - crate::protocol::openai_responses::decode_request_with_context( - &request_for_translation, - ) - } else { - crate::protocol::decode_client_request(client_wire, &request_for_translation).map( - |request| { - ( - request, - crate::protocol::openai_responses::CodexToolContext::default(), - ) - }, - ) - }; - let (mut ir, tool_context) = match decoded { - Ok(decoded) => decoded, - Err(e) => { - st.log( - "warn", - format!("client protocol decode ({:?}) failed: {}", client_wire, e), - ); - return error_response( - StatusCode::BAD_REQUEST, - &format!("CC Buddy invalid client request: {}", e), - "invalid_request_error", - ); - } - }; - // Neither the Anthropic nor the Responses client wire round-trips Gemini's thought - // signature, so every translated client (Claude Code AND Codex) needs the session-cache - // restore + documented fallback sentinel — Gemini 3 rejects current-turn function calls - // without a signature (400). - if is_gemini_upstream { - st.thought_signatures.lock().await.restore( - &routing.provider_id, - request_session.as_deref(), - &mut ir, - ); - } - // Repair after signature restoration so any call whose provider-visible payload changes - // cannot accidentally regain a cached signature that authenticated different bytes. - if provider_wire == crate::protocol::Wire::OpenAiChat { - sanitize_provider_history_tool_arguments(&mut ir); - } - if is_gemini_upstream { - apply_gemini_signature_fallback(&mut ir); - } - let translated_body = crate::protocol::encode_upstream_request(provider_wire, &ir, &outgoing, incremental); - match translated_body { - Ok(mut body) => { - if client_wire == crate::protocol::Wire::OpenAiResponses - && provider_wire == crate::protocol::Wire::OpenAiChat - { - apply_responses_chat_request_controls(&mut body, &request_for_translation); - } - // Ask OpenAI-family upstreams to include usage in the final stream chunk. - if incremental && provider_wire == crate::protocol::Wire::OpenAiChat { - body["stream_options"] = json!({ "include_usage": true }); - } - if let Ok(b) = serde_json::to_vec(&body) { - out_body = Bytes::from(b); - } - // Send to the provider protocol's endpoint (drop the inbound path/query). - target = provider_wire.upstream_url(base_url); - v1_fallback_target = provider_wire.v1_fallback_url(base_url); - translate = Some((client_wire, provider_wire, client_model, wanted_stream, incremental, - tool_context, request_for_translation, codex_history_scope.clone())); - } - Err(e) => { - st.log("error", format!("protocol translate ({:?}→{:?}) failed: {}", client_wire, provider_wire, e)); - return error_response(StatusCode::BAD_GATEWAY, &format!("CC Buddy protocol translation failed: {}", e), "api_error"); - } - } - } - } - - // upstream headers (sanitized + provider token swapped in) - let mut up_headers = HeaderMap::new(); - for (k, v) in in_headers.iter() { - let kn = k.as_str().to_ascii_lowercase(); - if HOP_BY_HOP_REQ.contains(&kn.as_str()) { - continue; - } - up_headers.insert(k.clone(), v.clone()); - } - up_headers.insert(axum::http::header::ACCEPT_ENCODING, HeaderValue::from_static("identity")); - // A translated Anthropic upstream needs the anthropic-version header; OpenAI-family clients - // (Codex) never send one. - if translate.as_ref().map(|t| t.1) == Some(crate::protocol::Wire::Anthropic) - && !up_headers.contains_key("anthropic-version") - { - up_headers.insert("anthropic-version", HeaderValue::from_static("2023-06-01")); - } - if !auth_token.is_empty() { - // Auth via Authorization: Bearer only. Sending both authorization and x-api-key trips - // providers that reject having the two auth headers present at once (matches provider_test). - // Both inbound auth headers are already stripped by HOP_BY_HOP_REQ above. - if let Ok(val) = HeaderValue::from_str(&format!("Bearer {}", auth_token)) { - up_headers.insert(axum::http::header::AUTHORIZATION, val); - } - } - - let ex_id = st.next_id(); - let ex_req_headers = redact_headers(&up_headers); - let ex_req_body = cap_text(&out_body, 4 * 1024 * 1024); - let original_target = target.clone(); - let mut ex_url = target.clone(); - // Client-side view of the exchange — what the gateway RECEIVED, before any translation — so - // the monitor can show a protocol translation's exact before/after (inbound URL/headers/body - // vs. the upstream URL/headers/body above). The body is duplicated only when a translation - // applies; for passthrough, reqBody already IS the client body (modulo the model rewrite). - let ex_translated = translate.as_ref().map(|t| format!("{} → {}", t.0.label(), t.1.label())); - let ex_client_req = { - let mut o = json!({ - "url": uri.path_and_query().map(|p| p.as_str().to_string()).unwrap_or_else(|| req_path.clone()), - "headers": redact_headers(&in_headers), - }); - if ex_translated.is_some() || history_localized { - o["body"] = cap_text(&body_bytes, 1024 * 1024); - } - o - }; - - let insecure = config.get("insecureSkipVerify").and_then(|v| v.as_bool()).unwrap_or(false) - && target.starts_with("https:"); - let client = if insecure { &st.client_insecure } else { &st.client }; - - let rc = config.get("retry429").cloned().unwrap_or(json!({})); - let retry_enabled = rc.get("enabled").map(|v| v.as_bool().unwrap_or(true)).unwrap_or(true); - let retry_max = rc.get("max").and_then(|v| v.as_i64()).unwrap_or(3); - let retry_base = rc.get("baseMs").and_then(|v| v.as_i64()).unwrap_or(500); - - // Forward with the existing 429 retry plus one compatibility attempt at `/v1`. The first - // response is retained until the fallback succeeds, so a failed fallback never masks the - // upstream's original error. - let mut attempt = 0i64; - let mut tried_v1_fallback = false; - let mut used_v1_fallback = false; - let mut first_path_error: Option = None; - let resp = loop { - let r = client - .request(method.clone(), &target) - .headers(up_headers.clone()) - .body(out_body.clone()) - .send() - .await; - match r { - Ok(resp) => { - if !tried_v1_fallback - && retry_enabled - && resp.status().as_u16() == 429 - && attempt < retry_max - { - let ra = resp.headers().get("retry-after").and_then(|v| v.to_str().ok()).map(|s| s.to_string()); - let delay = retry_delay(ra.as_deref(), attempt, retry_base); - st.log("warn", format!("upstream 429 — retry {}/{} in {}ms ({})", attempt + 1, retry_max, delay, provider_name)); - tokio::time::sleep(std::time::Duration::from_millis(delay)).await; - attempt += 1; - continue; - } - if !tried_v1_fallback - && crate::protocol::should_try_v1_fallback(resp.status().as_u16()) - { - if let Some(fallback) = v1_fallback_target.take() { - first_path_error = Some(resp); - target = fallback; - ex_url = target.clone(); - tried_v1_fallback = true; - attempt = 0; - continue; - } - } - if tried_v1_fallback { - if resp.status().is_success() { - used_v1_fallback = true; - ex_url = target.clone(); - break resp; - } - ex_url = original_target.clone(); - break first_path_error.take().expect("v1 fallback keeps the first response"); - } - break resp; - } - Err(e) => { - if tried_v1_fallback { - if let Some(first) = first_path_error.take() { - ex_url = original_target.clone(); - st.log("info", format!("/v1 compatibility retry failed: {} ({})", e, provider_name)); - break first; - } - } - if is_models_list { - st.emit_request(ex_id, started, &method, &req_path, &provider_name, &routing, 200, None); - return json_response(StatusCode::OK, &synthesize_models(&config, client_codex)); - } - if is_count_tokens { - let est = crate::counttokens::estimate_input_tokens(parsed.as_ref().unwrap_or(&Value::Null)); - st.emit_request(ex_id, started, &method, &req_path, &provider_name, &routing, 200, None); - return Response::builder() - .status(200) - .header("content-type", "application/json") - .header("x-ccbud-tokens", "estimated") - .header("x-ccbud-upstream-status", "error") - .body(Body::from(serde_json::to_vec(&json!({ "input_tokens": est })).unwrap_or_default())) - .unwrap(); - } - st.log("error", format!("upstream error: {}", e)); - st.emit_request(ex_id, started, &method, &req_path, &provider_name, &routing, 502, None); - return error_response(StatusCode::BAD_GATEWAY, &format!("CC Buddy upstream error: {}", e), "api_error"); - } - } - }; - - if used_v1_fallback { - if let Some(saved) = store::migrate_provider_base_url_to_v1(&routing.provider_id, base_url) { - st.log("info", format!("provider base URL updated with /v1 ({})", provider_name)); - st.emit("config:changed", saved); - } - } - - let status = resp.status(); - let ct = resp.headers().get("content-type").and_then(|v| v.to_str().ok()).unwrap_or("").to_string(); - - if is_head_root && status.as_u16() == 404 { - st.log("info", format!("HEAD / fallback: upstream 404 → gateway 200 ({})", provider_name)); - st.emit_request(ex_id, started, &method, &req_path, &provider_name, &routing, 200, None); - st.record_exchange(json!({ - "id": ex_id, "ts": now_ms(), "ms": started.elapsed().as_millis() as u64, - "method": method.as_str(), "path": req_path, "url": ex_url, - "provider": provider_name, "requestedModel": routing.client_facing_model, - "outgoingModel": routing.outgoing_model, "clientFacingModel": routing.client_facing_model, - "status": 200, "reqHeaders": ex_req_headers, "reqBody": ex_req_body, - "clientReq": ex_client_req, "translated": ex_translated, - "resHeaders": json!({ "x-ccbud-fallback": "head-root-404-to-200", "x-ccbud-upstream-status": "404" }), - "resBody": json!({ "text": "", "bytes": 0, "truncated": 0 }), - })) - .await; - return Response::builder() - .status(200) - .header("x-ccbud-fallback", "head-root-404-to-200") - .header("x-ccbud-upstream-status", "404") - .body(Body::empty()) - .unwrap(); - } - - let mut out_headers: Vec<(String, String)> = vec![]; - for (k, v) in resp.headers().iter() { - let kn = k.as_str().to_ascii_lowercase(); - if HOP_BY_HOP_RES.contains(&kn.as_str()) { - continue; - } - if let Ok(s) = v.to_str() { - out_headers.push((k.as_str().to_string(), s.to_string())); - } - } - - // streaming SSE — rewrite model + sniff usage, line-buffered - if ct.contains("text/event-stream") { - // Incremental cross-protocol transcode: feed each upstream SSE line through a stateful - // transcoder that emits the client protocol's events as they arrive (true token-by-token). - if let Some((client_wire, provider_wire, mut tc, history_request, history_scope)) = translate - .as_ref() - .filter(|t| t.4) - .and_then(|t| { - // can_transcode_stream guarded `incremental`, so new() matches a wired pair. - crate::protocol::stream::Transcoder::new_with_context(t.1, t.0, &t.2, t.5.clone()).map(|tc| (t.0, t.1, tc, t.6.clone(), t.7.clone())) - }) - { - let st2 = st.clone(); - let signature_provider_id = routing.provider_id.clone(); - let signature_session = request_session.clone(); - // Any Gemini-backed transcoded stream (Claude Code or Codex client) feeds the - // signature cache; transcoders that don't track calls return an empty capture. - let capture_thought_signatures = is_gemini_upstream; - let status_code = status.as_u16(); - let ex_id2 = ex_id; - let started2 = started; - let xlabel = format!("{:?}->{:?}", provider_wire, client_wire); - let up_res_headers = vec_headers(&out_headers); - let mut guard = StreamAbortGuard::new( - st.clone(), ex_id, started, method.clone(), req_path.clone(), provider_name.clone(), - routing.clone(), status_code, - json!({ - "id": ex_id, "ts": now_ms(), "method": method.as_str(), "path": req_path, "url": ex_url, - "provider": provider_name, "requestedModel": routing.client_facing_model, - "outgoingModel": routing.outgoing_model, "clientFacingModel": routing.client_facing_model, - "status": status_code, "reqHeaders": ex_req_headers, "reqBody": ex_req_body, - "clientReq": ex_client_req, "translated": ex_translated, - "resHeaders": json!({ "content-type": "text/event-stream", "x-ccbud-translated": xlabel }), - "resBody": json!({ "text": "", "bytes": 0, "truncated": 0 }), - }), - // raw upstream capture (pre-translation), so the monitor can show the exact - // upstream stream next to the translated one the client received - Some((status_code, up_res_headers)), - ); - let method2 = method.clone(); - let path2 = req_path.clone(); - let pname2 = provider_name.clone(); - let routing2 = routing.clone(); - let body_stream = async_stream::stream! { - let mut s = resp.bytes_stream(); - let mut buf = String::new(); - let mut history_recorded = false; - while let Some(chunk) = s.next().await { - match chunk { - Ok(bytes) => { - let raw = String::from_utf8_lossy(&bytes); - guard.push_up(&raw); - buf.push_str(&raw); - let mut out = String::new(); - while let Some(idx) = buf.find('\n') { - let line: String = buf.drain(..=idx).collect(); - out.push_str(&tc.push(&line)); - } - if capture_thought_signatures && tc.succeeded() { - let captured_calls = tc.captured_tool_calls(); - st2.thought_signatures.lock().await.remember( - &signature_provider_id, - signature_session.as_deref(), - &captured_calls, - ); - } - // Keep the guard current BEFORE suspending: once the terminal event is - // out, Codex closes the socket and the generator is dropped mid-await. - guard.push_res(&out); - guard.finished = tc.done(); - if client_wire == crate::protocol::Wire::OpenAiResponses - && !history_recorded - { - if let Some(terminal) = responses_terminal_event(&out) { - history_recorded = true; - if terminal.kind.is_resumable() { - if let Some(response) = terminal.response.as_ref() { - st2.codex_history - .record_response_scoped_with_metadata( - &history_scope, - ResponseOrigin::Local, - true, - &history_request, - response, - ) - .await; - } - } - } - } - guard.usage = Some(UsageAcc { - input: tc.input_tokens(), output: tc.output_tokens(), saw: true, ..Default::default() - }); - if !out.is_empty() { - yield Ok::(Bytes::from(out)); - } - } - Err(error) => { - let message = format!("upstream stream transport error: {}", error); - st2.log("error", format!("{} ({})", message, pname2)); - buf.clear(); - let out = tc.fail(&message); - guard.push_res(&out); - guard.finished = tc.done(); - guard.usage = Some(UsageAcc { - input: tc.input_tokens(), output: tc.output_tokens(), saw: true, ..Default::default() - }); - if !out.is_empty() { - yield Ok(Bytes::from(out)); - } - break; - } - } - } - let mut tail = String::new(); - if !buf.is_empty() { tail.push_str(&tc.push(&buf)); } - tail.push_str(&tc.finish()); - guard.finished = tc.done(); - guard.usage = Some(UsageAcc { - input: tc.input_tokens(), output: tc.output_tokens(), saw: true, ..Default::default() - }); - if capture_thought_signatures && tc.succeeded() { - let captured_calls = tc.captured_tool_calls(); - st2.thought_signatures.lock().await.remember( - &signature_provider_id, - signature_session.as_deref(), - &captured_calls, - ); - } - if !tail.is_empty() { - guard.push_res(&tail); - if client_wire == crate::protocol::Wire::OpenAiResponses - && !history_recorded - { - if let Some(terminal) = responses_terminal_event(&tail) { - if terminal.kind.is_resumable() { - if let Some(response) = terminal.response.as_ref() { - st2.codex_history - .record_response_scoped_with_metadata( - &history_scope, - ResponseOrigin::Local, - true, - &history_request, - response, - ) - .await; - } - } - } - } - yield Ok(Bytes::from(tail)); - } - let mut usage = UsageAcc::default(); - usage.input = tc.input_tokens(); - usage.output = tc.output_tokens(); - usage.saw = true; - st2.emit_request(ex_id2, started2, &method2, &path2, &pname2, &routing2, status_code, Some(&usage)); - let mut ex = guard.complete(); - ex["ms"] = json!(started2.elapsed().as_millis() as u64); - st2.record_exchange(ex).await; - }; - let mut builder = Response::builder() - .status(status.as_u16()) - .header("content-type", "text/event-stream") - .header("x-ccbud-translated", format!("{:?}->{:?}", provider_wire, client_wire)); - // Forward the upstream request id — clients (Claude Code) persist it as `requestId`, - // which usage analytics use as half of the de-dup key. - for (k, v) in &out_headers { - if k == "request-id" || k == "x-request-id" { - builder = builder.header(k, v); - } - } - return builder.body(Body::from_stream(body_stream)).unwrap(); - } - let rewrite_model = if need_rewrite { routing.client_facing_model.clone() } else { None }; - let st2 = st.clone(); - let status_code = status.as_u16(); - let ex_id2 = ex_id; - let started2 = started; - let res_headers = vec_headers(&out_headers); - let mut guard = StreamAbortGuard::new( - st.clone(), ex_id, started, method.clone(), req_path.clone(), provider_name.clone(), - routing.clone(), status_code, - json!({ - "id": ex_id, "ts": now_ms(), "method": method.as_str(), "path": req_path, "url": ex_url, - "provider": provider_name, "requestedModel": routing.client_facing_model, - "outgoingModel": routing.outgoing_model, "clientFacingModel": routing.client_facing_model, - "status": status_code, "reqHeaders": ex_req_headers, "reqBody": ex_req_body, - "clientReq": ex_client_req, "translated": ex_translated, - "resHeaders": res_headers, - "resBody": json!({ "text": "", "bytes": 0, "truncated": 0 }), - }), - None, - ); - let method2 = method.clone(); - let path2 = req_path.clone(); - let pname2 = provider_name.clone(); - let routing2 = routing.clone(); - let native_history = native_responses_history.clone(); - let native_responses_stream = client_wire == crate::protocol::Wire::OpenAiResponses; - let body_stream = async_stream::stream! { - let mut s = resp.bytes_stream(); - let mut buf = String::new(); - let mut usage = UsageAcc::default(); - let mut history_recorded = false; - while let Some(chunk) = s.next().await { - match chunk { - Ok(bytes) => { - buf.push_str(&String::from_utf8_lossy(&bytes)); - let mut out = String::new(); - while let Some(idx) = buf.find('\n') { - let line: String = buf.drain(..=idx).collect(); - out.push_str(&process_sse_line(&line, rewrite_model.as_deref(), &mut usage)); - } - let terminal = native_responses_stream - .then(|| responses_terminal_event(&out)) - .flatten(); - guard.push_res(&out); - if let Some(terminal) = terminal { - guard.finished = true; - if !history_recorded - && (200..300).contains(&status_code) - && terminal.kind.is_resumable() - { - if let (Some(history), Some(response)) = - (native_history.as_ref(), terminal.response.as_ref()) - { - st2.codex_history - .record_response_scoped_with_metadata( - &history.scope, - ResponseOrigin::Native(history.provider_id.clone()), - history.materializable, - &history.request, - response, - ) - .await; - } - } - history_recorded = true; - } - guard.usage = Some(usage.clone()); - if !out.is_empty() { - yield Ok::(Bytes::from(out)); - } - } - Err(error) => { - let message = format!("upstream stream transport error: {}", error); - st2.log("error", format!("{} ({})", message, pname2)); - yield Err(std::io::Error::new(std::io::ErrorKind::Other, message)); - return; - } - } - } - if !buf.is_empty() { - let line = process_sse_line(&buf, rewrite_model.as_deref(), &mut usage); - let terminal = native_responses_stream - .then(|| responses_terminal_event(&line)) - .flatten(); - guard.push_res(&line); - if let Some(terminal) = terminal { - guard.finished = true; - if !history_recorded - && (200..300).contains(&status_code) - && terminal.kind.is_resumable() - { - if let (Some(history), Some(response)) = - (native_history.as_ref(), terminal.response.as_ref()) - { - st2.codex_history - .record_response_scoped_with_metadata( - &history.scope, - ResponseOrigin::Native(history.provider_id.clone()), - history.materializable, - &history.request, - response, - ) - .await; - } - } - } - yield Ok(Bytes::from(line)); - } - if native_responses_stream && !guard.finished { - let message = "upstream Responses stream ended before a terminal event"; - st2.log("error", format!("{} ({})", message, pname2)); - yield Err(std::io::Error::new(std::io::ErrorKind::UnexpectedEof, message)); - return; - } - st2.emit_request(ex_id2, started2, &method2, &path2, &pname2, &routing2, status_code, Some(&usage)); - let mut ex = guard.complete(); - ex["ms"] = json!(started2.elapsed().as_millis() as u64); - st2.record_exchange(ex).await; - }; - let mut builder = Response::builder().status(status.as_u16()); - for (k, v) in &out_headers { - builder = builder.header(k, v); - } - return builder.body(Body::from_stream(body_stream)).unwrap(); - } - - // buffered (reqwest auto-decoded gzip/br/deflate) - let buf = match resp.bytes().await { - Ok(buf) => buf, - Err(error) => { - let message = format!("upstream response body transport error: {}", error); - st.log("error", format!("{} ({})", message, provider_name)); - st.emit_request( - ex_id, - started, - &method, - &req_path, - &provider_name, - &routing, - StatusCode::BAD_GATEWAY.as_u16(), - None, - ); - return error_response(StatusCode::BAD_GATEWAY, &format!("CC Buddy: {}", message), "api_error"); - } - }; - - // count_tokens: pass the upstream's real number when it implements the endpoint; otherwise - // (404 / non-JSON / missing input_tokens) estimate locally so Claude Code's sizing keeps working. - if is_count_tokens { - let upstream_ok = status.is_success() - && serde_json::from_slice::(&buf) - .ok() - .and_then(|o| o.get("input_tokens").and_then(|v| v.as_i64())) - .is_some(); - if upstream_ok { - let mut builder = Response::builder().status(200).header("x-ccbud-tokens", "upstream"); - for (k, v) in &out_headers { - builder = builder.header(k, v); - } - st.emit_request(ex_id, started, &method, &req_path, &provider_name, &routing, 200, None); - return builder.body(Body::from(buf)).unwrap(); - } - let est = crate::counttokens::estimate_input_tokens(parsed.as_ref().unwrap_or(&Value::Null)); - let ebody = serde_json::to_vec(&json!({ "input_tokens": est })).unwrap_or_default(); - st.emit_request(ex_id, started, &method, &req_path, &provider_name, &routing, 200, None); - st.record_exchange(json!({ - "id": ex_id, "ts": now_ms(), "ms": started.elapsed().as_millis() as u64, - "method": method.as_str(), "path": req_path, "url": ex_url, - "provider": provider_name, "requestedModel": routing.client_facing_model, - "outgoingModel": routing.outgoing_model, "clientFacingModel": routing.client_facing_model, - "status": 200, "reqHeaders": ex_req_headers, "reqBody": ex_req_body, - "clientReq": ex_client_req, "translated": ex_translated, - "resHeaders": json!({ "x-ccbud-tokens": "estimated", "x-ccbud-upstream-status": status.as_u16().to_string() }), - "resBody": json!({ "text": String::from_utf8_lossy(&ebody), "bytes": ebody.len(), "truncated": 0 }), - })) - .await; - return Response::builder() - .status(200) - .header("content-type", "application/json") - .header("x-ccbud-tokens", "estimated") - .header("x-ccbud-upstream-status", status.as_u16().to_string()) - .body(Body::from(ebody)) - .unwrap(); - } - - if is_models_list { - let mut merged = None; - if status.is_success() { - if let Ok(o) = serde_json::from_slice::(&buf) { - if let Some(data) = o.get("data").and_then(|d| d.as_array()) { - if let Some(pid) = &active_pid { - let ids: HashSet = data - .iter() - .filter_map(|m| m.get("id").and_then(|v| v.as_str()).map(|s| s.to_string())) - .collect(); - if !ids.is_empty() { - st.known.lock().await.insert(pid.clone(), ids); - } - } - merged = Some(merge_models(&o, &config, client_codex)); - } - } - } - let result = merged.unwrap_or_else(|| synthesize_models(&config, client_codex)); - let rbody = serde_json::to_vec(&result).unwrap_or_default(); - st.emit_request(ex_id, started, &method, &req_path, &provider_name, &routing, 200, None); - st.record_exchange(json!({ - "id": ex_id, "ts": now_ms(), "ms": started.elapsed().as_millis() as u64, - "method": method.as_str(), "path": req_path, "url": ex_url, - "provider": provider_name, "requestedModel": routing.client_facing_model, - "outgoingModel": routing.outgoing_model, "clientFacingModel": routing.client_facing_model, - "status": 200, "reqHeaders": ex_req_headers, "reqBody": ex_req_body, - "clientReq": ex_client_req, "translated": ex_translated, - "resHeaders": json!({ "content-type": "application/json" }), - "resBody": json!({ "text": String::from_utf8_lossy(&rbody), "bytes": rbody.len(), "truncated": 0 }), - })) - .await; - return Response::builder() - .status(200) - .header("content-type", "application/json") - .body(Body::from(rbody)) - .unwrap(); - } - - // Translated response: decode the (buffered) upstream reply → IR → re-encode to the client's - // protocol. We forced stream=false upstream, so the reply is always buffered here. - if let Some((client_wire, provider_wire, ref client_model, wanted_stream, _incremental, - ref tool_context, ref history_request, ref history_scope)) = translate { - let text = String::from_utf8_lossy(&buf); - if !status.is_success() { - st.log("warn", format!("upstream {} on translated request ({})", status.as_u16(), provider_name)); - st.emit_request(ex_id, started, &method, &req_path, &provider_name, &routing, status.as_u16(), None); - return error_response( - StatusCode::from_u16(status.as_u16()).unwrap_or(StatusCode::BAD_GATEWAY), - &format!("CC Buddy upstream error: {}", text.chars().take(400).collect::()), - "api_error", - ); - } - let ir = match crate::protocol::decode_upstream_response(provider_wire, &text) { - Ok(ir) => ir, - Err(e) => { - st.log("error", format!("response translate ({:?}→{:?}) failed: {}", provider_wire, client_wire, e)); - st.emit_request(ex_id, started, &method, &req_path, &provider_name, &routing, 502, None); - return error_response(StatusCode::BAD_GATEWAY, &format!("CC Buddy response translation failed: {}", e), "api_error"); - } - }; - let mut usage = UsageAcc::default(); - if let Some(u) = ir.usage.as_ref() { - usage.input = u.prompt_tokens as i64; - usage.output = u.completion_tokens as i64; - usage.saw = true; - } - let (ct_out, body_bytes, terminal_response) = if wanted_stream { - let sse = if client_wire == crate::protocol::Wire::OpenAiResponses { - crate::protocol::openai_responses::encode_response_sse_with_context( - &ir, - client_model, - tool_context, - ) - } else { - crate::protocol::encode_client_response_sse(client_wire, &ir, client_model).unwrap_or_default() - }; - let terminal = (client_wire == crate::protocol::Wire::OpenAiResponses) - .then(|| responses_terminal_event(&sse)) - .flatten(); - ("text/event-stream", Bytes::from(sse), terminal) - } else { - let j = if client_wire == crate::protocol::Wire::OpenAiResponses { - crate::protocol::openai_responses::encode_response_with_context( - &ir, - client_model, - tool_context, - ) - } else { - crate::protocol::encode_client_response(client_wire, &ir, client_model).unwrap_or_else(|_| json!({})) - }; - let terminal = (client_wire == crate::protocol::Wire::OpenAiResponses) - .then(|| responses_terminal_object(&j)) - .flatten(); - ("application/json", Bytes::from(serde_json::to_vec(&j).unwrap_or_default()), terminal) - }; - if is_gemini_upstream { - let captured_calls = if client_wire == crate::protocol::Wire::OpenAiResponses { - terminal_response - .as_ref() - .filter(|terminal| terminal.kind == ResponsesTerminalKind::Completed) - .and_then(|terminal| terminal.response.as_ref()) - .map(|response| response_tool_calls_with_client_ids(&ir, response)) - .unwrap_or_default() - } else { - response_tool_calls(&ir) - }; - if !captured_calls.is_empty() { - st.thought_signatures.lock().await.remember( - &routing.provider_id, - request_session.as_deref(), - &captured_calls, - ); - } - } - if let Some(terminal) = terminal_response.as_ref() { - if terminal.kind.is_resumable() { - if let Some(response) = terminal.response.as_ref() { - st.codex_history - .record_response_scoped_with_metadata( - history_scope, - ResponseOrigin::Local, - true, - history_request, - response, - ) - .await; - } - } - } - st.emit_request(ex_id, started, &method, &req_path, &provider_name, &routing, status.as_u16(), Some(&usage)); - st.record_exchange(json!({ - "id": ex_id, "ts": now_ms(), "ms": started.elapsed().as_millis() as u64, - "method": method.as_str(), "path": req_path, "url": ex_url, - "provider": provider_name, "requestedModel": routing.client_facing_model, - "outgoingModel": routing.outgoing_model, "clientFacingModel": routing.client_facing_model, - "status": status.as_u16(), "reqHeaders": ex_req_headers, "reqBody": ex_req_body, - "clientReq": ex_client_req, "translated": ex_translated, - "upstreamRes": json!({ "status": status.as_u16(), "headers": vec_headers(&out_headers), "body": cap_text(&buf, 1024 * 1024) }), - "resHeaders": json!({ "content-type": ct_out, "x-ccbud-translated": format!("{:?}->{:?}", provider_wire, client_wire) }), - "resBody": cap_text(&body_bytes, 2 * 1024 * 1024), - })) - .await; - let mut builder = Response::builder() - .status(status.as_u16()) - .header("content-type", ct_out) - .header("x-ccbud-translated", format!("{:?}->{:?}", provider_wire, client_wire)); - for (k, v) in &out_headers { - if k == "request-id" || k == "x-request-id" { - builder = builder.header(k, v); - } - } - return builder.body(Body::from(body_bytes)).unwrap(); - } - - let mut out_buf = buf.clone(); - let mut usage = UsageAcc::default(); - if ct.contains("application/json") || native_responses_history.is_some() { - if let Ok(mut o) = serde_json::from_slice::(&buf) { - if let Some(u) = o.get("usage").cloned() { - usage.input += u.get("input_tokens").and_then(|v| v.as_i64()).unwrap_or(0); - usage.output += u.get("output_tokens").and_then(|v| v.as_i64()).unwrap_or(0); - usage.cache_read += u.get("cache_read_input_tokens").and_then(|v| v.as_i64()).unwrap_or(0); - usage.cache_creation += u.get("cache_creation_input_tokens").and_then(|v| v.as_i64()).unwrap_or(0); - usage.saw = true; - } - if status.is_success() { - if let (Some(history), Some(terminal)) = ( - native_responses_history.as_ref(), - responses_terminal_object(&o), - ) { - if terminal.kind.is_resumable() { - st.codex_history - .record_response_scoped_with_metadata( - &history.scope, - ResponseOrigin::Native(history.provider_id.clone()), - history.materializable, - &history.request, - &o, - ) - .await; - } - } - } - if need_rewrite { - if let Some(cf) = &routing.client_facing_model { - if o.get("model").and_then(|v| v.as_str()).is_some() { - o["model"] = json!(cf); - if let Ok(b) = serde_json::to_vec(&o) { - out_buf = Bytes::from(b); - } - } - } - } - } - } - st.emit_request(ex_id, started, &method, &req_path, &provider_name, &routing, status.as_u16(), Some(&usage)); - st.record_exchange(json!({ - "id": ex_id, "ts": now_ms(), "ms": started.elapsed().as_millis() as u64, - "method": method.as_str(), "path": req_path, "url": ex_url, - "provider": provider_name, "requestedModel": routing.client_facing_model, - "outgoingModel": routing.outgoing_model, "clientFacingModel": routing.client_facing_model, - "status": status.as_u16(), "reqHeaders": ex_req_headers, "reqBody": ex_req_body, - "clientReq": ex_client_req, "translated": ex_translated, - "resHeaders": vec_headers(&out_headers), "resBody": cap_text(&out_buf, 2 * 1024 * 1024), - })) - .await; - - let mut builder = Response::builder().status(status.as_u16()); - for (k, v) in &out_headers { - builder = builder.header(k, v); - } - builder.body(Body::from(out_buf)).unwrap() -} - -// ---- mock upstream + end-to-end gateway selftest (debug only) ---- - -/// Spawn an in-process mock Anthropic-style upstream on a random port. Echoes back the model -/// the gateway forwarded (proving the outgoing rewrite), with usage, as JSON or SSE. -pub async fn start_mock_upstream() -> Option { - let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0)).await.ok()?; - let port = listener.local_addr().ok()?.port(); - let app: Router = Router::new().fallback(mock_handler); - tauri::async_runtime::spawn(async move { - let _ = axum::serve(listener, app).await; - }); - Some(port) -} - -async fn mock_handler(req: axum::extract::Request) -> Response { - let (parts, body) = req.into_parts(); - let path = parts.uri.path().to_string(); - let bytes = to_bytes(body, 1024 * 1024).await.unwrap_or_default(); - if path.ends_with("/count_tokens") || path == "/" { - // Simulate a provider that implements neither count_tokens nor `HEAD /` → the gateway - // estimates locally / serves the health-probe fallback. - return Response::builder() - .status(404) - .header("content-type", "application/json") - .body(Body::from("{\"error\":\"not found\"}")) - .unwrap(); - } - let v: Value = serde_json::from_slice(&bytes).unwrap_or_else(|_| json!({})); - let stream = v.get("stream").and_then(|s| s.as_bool()).unwrap_or(false); - let model = v.get("model").and_then(|m| m.as_str()).unwrap_or("upstream-model").to_string(); - // OpenAI Chat endpoint: answer in Chat Completions shape so the gateway's protocol translation - // (Anthropic→chat request, chat→Anthropic response) can be exercised end-to-end. The gateway - // forces stream=false upstream when translating, so we only need the buffered form here. - if path.contains("/chat/completions") { - if stream { - // OpenAI Chat streaming chunks (text split across two chunks + a usage-bearing final - // chunk), so the incremental transcoder is exercised end-to-end. - let sse = format!( - "data: {{\"choices\":[{{\"index\":0,\"delta\":{{\"role\":\"assistant\"}}}}]}}\n\n\ - data: {{\"choices\":[{{\"index\":0,\"delta\":{{\"content\":\"hi \"}}}}]}}\n\n\ - data: {{\"choices\":[{{\"index\":0,\"delta\":{{\"content\":\"from chat\"}}}}]}}\n\n\ - data: {{\"choices\":[{{\"index\":0,\"delta\":{{}},\"finish_reason\":\"stop\"}}],\"usage\":{{\"prompt_tokens\":12,\"completion_tokens\":5}}}}\n\n\ - data: [DONE]\n\n" - ); - let _ = &model; - return Response::builder() - .status(200) - .header("content-type", "text/event-stream") - .body(Body::from(sse)) - .unwrap(); - } - return json_response( - StatusCode::OK, - &json!({ - "id": "chatcmpl-mock", "object": "chat.completion", "created": 1, "model": model, - "choices": [{ "index": 0, "finish_reason": "stop", - "message": { "role": "assistant", "content": "hi from chat" } }], - "usage": { "prompt_tokens": 12, "completion_tokens": 5, "total_tokens": 17 }, - }), - ); - } - // OpenAI Responses endpoint: reply in Responses shape (buffered) so messages→responses can be - // exercised end-to-end. (The gateway forces stream=false upstream for the responses direction.) - if path.ends_with("/responses") || path.ends_with("/responses/") { - return json_response( - StatusCode::OK, - &json!({ - "id": "resp-mock", "object": "response", "created_at": 1, "model": model, "status": "completed", - "output": [{ "type": "message", "role": "assistant", - "content": [{ "type": "output_text", "text": "hi from responses" }] }], - "output_text": "hi from responses", - "usage": { "input_tokens": 14, "output_tokens": 6, "total_tokens": 20 }, - }), - ); - } - if stream { - // Anthropic streaming with a real text block, so the Anthropic→Responses incremental - // transcoder (Codex client) has content to carry, not just usage bookkeeping. - let sse = format!( - "event: message_start\ndata: {{\"type\":\"message_start\",\"message\":{{\"id\":\"msg_mock\",\"model\":\"{m}\",\"usage\":{{\"input_tokens\":10,\"cache_read_input_tokens\":0,\"cache_creation_input_tokens\":0}}}}}}\n\nevent: content_block_start\ndata: {{\"type\":\"content_block_start\",\"index\":0,\"content_block\":{{\"type\":\"text\",\"text\":\"\"}}}}\n\nevent: content_block_delta\ndata: {{\"type\":\"content_block_delta\",\"index\":0,\"delta\":{{\"type\":\"text_delta\",\"text\":\"hi from anthropic\"}}}}\n\nevent: content_block_stop\ndata: {{\"type\":\"content_block_stop\",\"index\":0}}\n\nevent: message_delta\ndata: {{\"type\":\"message_delta\",\"usage\":{{\"output_tokens\":7}}}}\n\nevent: message_stop\ndata: {{\"type\":\"message_stop\"}}\n\n", - m = model - ); - Response::builder() - .status(200) - .header("content-type", "text/event-stream") - .body(Body::from(sse)) - .unwrap() - } else { - json_response( - StatusCode::OK, - &json!({ "id":"msg_mock", "type":"message", "role":"assistant", "model":model, "content":[{"type":"text","text":"hi"}], "stop_reason":"end_turn", "usage":{"input_tokens":10,"output_tokens":7} }), - ) - } -} - -/// End-to-end gateway test against the mock upstream: routing + response model rewrite for both -/// buffered JSON and streaming SSE. Mutates CCBUD_HOME config (only called in a throwaway run). -pub async fn gateway_selftest(gport: u16) -> Value { - if gport == 0 { - return json!({ "err": "gateway not running" }); - } - let mock = match start_mock_upstream().await { - Some(p) => p, - None => return json!({ "err": "mock failed to start" }), - }; - let cfg = json!({ "port": gport, "activeProviderId":"mock", "providers":[ - { "id":"mock","name":"Mock","baseUrl":format!("http://127.0.0.1:{}", mock),"authToken":"k","defaultModel":"upstream-model","smallFastModel":"upstream-model","mapDefaultModels":true,"models":[{"alias":"test-alias","upstream":"upstream-model"}] } - ]}); - store::write_config(cfg); - tokio::time::sleep(std::time::Duration::from_millis(80)).await; - - let client = reqwest::Client::new(); - let base = format!("http://127.0.0.1:{}/v1/messages", gport); - - let ns = client - .post(&base) - .json(&json!({ "model":"test-alias","max_tokens":8,"messages":[{"role":"user","content":"hi"}] })) - .send() - .await; - let (ns_status, ns_model) = match ns { - Ok(r) => { - let s = r.status().as_u16(); - let j: Value = r.json().await.unwrap_or_else(|_| json!({})); - (s, j.get("model").and_then(|m| m.as_str()).unwrap_or("").to_string()) - } - Err(e) => (0, format!("ERR:{}", e)), - }; - - let stm = client - .post(&base) - .json(&json!({ "model":"test-alias","stream":true,"max_tokens":8,"messages":[{"role":"user","content":"hi"}] })) - .send() - .await; - let (st_status, st_text) = match stm { - Ok(r) => (r.status().as_u16(), r.text().await.unwrap_or_default()), - Err(e) => (0, format!("ERR:{}", e)), - }; - - // count_tokens — mock 404s, so the gateway must estimate locally - let ct = client - .post(format!("http://127.0.0.1:{}/v1/messages/count_tokens", gport)) - .json(&json!({ "model":"test-alias","messages":[{"role":"user","content":"hello world this is a token counting test"}] })) - .send() - .await; - let (ct_status, ct_tokens, ct_estimated) = match ct { - Ok(r) => { - let s = r.status().as_u16(); - let estimated = r.headers().get("x-ccbud-tokens").and_then(|v| v.to_str().ok()) == Some("estimated"); - let j: Value = r.json().await.unwrap_or_else(|_| json!({})); - (s, j.get("input_tokens").and_then(|v| v.as_i64()).unwrap_or(-1), estimated) - } - Err(_) => (0, -1, false), - }; - - // ---- protocol translation: Claude Code (Anthropic /v1/messages) → an OpenAI-Chat provider ---- - // Reconfigure the mock provider to speak openai-chat, then hit /v1/messages and prove the - // response comes back Anthropic-shaped (non-stream) and as a valid Anthropic SSE (stream). - let cfg2 = json!({ "port": gport, "activeProviderId":"mockoa", "providers":[ - { "id":"mockoa","name":"MockOpenAI","baseUrl":format!("http://127.0.0.1:{}", mock),"authToken":"k","protocol":"openai-chat","defaultModel":"gpt-mock","smallFastModel":"gpt-mock","mapDefaultModels":true,"models":[{"alias":"test-alias","upstream":"gpt-mock"}] } - ]}); - store::write_config(cfg2.clone()); - tokio::time::sleep(std::time::Duration::from_millis(50)).await; - - let tx_ns = client - .post(&base) - .json(&json!({ "model":"test-alias","max_tokens":8,"messages":[{"role":"user","content":"hi"}] })) - .send() - .await; - let (tx_ns_status, tx_ns_anthropic, tx_ns_text, tx_ns_model) = match tx_ns { - Ok(r) => { - let s = r.status().as_u16(); - let j: Value = r.json().await.unwrap_or_else(|_| json!({})); - let is_msg = j.get("type").and_then(|v| v.as_str()) == Some("message"); - let text = j.get("content").and_then(|c| c.as_array()).and_then(|a| a.first()) - .and_then(|b| b.get("text")).and_then(|v| v.as_str()).unwrap_or("").to_string(); - let model = j.get("model").and_then(|v| v.as_str()).unwrap_or("").to_string(); - (s, is_msg, text, model) - } - Err(e) => (0, false, format!("ERR:{}", e), String::new()), - }; - - let tx_st = client - .post(&base) - .json(&json!({ "model":"test-alias","stream":true,"max_tokens":8,"messages":[{"role":"user","content":"hi"}] })) - .send() - .await; - let (tx_st_status, tx_st_text) = match tx_st { - Ok(r) => (r.status().as_u16(), r.text().await.unwrap_or_default()), - Err(e) => (0, format!("ERR:{}", e)), - }; - - // ---- protocol translation: Claude Code (Anthropic /v1/messages) → an OpenAI-Responses provider ---- - let cfg3 = json!({ "port": gport, "activeProviderId":"mockre", "providers":[ - { "id":"mockre","name":"MockResponses","baseUrl":format!("http://127.0.0.1:{}", mock),"authToken":"k","protocol":"openai-responses","defaultModel":"gpt-mock","smallFastModel":"gpt-mock","mapDefaultModels":true,"models":[{"alias":"test-alias","upstream":"gpt-mock"}] } - ]}); - store::write_config(cfg3); - tokio::time::sleep(std::time::Duration::from_millis(50)).await; - let rx = client - .post(&base) - .json(&json!({ "model":"test-alias","max_tokens":8,"messages":[{"role":"user","content":"hi"}] })) - .send() - .await; - let (rx_status, rx_anthropic, rx_text) = match rx { - Ok(r) => { - let s = r.status().as_u16(); - let j: Value = r.json().await.unwrap_or_else(|_| json!({})); - let is_msg = j.get("type").and_then(|v| v.as_str()) == Some("message"); - let text = j.get("content").and_then(|c| c.as_array()).and_then(|a| a.first()) - .and_then(|b| b.get("text")).and_then(|v| v.as_str()).unwrap_or("").to_string(); - (s, is_msg, text) - } - Err(e) => (0, false, format!("ERR:{}", e)), - }; - - // ---- reverse: an OpenAI-Chat client (/v1/chat/completions) → an Anthropic provider ---- - let cfg4 = json!({ "port": gport, "activeProviderId":"mockan", "providers":[ - { "id":"mockan","name":"MockAnthropic","baseUrl":format!("http://127.0.0.1:{}", mock),"authToken":"k","protocol":"anthropic","defaultModel":"claude-mock","smallFastModel":"claude-mock","mapDefaultModels":true,"models":[{"alias":"test-alias","upstream":"claude-mock"}] } - ]}); - store::write_config(cfg4); - tokio::time::sleep(std::time::Duration::from_millis(50)).await; - let rev = client - .post(format!("http://127.0.0.1:{}/v1/chat/completions", gport)) - .json(&json!({ "model":"test-alias","messages":[{"role":"user","content":"hi"}] })) - .send() - .await; - let (rev_status, rev_is_chat, rev_text) = match rev { - Ok(r) => { - let s = r.status().as_u16(); - let j: Value = r.json().await.unwrap_or_else(|_| json!({})); - let is_chat = j.get("object").and_then(|v| v.as_str()) == Some("chat.completion"); - let text = j.get("choices").and_then(|c| c.as_array()).and_then(|a| a.first()) - .and_then(|c| c.get("message")).and_then(|m| m.get("content")).and_then(|v| v.as_str()).unwrap_or("").to_string(); - (s, is_chat, text) - } - Err(e) => (0, false, format!("ERR:{}", e)), - }; - - // ---- Codex (OpenAI-Responses client, /v1/responses) → an Anthropic provider ---- - // The shape Codex sends with wire_api="responses": instructions + item-based input + flattened - // function tools. Non-stream proves the buffered translate; stream proves the incremental - // Anthropic→Responses transcoder (item done events + terminal response.completed). - let codex_body = json!({ "model":"test-alias", "instructions":"be nice", - "input":[{ "type":"message","role":"user","content":[{ "type":"input_text","text":"hi" }] }], - "tools":[{ "type":"function","name":"shell","description":"run","parameters":{ "type":"object" } }], - "tool_choice":"auto", "store": false }); - let cdx = client - .post(format!("http://127.0.0.1:{}/v1/responses", gport)) - .json(&codex_body) - .send() - .await; - let (cdx_status, cdx_is_response, cdx_text) = match cdx { - Ok(r) => { - let s = r.status().as_u16(); - let j: Value = r.json().await.unwrap_or_else(|_| json!({})); - let is_resp = j.get("object").and_then(|v| v.as_str()) == Some("response"); - let text = j.get("output_text").and_then(|v| v.as_str()).unwrap_or("").to_string(); - (s, is_resp, text) - } - Err(e) => (0, false, format!("ERR:{}", e)), - }; - let mut codex_stream_body = codex_body.clone(); - codex_stream_body["stream"] = json!(true); - let cdx_st = client - .post(format!("http://127.0.0.1:{}/v1/responses", gport)) - .json(&codex_stream_body) - .send() - .await; - let (cdx_st_status, cdx_st_text) = match cdx_st { - Ok(r) => (r.status().as_u16(), r.text().await.unwrap_or_default()), - Err(e) => (0, format!("ERR:{}", e)), - }; - - // ---- Codex → an OpenAI-Chat provider (incremental chat→Responses transcoding) ---- - store::write_config(cfg2.clone()); - tokio::time::sleep(std::time::Duration::from_millis(50)).await; - let cdx_chat = client - .post(format!("http://127.0.0.1:{}/v1/responses", gport)) - .json(&codex_stream_body) - .send() - .await; - let (cdx_chat_status, cdx_chat_text) = match cdx_chat { - Ok(r) => (r.status().as_u16(), r.text().await.unwrap_or_default()), - Err(e) => (0, format!("ERR:{}", e)), - }; - - json!({ - "nonStreamStatus": ns_status, - "nonStreamModel": ns_model, - "nonStreamRewritten": ns_model == "test-alias", - "xlateResponsesStatus": rx_status, - "xlateResponsesIsAnthropic": rx_anthropic, - "xlateResponsesText": rx_text, - "revChatStatus": rev_status, - "revChatIsChatCompletion": rev_is_chat, - "revChatText": rev_text, - "streamStatus": st_status, - "streamHasStart": st_text.contains("message_start"), - "streamRewritten": st_text.contains("\"test-alias\"") && !st_text.contains("upstream-model"), - "countTokensStatus": ct_status, - "countTokensEstimated": ct_estimated, - "countTokens": ct_tokens, - // protocol translation (messages→chat) - "xlateNonStreamStatus": tx_ns_status, - "xlateNonStreamIsAnthropic": tx_ns_anthropic, - "xlateNonStreamText": tx_ns_text, - "xlateNonStreamModel": tx_ns_model, - "xlateStreamStatus": tx_st_status, - "xlateStreamHasStart": tx_st_text.contains("message_start"), - "xlateStreamHasStop": tx_st_text.contains("message_stop"), - // incremental transcode: OpenAI chunks → Anthropic text_delta events (text split across - // chunks), a real content_block_delta, and end_turn stop. - "xlateStreamIncremental": tx_st_text.contains("content_block_delta") && tx_st_text.contains("text_delta"), - "xlateStreamText": tx_st_text.contains("from chat"), - "xlateStreamStop": tx_st_text.contains("\"stop_reason\":\"end_turn\""), - // Codex (Responses client): buffered translate + incremental stream transcoders. Codex - // materializes items from response.output_item.done and requires response.completed. - "codexNonStreamStatus": cdx_status, - "codexNonStreamIsResponse": cdx_is_response, - "codexNonStreamText": cdx_text, - "codexAnthropicStreamStatus": cdx_st_status, - "codexAnthropicStreamDelta": cdx_st_text.contains("response.output_text.delta"), - "codexAnthropicStreamItemDone": cdx_st_text.contains("response.output_item.done"), - "codexAnthropicStreamCompleted": cdx_st_text.contains("response.completed"), - "codexAnthropicStreamText": cdx_st_text.contains("hi from anthropic"), - "codexChatStreamStatus": cdx_chat_status, - "codexChatStreamDelta": cdx_chat_text.contains("response.output_text.delta"), - "codexChatStreamItemDone": cdx_chat_text.contains("response.output_item.done"), - "codexChatStreamCompleted": cdx_chat_text.contains("response.completed"), - "codexChatStreamText": cdx_chat_text.contains("from chat"), - }) -} - -/// In-binary equivalent of test/selftest.js's 8 routing unit checks. -pub fn routing_selftest() -> Value { - let config = json!({ "port":0, "activeProviderId":"glm", "providers":[ - { "id":"glm","name":"GLM","baseUrl":"https://x","authToken":"","defaultModel":"glm-5.1","smallFastModel":"glm-5.1","mapDefaultModels":true,"models":[{"alias":"claude-opus-4.8[1m]","upstream":"glm-5.1"}] } - ]}); - let cfg2 = json!({ "port":0, "activeProviderId":"main", "providers":[ - { "id":"main","name":"Main","baseUrl":"http://127.0.0.1:1","authToken":"k","defaultModel":"big-model","smallFastModel":"small-model","mapDefaultModels":true,"models":[{"alias":"my-alias","upstream":"aliased-up"}] }, - { "id":"other","name":"Other","baseUrl":"http://127.0.0.1:2","authToken":"k","defaultModel":"other-big","smallFastModel":"other-small","mapDefaultModels":true,"models":[{"alias":"other-alias","upstream":"other-up"}] } - ]}); - let off = json!({ "port":0, "activeProviderId":"m", "providers":[ - { "id":"m","name":"M","baseUrl":"http://127.0.0.1:1","authToken":"k","defaultModel":"big","smallFastModel":"small","mapDefaultModels":false,"models":[] } - ]}); - - let out = |r: &Option| r.as_ref().and_then(|x| x.outgoing_model.clone()); - let cf = |r: &Option| r.as_ref().and_then(|x| x.client_facing_model.clone()); - let pidf = |r: &Option| r.as_ref().map(|x| x.provider_id.clone()); - - let mut fails: Vec = vec![]; - let mut n = 0; - let mut chk = |name: &str, cond: bool| { - n += 1; - if !cond { - fails.push(name.to_string()); - } - }; - - let r = resolve_routing(Some("claude-opus-4.8[1m]"), &config, None); - chk("1 alias→upstream", out(&r).as_deref() == Some("glm-5.1") && cf(&r).as_deref() == Some("claude-opus-4.8[1m]")); - let r = resolve_routing(Some("glm-5.1"), &config, None); - chk("2 real passthrough", out(&r).as_deref() == Some("glm-5.1") && cf(&r).as_deref() == Some("glm-5.1")); - let r = resolve_routing(Some("claude-3-5-haiku-20241022"), &cfg2, None); - chk("3 haiku→light", out(&r).as_deref() == Some("small-model")); - let r = resolve_routing(Some("claude-sonnet-4-6"), &cfg2, None); - chk("4 sonnet→primary", out(&r).as_deref() == Some("big-model")); - let r = resolve_routing(Some("gpt-4-turbo"), &cfg2, None); - chk("5 foreign→light", out(&r).as_deref() == Some("small-model")); - let mut known = HashSet::new(); - known.insert("glm-5.2".to_string()); - let r = resolve_routing(Some("glm-5.2"), &cfg2, Some(&known)); - chk("6 known passthrough", out(&r).as_deref() == Some("glm-5.2")); - let r = resolve_routing(Some("other-alias"), &cfg2, None); - chk("7 stays on active", pidf(&r).as_deref() == Some("main") && out(&r).as_deref() == Some("small-model")); - let r = resolve_routing(Some("whatever-x"), &off, None); - chk("8 mapoff passthrough", out(&r).as_deref() == Some("whatever-x")); - let r = resolve_routing(Some("gpt-5.5-ccbud"), &cfg2, None); - chk( - "9 codex sentinel→primary", - out(&r).as_deref() == Some("big-model") && cf(&r).as_deref() == Some("gpt-5.5-ccbud"), - ); - - json!({ "total": n, "passed": n - fails.len(), "failed": fails.len(), "fails": fails }) -} - -#[cfg(test)] -mod tests { - use super::*; - #[test] - fn routing_parity_with_proxy_js() { - let r = routing_selftest(); - assert_eq!(r.get("failed").and_then(|v| v.as_i64()), Some(0), "routing mismatch: {:?}", r); - assert_eq!(r.get("passed").and_then(|v| v.as_i64()), Some(9)); - } - #[test] - fn synthesize_models_includes_claude_tiers() { - let cfg = json!({ "providers": [{ "id": "p", "defaultModel": "m", "smallFastModel": "m" }], "activeProviderId": "p" }); - let s = synthesize_models(&cfg, false); - let ids: Vec<&str> = s["data"].as_array().unwrap().iter().filter_map(|m| m["id"].as_str()).collect(); - assert!(ids.contains(&"claude-sonnet-5")); - assert!(ids.contains(&"claude-fable-5")); - assert!(!ids.iter().any(|id| id.starts_with("gpt-"))); - } - #[test] - fn synthesize_models_codex_returns_gpt_tiers() { - let cfg = json!({ "providers": [{ "id": "p", "defaultModel": "m", "smallFastModel": "m" }], "activeProviderId": "p" }); - let s = synthesize_models(&cfg, true); - let ids: Vec<&str> = s["data"].as_array().unwrap().iter().filter_map(|m| m["id"].as_str()).collect(); - assert!(ids.contains(&"gpt-5.4")); - assert!(ids.contains(&"gpt-5.4-mini")); - assert!(!ids.iter().any(|id| id.starts_with("claude-"))); - } - #[test] - fn responses_chat_translation_preserves_parallel_tool_calls() { - let mut body = json!({ "model": "upstream", "messages": [] }); - apply_responses_chat_request_controls( - &mut body, - &json!({ "parallel_tool_calls": false }), - ); - assert_eq!(body["parallel_tool_calls"], false); - - let mut absent = json!({ "model": "upstream", "messages": [] }); - apply_responses_chat_request_controls(&mut absent, &json!({})); - assert!(absent.get("parallel_tool_calls").is_none()); - } - #[test] - fn build_target_collapses_path_overlap() { - let u = |s: &str| s.parse::().unwrap(); - // openai-* provider / sidecar plugin: base ends in /v1 and the client path - // repeats /v1 → collapse (was ".../v1/v1/responses" → 404). - assert_eq!(build_target("http://127.0.0.1:57085/v1", &u("/v1/responses")).unwrap(), "http://127.0.0.1:57085/v1/responses"); - assert_eq!(build_target("http://127.0.0.1:57085/v1", &u("/v1/models?x=1")).unwrap(), "http://127.0.0.1:57085/v1/models?x=1"); - // non-overlapping prefix (anthropic providers) → plain concat, unchanged. - assert_eq!(build_target("https://api.deepseek.com/anthropic", &u("/v1/messages")).unwrap(), "https://api.deepseek.com/anthropic/v1/messages"); - // base without a path → unchanged. - assert_eq!(build_target("http://127.0.0.1:9", &u("/v1/responses")).unwrap(), "http://127.0.0.1:9/v1/responses"); - // segment-aware: a /v1 base must NOT eat a /v1beta path. - assert_eq!(build_target("http://h/v1", &u("/v1beta/x")).unwrap(), "http://h/v1/v1beta/x"); - } - #[test] - fn primary_endpoints_use_the_configured_base_and_offer_one_v1_fallback() { - let u = |s: &str| s.parse::().unwrap(); - assert_eq!( - endpoint_targets("https://example.com/api", &u("/v1/messages?x=1")), - Some(( - "https://example.com/api/messages?x=1".to_string(), - Some("https://example.com/api/v1/messages?x=1".to_string()), - )) - ); - assert_eq!( - endpoint_targets("https://example.com/v4", &u("/v1/chat/completions")), - Some(("https://example.com/v4/chat/completions".to_string(), None)) - ); - assert_eq!( - endpoint_targets("https://example.com/v1", &u("/v1/responses")), - Some(("https://example.com/v1/responses".to_string(), None)) - ); - assert_eq!( - endpoint_targets("https://example.com/v1", &u("/v1/responses/compact")), - Some(("https://example.com/v1/responses/compact".to_string(), None)) - ); - assert_eq!( - endpoint_targets( - "https://generativelanguage.googleapis.com/v1beta/openai", - &u("/v1/chat/completions"), - ), - Some(( - "https://generativelanguage.googleapis.com/v1beta/openai/chat/completions".to_string(), - None, - )) - ); - assert_eq!(endpoint_targets("https://example.com/api", &u("/v1/models")), None); - assert_eq!(endpoint_targets("https://example.com/api", &u("/v1/messages/count_tokens")), None); - } - #[tokio::test] - async fn compact_rejects_cross_wire_and_allows_responses_passthrough() { - for provider_wire in [ - crate::protocol::Wire::OpenAiChat, - crate::protocol::Wire::Anthropic, - ] { - let response = cross_wire_compact_error("/v1/responses/compact/", provider_wire) - .expect("cross-wire compact must be rejected locally"); - assert_eq!(response.status(), StatusCode::NOT_IMPLEMENTED); - let body = to_bytes(response.into_body(), 4096).await.unwrap(); - let error: Value = serde_json::from_slice(&body).unwrap(); - assert_eq!(error["error"]["type"], "invalid_request_error"); - assert!(error["error"]["message"] - .as_str() - .unwrap() - .contains("cross-protocol compaction is not supported")); - } - - assert!(cross_wire_compact_error( - "/v1/responses/compact", - crate::protocol::Wire::OpenAiResponses, - ) - .is_none()); - assert!(cross_wire_compact_error( - "/v1/responses", - crate::protocol::Wire::OpenAiChat, - ) - .is_none()); - } - #[test] - fn responses_history_policy_covers_native_and_translated_provider_switches() { - let known = |origin: ResponseOrigin, materializable: bool| HistoryResolution { - changed: 3, - had_previous_response_id: true, - previous_found: true, - previous_materialized: materializable, - previous_origin: Some(origin), - }; - let portable = ResponsesHistoryDecision { - forward: ResponsesForwardMode::Materialized, - descendant_materializable: true, - }; - - // Native Responses A → translated chat/Anthropic. - assert_eq!( - decide_responses_history( - crate::protocol::Wire::OpenAiChat, - "provider-chat", - &known(ResponseOrigin::Native("provider-a".to_string()), true), - ), - Ok(portable) - ); - // Translated/local → native Responses. - assert_eq!( - decide_responses_history( - crate::protocol::Wire::OpenAiResponses, - "provider-b", - &known(ResponseOrigin::Local, true), - ), - Ok(portable) - ); - // Native Responses A → native Responses B. - assert_eq!( - decide_responses_history( - crate::protocol::Wire::OpenAiResponses, - "provider-b", - &known(ResponseOrigin::Native("provider-a".to_string()), true), - ), - Ok(portable) - ); - // Same native owner keeps the provider-side id while the materialized local copy is retained - // for recording its descendant. - assert_eq!( - decide_responses_history( - crate::protocol::Wire::OpenAiResponses, - "provider-a", - &known(ResponseOrigin::Native("provider-a".to_string()), true), - ), - Ok(ResponsesHistoryDecision { - forward: ResponsesForwardMode::Original, - descendant_materializable: true, - }) - ); - assert_eq!( - decide_responses_history( - crate::protocol::Wire::OpenAiResponses, - "provider-a", - &known(ResponseOrigin::Native("provider-a".to_string()), false), - ), - Ok(ResponsesHistoryDecision { - forward: ResponsesForwardMode::Original, - descendant_materializable: false, - }) - ); - - let missing = HistoryResolution { - had_previous_response_id: true, - ..HistoryResolution::default() - }; - assert_eq!( - decide_responses_history( - crate::protocol::Wire::OpenAiResponses, - "provider-a", - &missing, - ), - Ok(ResponsesHistoryDecision { - forward: ResponsesForwardMode::Original, - descendant_materializable: false, - }) - ); - assert_eq!( - decide_responses_history( - crate::protocol::Wire::Anthropic, - "provider-anthropic", - &missing, - ), - Err(ResponsesHistoryError::Unavailable) - ); - assert_eq!( - decide_responses_history( - crate::protocol::Wire::OpenAiResponses, - "provider-a", - &HistoryResolution { - changed: 2, - ..HistoryResolution::default() - }, - ), - Ok(ResponsesHistoryDecision { - forward: ResponsesForwardMode::Materialized, - descendant_materializable: true, - }) - ); - assert_eq!( - decide_responses_history( - crate::protocol::Wire::OpenAiResponses, - "provider-b", - &known(ResponseOrigin::Local, false), - ), - Err(ResponsesHistoryError::Unavailable) - ); - } - #[test] - fn responses_compact_localizes_portable_foreign_history_and_allows_owner_or_cache_miss() { - let known = |origin: ResponseOrigin, materializable: bool| HistoryResolution { - changed: 2, - had_previous_response_id: true, - previous_found: true, - previous_materialized: materializable, - previous_origin: Some(origin), - }; - let missing = HistoryResolution { - had_previous_response_id: true, - ..HistoryResolution::default() - }; - assert_eq!( - decide_responses_compact_history("provider-a", &missing), - Ok(ResponsesForwardMode::Original) - ); - assert_eq!( - decide_responses_compact_history( - "provider-a", - &known(ResponseOrigin::Native("provider-a".to_string()), false), - ), - Ok(ResponsesForwardMode::Original) - ); - for origin in [ - ResponseOrigin::Local, - ResponseOrigin::Native("provider-b".to_string()), - ] { - assert_eq!( - decide_responses_compact_history("provider-a", &known(origin, true)), - Ok(ResponsesForwardMode::Materialized) - ); - } - assert_eq!( - decide_responses_compact_history( - "provider-a", - &known(ResponseOrigin::Local, false), - ), - Err(ResponsesHistoryError::Unavailable) - ); - } - #[test] - fn responses_terminal_parser_keeps_completed_and_incomplete_but_marks_failed() { - for (event_type, status, expected) in [ - ( - "response.completed", - "completed", - ResponsesTerminalKind::Completed, - ), - ( - "response.incomplete", - "incomplete", - ResponsesTerminalKind::Incomplete, - ), - ("response.failed", "failed", ResponsesTerminalKind::Failed), - ] { - let sse = format!( - "event: {event_type}\ndata: {{\"type\":\"{event_type}\",\"response\":{{\"id\":\"resp_1\",\"object\":\"response\",\"status\":\"{status}\",\"output\":[]}}}}\n\n" - ); - let terminal = responses_terminal_event(&sse).unwrap(); - assert_eq!(terminal.kind, expected); - assert_eq!(terminal.kind.is_resumable(), status != "failed"); - assert_eq!(terminal.response.as_ref().unwrap()["status"], status); - } - assert!(responses_terminal_event( - "event: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"delta\":\"partial\"}\n\n" - ) - .is_none()); - } - #[test] - fn routing_classifies_by_family() { - let cfg = json!({ "providers": [{ "id": "p", "baseUrl": "http://127.0.0.1:1", "authToken": "k", - "defaultModel": "big", "smallFastModel": "small", "mapDefaultModels": true, "models": [] }], "activeProviderId": "p" }); - let out = |r: Option| r.and_then(|x| x.outgoing_model); - // Claude: haiku → fast, fable/opus/sonnet → primary. - assert_eq!(out(resolve_routing(Some("claude-haiku-4-5"), &cfg, None)).as_deref(), Some("small")); - assert_eq!(out(resolve_routing(Some("claude-fable-5"), &cfg, None)).as_deref(), Some("big")); - assert_eq!(out(resolve_routing(Some("claude-opus-4-8"), &cfg, None)).as_deref(), Some("big")); - // Codex: stable/default identities → primary; explicit small tiers → fast. Legacy - // sol/terra names remain primary for existing configs. - assert_eq!( - out(resolve_routing(Some("gpt-5.4"), &cfg, None)).as_deref(), - Some("big") - ); - assert_eq!( - out(resolve_routing(Some("gpt-5.4-mini"), &cfg, None)).as_deref(), - Some("small") - ); - assert_eq!(out(resolve_routing(Some("gpt-5.6-sol"), &cfg, None)).as_deref(), Some("big")); - assert_eq!(out(resolve_routing(Some("gpt-5.6-terra"), &cfg, None)).as_deref(), Some("big")); - assert_eq!(out(resolve_routing(Some("gpt-5.6-sol-pro"), &cfg, None)).as_deref(), Some("big")); - assert_eq!(out(resolve_routing(Some("gpt-5.6-luna"), &cfg, None)).as_deref(), Some("small")); - } - #[test] - fn retry_delay_honors_seconds_and_backoff() { - assert_eq!(retry_delay(Some("2"), 0, 500), 2000); - assert_eq!(retry_delay(None, 0, 500), 500); - assert_eq!(retry_delay(None, 1, 500), 1000); - // HTTP-date in the past → no wait (clamped to 0), NOT a fall-through to backoff. - assert_eq!(retry_delay(Some("Wed, 21 Oct 2015 07:28:00 GMT"), 3, 500), 0); - // Unparseable Retry-After → exponential backoff (base * 2^attempt). - assert_eq!(retry_delay(Some("soon"), 2, 500), 2000); - } - #[test] - fn extracts_claude_session_id_from_metadata() { - let nested = json!({ "metadata": { "user_id": "{\"session_id\":\"session-123\",\"account_id\":\"a\"}" } }); - assert_eq!(request_session_id(&nested).as_deref(), Some("session-123")); - assert!(request_session_id(&json!({ "metadata": { "user_id": "user-123" } })).is_none()); - // Codex (Responses client) identifies its conversation via prompt_cache_key. - assert_eq!( - request_session_id(&json!({ "prompt_cache_key": "conv-42" })).as_deref(), - Some("conv-42") - ); - assert!(request_session_id(&json!({ "prompt_cache_key": " " })).is_none()); - assert_eq!(codex_history_scope_for_session(Some("conv-42")), "conv-42"); - assert_eq!(codex_history_scope_for_session(None), ""); - } - - // The full Codex ⇄ Gemini(chat) signature round-trip: what ChatToResponses captured last turn - // is restored onto the function_call history Codex echoes back, and earlier steps get the - // documented fallback sentinel — without it Gemini 3 rejects the request with a 400. - #[test] - fn restores_signatures_for_codex_responses_requests() { - let mut cache = ThoughtSignatureCache::default(); - cache.remember("google", Some("conv-42"), &[ - crate::protocol::stream::CapturedToolCall { - call_id: "call_9".to_string(), - name: "shell".to_string(), - arguments: "{\"command\":[\"ls\"]}".to_string(), - thought_signature: Some("sig-codex".to_string()), - }, - ]); - let codex = json!({ - "model": "gpt-5.5-ccbud", - "instructions": "You are Codex.", - "prompt_cache_key": "conv-42", - "input": [ - { "type": "message", "role": "user", "content": [{ "type": "input_text", "text": "list, then read" }] }, - { "type": "function_call", "call_id": "call_1", "name": "shell", "arguments": "{\"command\":[\"pwd\"]}" }, - { "type": "function_call_output", "call_id": "call_1", "output": "/tmp" }, - { "type": "function_call", "call_id": "call_9", "name": "shell", "arguments": "{ \"command\": [\"ls\"] }" }, - { "type": "function_call_output", "call_id": "call_9", "output": "a.txt" } - ], - "store": false, "stream": true - }); - let mut ir = crate::protocol::decode_client_request(crate::protocol::Wire::OpenAiResponses, &codex).unwrap(); - assert_eq!(request_session_id(&codex).as_deref(), Some("conv-42")); - assert_eq!(cache.restore("google", Some("conv-42"), &mut ir), 1); - assert_eq!(apply_gemini_signature_fallback(&mut ir), 1); - let steps: Vec<_> = ir.messages.iter().filter_map(|message| message.tool_calls.as_ref()).collect(); - assert_eq!(crate::protocol::tool_call_thought_signature(&steps[0][0]).as_deref(), - Some(GEMINI_SIGNATURE_FALLBACK)); - assert_eq!(crate::protocol::tool_call_thought_signature(&steps[1][0]).as_deref(), - Some("sig-codex")); - // …and the encoded Gemini chat body carries them where Gemini validates them. - let body = crate::protocol::encode_upstream_request( - crate::protocol::Wire::OpenAiChat, &ir, "gemini-3-flash-preview", true, - ).unwrap(); - let signatures: Vec<_> = body["messages"].as_array().unwrap().iter() - .filter(|message| message["role"] == "assistant") - .map(|message| message["tool_calls"][0]["extra_content"]["google"]["thought_signature"].clone()) - .collect(); - assert_eq!(signatures, vec![json!(GEMINI_SIGNATURE_FALLBACK), json!("sig-codex")]); - } - - #[test] - fn repairs_malformed_history_arguments_before_strict_chat_forwarding() { - let body = json!({ - "model": "gpt-5.4", - "input": [ - { "type": "message", "role": "user", "content": [{ - "type": "input_text", "text": "Use the helper and continue" - }] }, - { "type": "function_call", "call_id": "call_bad", "name": "helper", - "arguments": "{\"value\":1} trailing-garbage" }, - { "type": "function_call_output", "call_id": "call_bad", - "output": "failed to parse function arguments" } - ], - "tools": [{ "type": "function", "name": "helper", "description": "test", - "parameters": { "type": "object", "properties": { "value": { "type": "number" } } } }] - }); - let mut ir = crate::protocol::decode_client_request( - crate::protocol::Wire::OpenAiResponses, - &body, - ) - .unwrap(); - let call = ir.messages[1].tool_calls.as_mut().unwrap().first_mut().unwrap(); - call.thought_signature = Some("stale-signature".to_string()); - - assert_eq!(sanitize_provider_history_tool_arguments(&mut ir), 1); - let call = &ir.messages[1].tool_calls.as_ref().unwrap()[0]; - assert_eq!(serde_json::from_str::(&call.function.arguments).unwrap()["value"], 1); - assert!(crate::protocol::tool_call_thought_signature(call).is_none()); - assert_eq!(apply_gemini_signature_fallback(&mut ir), 1); - - let encoded = crate::protocol::encode_upstream_request( - crate::protocol::Wire::OpenAiChat, - &ir, - "gemini-3.5-flash", - false, - ) - .unwrap(); - let outgoing = &encoded["messages"][1]["tool_calls"][0]; - assert_eq!( - serde_json::from_str::(outgoing["function"]["arguments"].as_str().unwrap()) - .unwrap()["value"], - 1 - ); - assert_eq!( - outgoing["extra_content"]["google"]["thought_signature"], - GEMINI_SIGNATURE_FALLBACK - ); - } - - #[test] - fn history_argument_repair_preserves_valid_objects_and_wraps_unrecoverable_text() { - assert_eq!(provider_safe_history_tool_arguments(" { \"value\": 1 } "), None); - let scalar = provider_safe_history_tool_arguments("42").unwrap(); - assert_eq!(serde_json::from_str::(&scalar).unwrap()["_ccbuddy_value"], 42); - let raw = provider_safe_history_tool_arguments("not json at all").unwrap(); - assert_eq!( - serde_json::from_str::(&raw).unwrap()["_ccbuddy_raw_arguments"], - "not json at all" - ); - - for arguments in ["{\"value\":1}\u{00a0}", "\u{000b}{\"value\":1}"] { - let repaired = provider_safe_history_tool_arguments(arguments).unwrap(); - assert_eq!( - serde_json::from_str::(&repaired).unwrap(), - json!({ "value": 1 }) - ); - } - } - - #[test] - fn history_argument_repair_clears_a_signature_restored_for_different_bytes() { - let captured = crate::protocol::stream::CapturedToolCall { - call_id: "call_empty".to_string(), - name: "helper".to_string(), - arguments: String::new(), - thought_signature: Some("real-signature".to_string()), - }; - let mut cache = ThoughtSignatureCache::default(); - cache.remember("google", Some("session-empty"), &[captured]); - let body = json!({ - "model": "gpt-5.4", - "input": [ - { "role": "user", "content": "Call helper" }, - { "type": "function_call", "call_id": "call_empty", "name": "helper", - "arguments": "" }, - { "type": "function_call_output", "call_id": "call_empty", "output": "invalid" } - ] - }); - let mut ir = crate::protocol::decode_client_request( - crate::protocol::Wire::OpenAiResponses, - &body, - ) - .unwrap(); - - assert_eq!(cache.restore("google", Some("session-empty"), &mut ir), 1); - assert_eq!(sanitize_provider_history_tool_arguments(&mut ir), 1); - let call = &ir.messages[1].tool_calls.as_ref().unwrap()[0]; - assert_eq!(call.function.arguments, "{}"); - assert!(crate::protocol::tool_call_thought_signature(call).is_none()); - assert_eq!(apply_gemini_signature_fallback(&mut ir), 1); - assert_eq!( - crate::protocol::tool_call_thought_signature( - &ir.messages[1].tool_calls.as_ref().unwrap()[0] - ) - .as_deref(), - Some(GEMINI_SIGNATURE_FALLBACK) - ); - } - - #[test] - fn restores_latest_batch_and_falls_back_for_prior_steps() { - let call = |id: &str, name: &str, arguments: &str, signature: Option<&str>| { - crate::protocol::stream::CapturedToolCall { - call_id: id.to_string(), - name: name.to_string(), - arguments: arguments.to_string(), - thought_signature: signature.map(str::to_string), - } - }; - let mut cache = ThoughtSignatureCache::default(); - cache.remember("google", Some("session-1"), &[ - call("default_api:Bash", "default_api:Bash", "{\"command\":\"pwd\"}", Some("sig-old")), - ]); - cache.remember("google", Some("session-1"), &[ - call("call_paris", "weather", "{ \"city\": \"Paris\" }", Some("sig-latest")), - call("call_london", "weather", "{\"city\":\"London\"}", None), - ]); - let claude = json!({ - "model": "claude-sonnet-5", "max_tokens": 1024, - "messages": [ - { "role": "user", "content": "Run pwd, then check Paris and London" }, - { "role": "assistant", "content": [{ - "type": "tool_use", "id": "default_api:Bash", "name": "default_api:Bash", - "input": { "command": "pwd" } - }] }, - { "role": "user", "content": [{ - "type": "tool_result", "tool_use_id": "default_api:Bash", "content": "/tmp" - }] }, - { "role": "assistant", "content": [ - { "type": "tool_use", "id": "call_paris", "name": "weather", - "input": { "city": "Paris" } }, - { "type": "tool_use", "id": "call_london", "name": "weather", - "input": { "city": "London" } } - ] }, - { "role": "user", "content": [ - { "type": "tool_result", "tool_use_id": "call_paris", "content": "15C" }, - { "type": "tool_result", "tool_use_id": "call_london", "content": "12C" } - ] } - ] - }); - let mut ir = crate::protocol::decode_client_request(crate::protocol::Wire::Anthropic, &claude).unwrap(); - assert_eq!(cache.restore("google", Some("session-1"), &mut ir), 1); - assert_eq!(apply_gemini_signature_fallback(&mut ir), 1); - let steps: Vec<_> = ir.messages.iter().filter_map(|message| message.tool_calls.as_ref()).collect(); - assert_eq!(crate::protocol::tool_call_thought_signature(&steps[0][0]).as_deref(), - Some(GEMINI_SIGNATURE_FALLBACK)); - assert_eq!(crate::protocol::tool_call_thought_signature(&steps[1][0]).as_deref(), - Some("sig-latest")); - assert!(crate::protocol::tool_call_thought_signature(&steps[1][1]).is_none()); - } - - #[test] - fn sessionless_cache_access_prunes_expired_batches() { - let stale = ThoughtSignatureBatch { - calls: vec![], - touched_at: now_ms().saturating_sub(THOUGHT_SIGNATURE_TTL_MS + 1), - }; - let mut cache = ThoughtSignatureCache::default(); - cache.batches.insert(("google".into(), "stale".into()), stale.clone()); - cache.remember("google", None, &[]); - assert!(cache.batches.is_empty()); - - cache.batches.insert(("google".into(), "stale".into()), stale); - let body = json!({ - "model": "claude-sonnet-5", - "max_tokens": 1, - "messages": [{ "role": "user", "content": "ping" }] - }); - let mut request = crate::protocol::decode_client_request( - crate::protocol::Wire::Anthropic, - &body, - ).unwrap(); - assert_eq!(cache.restore("google", None, &mut request), 0); - assert!(cache.batches.is_empty()); - } -} diff --git a/src-tauri/src/gateway/capture.rs b/src-tauri/src/gateway/capture.rs new file mode 100644 index 0000000..8c92728 --- /dev/null +++ b/src-tauri/src/gateway/capture.rs @@ -0,0 +1,128 @@ +use axum::http::Method; +use serde_json::{json, Value}; +use std::sync::Arc; + +use super::monitor::UsageAcc; +use super::routing::Routing; +use super::state::GatewayState; + +/// Makes a streaming request visible in the monitor even when the client aborts mid-stream. +/// The row + exchange record are normally emitted at the END of the response generator; when the +/// client disconnects, axum simply drops the generator and that code never runs — the request +/// vanished from the request stream (Codex users interrupt turns constantly). The generator owns +/// this guard: `complete()` hands back the prepared exchange (bodies filled) for the normal path, +/// and Drop-without-complete emits the row + a record. +/// +/// The response capture buffers live IN the guard rather than in generator locals: a dropped +/// generator then still records whatever already streamed through. This matters beyond real +/// aborts — Responses clients (Codex) tear the connection down the moment the terminal +/// `response.completed` event arrives, before upstream EOF, which used to lose BOTH response +/// bodies on every transcoded turn. When the transcoder has already emitted its terminal event +/// (`finished`), that disconnect is the normal end of a turn and is not flagged `aborted`. +pub(super) struct StreamAbortGuard { + armed: bool, + st: Arc, + id: u64, + started: std::time::Instant, + method: Method, + path: String, + provider: String, + routing: Routing, + status: u16, + ex: Value, + res_cap: String, + up_cap: Option, + pub(super) finished: bool, + pub(super) usage: Option, +} + +/// Raw upstream capture (pre-translation) for transcoded streams: status + headers are fixed at +/// guard construction, text accumulates as chunks arrive. +struct UpCapture { + status: u16, + headers: Value, + text: String, + total: usize, +} + +const RES_CAP_MAX: usize = 2 * 1024 * 1024; +const UP_CAP_MAX: usize = 1024 * 1024; + +impl StreamAbortGuard { + #[allow(clippy::too_many_arguments)] + pub(super) fn new( + st: Arc, + id: u64, + started: std::time::Instant, + method: Method, + path: String, + provider: String, + routing: Routing, + status: u16, + ex: Value, + upstream: Option<(u16, Value)>, + ) -> Self { + let up_cap = upstream.map(|(status, headers)| UpCapture { status, headers, text: String::new(), total: 0 }); + Self { + armed: true, st, id, started, method, path, provider, routing, status, ex, + res_cap: String::new(), up_cap, finished: false, usage: None, + } + } + + /// Append to the client-facing response capture (the translated stream for transcoded pairs). + pub(super) fn push_res(&mut self, s: &str) { + if self.res_cap.len() < RES_CAP_MAX { + self.res_cap.push_str(s); + } + } + + /// Append raw upstream bytes (pre-translation) when this guard tracks an upstream capture. + pub(super) fn push_up(&mut self, raw: &str) { + if let Some(u) = self.up_cap.as_mut() { + u.total += raw.len(); + if u.text.len() < UP_CAP_MAX { + u.text.push_str(raw); + } + } + } + + /// Write the captured bodies into the exchange skeleton — shared by normal and abort paths. + fn fill_bodies(&mut self) { + self.ex["resBody"] = json!({ "text": self.res_cap, "bytes": self.res_cap.len(), "truncated": 0 }); + if let Some(u) = self.up_cap.as_ref() { + self.ex["upstreamRes"] = json!({ "status": u.status, "headers": u.headers, + "body": { "text": u.text, "bytes": u.total, "truncated": u.total.saturating_sub(u.text.len()) } }); + } + } + + /// Normal completion: disarm and hand the exchange (bodies filled) back to the caller (who + /// fills in ms / usage and records it). + pub(super) fn complete(&mut self) -> Value { + self.armed = false; + self.fill_bodies(); + std::mem::take(&mut self.ex) + } +} + +impl Drop for StreamAbortGuard { + fn drop(&mut self) { + if !self.armed { + return; + } + self.fill_bodies(); + let mut ex = std::mem::take(&mut self.ex); + ex["ms"] = json!(self.started.elapsed().as_millis() as u64); + // A disconnect after the transcoder's terminal event is the normal end of a Responses + // turn — only flag genuinely interrupted streams. + if !self.finished { + ex["aborted"] = json!(true); + } + self.st.emit_request(self.id, self.started, &self.method, &self.path, &self.provider, &self.routing, self.status, self.usage.as_ref()); + let st = self.st.clone(); + // record_exchange is async and Drop is sync — spawn it, tolerating an already-torn-down + // runtime at app quit. + let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(move || { + tauri::async_runtime::spawn(async move { st.record_exchange(ex).await }); + })); + } +} diff --git a/src-tauri/src/gateway/finish.rs b/src-tauri/src/gateway/finish.rs new file mode 100644 index 0000000..a29fb07 --- /dev/null +++ b/src-tauri/src/gateway/finish.rs @@ -0,0 +1,166 @@ +use axum::{ + body::Body, + http::{Method, StatusCode}, + response::Response, +}; +use bytes::Bytes; +use serde_json::{json, Value}; +use std::collections::HashSet; +use std::sync::Arc; + + +use super::models::{merge_models, synthesize_models}; +use super::redact::now_ms; +use super::routing::Routing; +use super::state::GatewayState; + +#[allow(clippy::too_many_arguments)] +pub(super) async fn finish_head_root( + st: &Arc, + ex_id: u64, + started: std::time::Instant, + method: Method, + req_path: String, + provider_name: String, + routing: Routing, + ex_req_headers: Value, + ex_req_body: Value, + ex_client_req: Value, + ex_translated: Option, + ex_url: String, +) -> Response { + st.log("info", format!("HEAD / fallback: upstream 404 → gateway 200 ({})", provider_name)); + st.emit_request(ex_id, started, &method, &req_path, &provider_name, &routing, 200, None); + st.record_exchange(json!({ + "id": ex_id, "ts": now_ms(), "ms": started.elapsed().as_millis() as u64, + "method": method.as_str(), "path": req_path, "url": ex_url, + "provider": provider_name, "requestedModel": routing.client_facing_model, + "outgoingModel": routing.outgoing_model, "clientFacingModel": routing.client_facing_model, + "status": 200, "reqHeaders": ex_req_headers, "reqBody": ex_req_body, + "clientReq": ex_client_req, "translated": ex_translated, + "resHeaders": json!({ "x-ccbud-fallback": "head-root-404-to-200", "x-ccbud-upstream-status": "404" }), + "resBody": json!({ "text": "", "bytes": 0, "truncated": 0 }), + })) + .await; + return Response::builder() + .status(200) + .header("x-ccbud-fallback", "head-root-404-to-200") + .header("x-ccbud-upstream-status", "404") + .body(Body::empty()) + .unwrap(); +} + +#[allow(clippy::too_many_arguments)] +pub(super) async fn finish_count_tokens( + st: &Arc, + status: StatusCode, + buf: Bytes, + out_headers: Vec<(String, String)>, + parsed: &Option, + ex_id: u64, + started: std::time::Instant, + method: Method, + req_path: String, + provider_name: String, + routing: Routing, + ex_req_headers: Value, + ex_req_body: Value, + ex_client_req: Value, + ex_translated: Option, + ex_url: String, +) -> Response { +// count_tokens: pass the upstream's real number when it implements the endpoint; otherwise +// (404 / non-JSON / missing input_tokens) estimate locally so Claude Code's sizing keeps working. + let upstream_ok = status.is_success() + && serde_json::from_slice::(&buf) + .ok() + .and_then(|o| o.get("input_tokens").and_then(|v| v.as_i64())) + .is_some(); + if upstream_ok { + let mut builder = Response::builder().status(200).header("x-ccbud-tokens", "upstream"); + for (k, v) in &out_headers { + builder = builder.header(k, v); + } + st.emit_request(ex_id, started, &method, &req_path, &provider_name, &routing, 200, None); + return builder.body(Body::from(buf)).unwrap(); + } + let est = crate::counttokens::estimate_input_tokens(parsed.as_ref().unwrap_or(&Value::Null)); + let ebody = serde_json::to_vec(&json!({ "input_tokens": est })).unwrap_or_default(); + st.emit_request(ex_id, started, &method, &req_path, &provider_name, &routing, 200, None); + st.record_exchange(json!({ + "id": ex_id, "ts": now_ms(), "ms": started.elapsed().as_millis() as u64, + "method": method.as_str(), "path": req_path, "url": ex_url, + "provider": provider_name, "requestedModel": routing.client_facing_model, + "outgoingModel": routing.outgoing_model, "clientFacingModel": routing.client_facing_model, + "status": 200, "reqHeaders": ex_req_headers, "reqBody": ex_req_body, + "clientReq": ex_client_req, "translated": ex_translated, + "resHeaders": json!({ "x-ccbud-tokens": "estimated", "x-ccbud-upstream-status": status.as_u16().to_string() }), + "resBody": json!({ "text": String::from_utf8_lossy(&ebody), "bytes": ebody.len(), "truncated": 0 }), + })) + .await; + return Response::builder() + .status(200) + .header("content-type", "application/json") + .header("x-ccbud-tokens", "estimated") + .header("x-ccbud-upstream-status", status.as_u16().to_string()) + .body(Body::from(ebody)) + .unwrap(); +} + +#[allow(clippy::too_many_arguments)] +pub(super) async fn finish_models_list( + st: &Arc, + status: StatusCode, + buf: Bytes, + config: &Value, + client_codex: bool, + active_pid: Option, + ex_id: u64, + started: std::time::Instant, + method: Method, + req_path: String, + provider_name: String, + routing: Routing, + ex_req_headers: Value, + ex_req_body: Value, + ex_client_req: Value, + ex_translated: Option, + ex_url: String, +) -> Response { + let mut merged = None; + if status.is_success() { + if let Ok(o) = serde_json::from_slice::(&buf) { + if let Some(data) = o.get("data").and_then(|d| d.as_array()) { + if let Some(pid) = &active_pid { + let ids: HashSet = data + .iter() + .filter_map(|m| m.get("id").and_then(|v| v.as_str()).map(|s| s.to_string())) + .collect(); + if !ids.is_empty() { + st.known.lock().await.insert(pid.clone(), ids); + } + } + merged = Some(merge_models(&o, &config, client_codex)); + } + } + } + let result = merged.unwrap_or_else(|| synthesize_models(&config, client_codex)); + let rbody = serde_json::to_vec(&result).unwrap_or_default(); + st.emit_request(ex_id, started, &method, &req_path, &provider_name, &routing, 200, None); + st.record_exchange(json!({ + "id": ex_id, "ts": now_ms(), "ms": started.elapsed().as_millis() as u64, + "method": method.as_str(), "path": req_path, "url": ex_url, + "provider": provider_name, "requestedModel": routing.client_facing_model, + "outgoingModel": routing.outgoing_model, "clientFacingModel": routing.client_facing_model, + "status": 200, "reqHeaders": ex_req_headers, "reqBody": ex_req_body, + "clientReq": ex_client_req, "translated": ex_translated, + "resHeaders": json!({ "content-type": "application/json" }), + "resBody": json!({ "text": String::from_utf8_lossy(&rbody), "bytes": rbody.len(), "truncated": 0 }), + })) + .await; + return Response::builder() + .status(200) + .header("content-type", "application/json") + .body(Body::from(rbody)) + .unwrap(); +} diff --git a/src-tauri/src/gateway/finish_buffered.rs b/src-tauri/src/gateway/finish_buffered.rs new file mode 100644 index 0000000..5d67411 --- /dev/null +++ b/src-tauri/src/gateway/finish_buffered.rs @@ -0,0 +1,98 @@ +use axum::{ + body::Body, + http::{Method, StatusCode}, + response::Response, +}; +use bytes::Bytes; +use serde_json::{json, Value}; +use std::sync::Arc; + +use crate::protocol::codex_history::ResponseOrigin; + +use super::monitor::UsageAcc; +use super::redact::{cap_text, now_ms, vec_headers}; +use super::responses_history::NativeResponsesHistoryContext; +use super::routing::Routing; +use super::sse::responses_terminal_object; +use super::state::GatewayState; + +#[allow(clippy::too_many_arguments)] +pub(super) async fn finish_buffered( + st: &Arc, + buf: Bytes, + ct: &str, + native_responses_history: Option, + need_rewrite: bool, + routing: Routing, + status: StatusCode, + out_headers: Vec<(String, String)>, + ex_id: u64, + started: std::time::Instant, + method: Method, + req_path: String, + provider_name: String, + ex_req_headers: Value, + ex_req_body: Value, + ex_client_req: Value, + ex_translated: Option, + ex_url: String, +) -> Response { + let mut out_buf = buf.clone(); + let mut usage = UsageAcc::default(); + if ct.contains("application/json") || native_responses_history.is_some() { + if let Ok(mut o) = serde_json::from_slice::(&buf) { + if let Some(u) = o.get("usage").cloned() { + usage.input += u.get("input_tokens").and_then(|v| v.as_i64()).unwrap_or(0); + usage.output += u.get("output_tokens").and_then(|v| v.as_i64()).unwrap_or(0); + usage.cache_read += u.get("cache_read_input_tokens").and_then(|v| v.as_i64()).unwrap_or(0); + usage.cache_creation += u.get("cache_creation_input_tokens").and_then(|v| v.as_i64()).unwrap_or(0); + usage.saw = true; + } + if status.is_success() { + if let (Some(history), Some(terminal)) = ( + native_responses_history.as_ref(), + responses_terminal_object(&o), + ) { + if terminal.kind.is_resumable() { + st.codex_history + .record_response_scoped_with_metadata( + &history.scope, + ResponseOrigin::Native(history.provider_id.clone()), + history.materializable, + &history.request, + &o, + ) + .await; + } + } + } + if need_rewrite { + if let Some(cf) = &routing.client_facing_model { + if o.get("model").and_then(|v| v.as_str()).is_some() { + o["model"] = json!(cf); + if let Ok(b) = serde_json::to_vec(&o) { + out_buf = Bytes::from(b); + } + } + } + } + } + } + st.emit_request(ex_id, started, &method, &req_path, &provider_name, &routing, status.as_u16(), Some(&usage)); + st.record_exchange(json!({ + "id": ex_id, "ts": now_ms(), "ms": started.elapsed().as_millis() as u64, + "method": method.as_str(), "path": req_path, "url": ex_url, + "provider": provider_name, "requestedModel": routing.client_facing_model, + "outgoingModel": routing.outgoing_model, "clientFacingModel": routing.client_facing_model, + "status": status.as_u16(), "reqHeaders": ex_req_headers, "reqBody": ex_req_body, + "clientReq": ex_client_req, "translated": ex_translated, + "resHeaders": vec_headers(&out_headers), "resBody": cap_text(&out_buf, 2 * 1024 * 1024), + })) + .await; + + let mut builder = Response::builder().status(status.as_u16()); + for (k, v) in &out_headers { + builder = builder.header(k, v); + } + builder.body(Body::from(out_buf)).unwrap() +} diff --git a/src-tauri/src/gateway/finish_translated.rs b/src-tauri/src/gateway/finish_translated.rs new file mode 100644 index 0000000..897df63 --- /dev/null +++ b/src-tauri/src/gateway/finish_translated.rs @@ -0,0 +1,159 @@ +use axum::{ + body::Body, + http::{Method, StatusCode}, + response::Response, +}; +use bytes::Bytes; +use serde_json::{json, Value}; +use std::sync::Arc; + +use crate::protocol::codex_history::ResponseOrigin; + +use super::monitor::UsageAcc; +use super::redact::{cap_text, now_ms, vec_headers}; +use super::routing::Routing; +use super::session::{response_tool_calls, response_tool_calls_with_client_ids}; +use super::sse::{responses_terminal_event, responses_terminal_object, ResponsesTerminalKind}; +use super::state::GatewayState; +use super::targets::error_response; + +#[allow(clippy::too_many_arguments)] +pub(super) async fn finish_translated( + st: &Arc, + buf: Bytes, + status: StatusCode, + out_headers: Vec<(String, String)>, + client_wire: crate::protocol::Wire, + provider_wire: crate::protocol::Wire, + client_model: &str, + wanted_stream: bool, + tool_context: &crate::protocol::openai_responses::CodexToolContext, + history_request: &Value, + history_scope: &str, + routing: &Routing, + request_session: Option, + is_gemini_upstream: bool, + ex_id: u64, + started: std::time::Instant, + method: &Method, + req_path: &str, + provider_name: &str, + ex_req_headers: &Value, + ex_req_body: &Value, + ex_client_req: &Value, + ex_translated: &Option, + ex_url: &str, +) -> Response { +// Translated response: decode the (buffered) upstream reply → IR → re-encode to the client's +// protocol. We forced stream=false upstream, so the reply is always buffered here. + let text = String::from_utf8_lossy(&buf); + if !status.is_success() { + st.log("warn", format!("upstream {} on translated request ({})", status.as_u16(), provider_name)); + st.emit_request(ex_id, started, &method, &req_path, &provider_name, &routing, status.as_u16(), None); + return error_response( + StatusCode::from_u16(status.as_u16()).unwrap_or(StatusCode::BAD_GATEWAY), + &format!("CC Buddy upstream error: {}", text.chars().take(400).collect::()), + "api_error", + ); + } + let ir = match crate::protocol::decode_upstream_response(provider_wire, &text) { + Ok(ir) => ir, + Err(e) => { + st.log("error", format!("response translate ({:?}→{:?}) failed: {}", provider_wire, client_wire, e)); + st.emit_request(ex_id, started, &method, &req_path, &provider_name, &routing, 502, None); + return error_response(StatusCode::BAD_GATEWAY, &format!("CC Buddy response translation failed: {}", e), "api_error"); + } + }; + let mut usage = UsageAcc::default(); + if let Some(u) = ir.usage.as_ref() { + usage.input = u.prompt_tokens as i64; + usage.output = u.completion_tokens as i64; + usage.saw = true; + } + let (ct_out, body_bytes, terminal_response) = if wanted_stream { + let sse = if client_wire == crate::protocol::Wire::OpenAiResponses { + crate::protocol::openai_responses::encode_response_sse_with_context( + &ir, + client_model, + tool_context, + ) + } else { + crate::protocol::encode_client_response_sse(client_wire, &ir, client_model).unwrap_or_default() + }; + let terminal = (client_wire == crate::protocol::Wire::OpenAiResponses) + .then(|| responses_terminal_event(&sse)) + .flatten(); + ("text/event-stream", Bytes::from(sse), terminal) + } else { + let j = if client_wire == crate::protocol::Wire::OpenAiResponses { + crate::protocol::openai_responses::encode_response_with_context( + &ir, + client_model, + tool_context, + ) + } else { + crate::protocol::encode_client_response(client_wire, &ir, client_model).unwrap_or_else(|_| json!({})) + }; + let terminal = (client_wire == crate::protocol::Wire::OpenAiResponses) + .then(|| responses_terminal_object(&j)) + .flatten(); + ("application/json", Bytes::from(serde_json::to_vec(&j).unwrap_or_default()), terminal) + }; + if is_gemini_upstream { + let captured_calls = if client_wire == crate::protocol::Wire::OpenAiResponses { + terminal_response + .as_ref() + .filter(|terminal| terminal.kind == ResponsesTerminalKind::Completed) + .and_then(|terminal| terminal.response.as_ref()) + .map(|response| response_tool_calls_with_client_ids(&ir, response)) + .unwrap_or_default() + } else { + response_tool_calls(&ir) + }; + if !captured_calls.is_empty() { + st.thought_signatures.lock().await.remember( + &routing.provider_id, + request_session.as_deref(), + &captured_calls, + ); + } + } + if let Some(terminal) = terminal_response.as_ref() { + if terminal.kind.is_resumable() { + if let Some(response) = terminal.response.as_ref() { + st.codex_history + .record_response_scoped_with_metadata( + history_scope, + ResponseOrigin::Local, + true, + history_request, + response, + ) + .await; + } + } + } + st.emit_request(ex_id, started, &method, &req_path, &provider_name, &routing, status.as_u16(), Some(&usage)); + st.record_exchange(json!({ + "id": ex_id, "ts": now_ms(), "ms": started.elapsed().as_millis() as u64, + "method": method.as_str(), "path": req_path, "url": ex_url, + "provider": provider_name, "requestedModel": routing.client_facing_model, + "outgoingModel": routing.outgoing_model, "clientFacingModel": routing.client_facing_model, + "status": status.as_u16(), "reqHeaders": ex_req_headers, "reqBody": ex_req_body, + "clientReq": ex_client_req, "translated": ex_translated, + "upstreamRes": json!({ "status": status.as_u16(), "headers": vec_headers(&out_headers), "body": cap_text(&buf, 1024 * 1024) }), + "resHeaders": json!({ "content-type": ct_out, "x-ccbud-translated": format!("{:?}->{:?}", provider_wire, client_wire) }), + "resBody": cap_text(&body_bytes, 2 * 1024 * 1024), + })) + .await; + let mut builder = Response::builder() + .status(status.as_u16()) + .header("content-type", ct_out) + .header("x-ccbud-translated", format!("{:?}->{:?}", provider_wire, client_wire)); + for (k, v) in &out_headers { + if k == "request-id" || k == "x-request-id" { + builder = builder.header(k, v); + } + } + return builder.body(Body::from(body_bytes)).unwrap(); +} diff --git a/src-tauri/src/gateway/forward.rs b/src-tauri/src/gateway/forward.rs new file mode 100644 index 0000000..f2b93b6 --- /dev/null +++ b/src-tauri/src/gateway/forward.rs @@ -0,0 +1,191 @@ +use axum::{ + http::{HeaderMap, Method, StatusCode}, + response::Response, +}; +use bytes::Bytes; +use serde_json::Value; +use std::sync::Arc; + +use crate::store; + +use super::finish::{finish_count_tokens, finish_head_root, finish_models_list}; +use super::finish_buffered::finish_buffered; +use super::finish_translated::finish_translated; +use super::redact::HOP_BY_HOP_RES; +use super::responses_history::NativeResponsesHistoryContext; +use super::retry::forward_with_retry; +use super::routing::Routing; +use super::state::GatewayState; +use super::stream_passthrough::stream_passthrough; +use super::stream_transcode::stream_transcoded; +use super::targets::error_response; + +#[allow(clippy::too_many_arguments)] +pub(super) async fn forward_and_finish( + st: &Arc, + config: &Value, + client: &reqwest::Client, + method: Method, + target: String, + v1_fallback_target: Option, + up_headers: HeaderMap, + out_body: Bytes, + original_target: String, + ex_url: String, + retry_enabled: bool, + retry_max: i64, + retry_base: i64, + provider_name: String, + base_url: &str, + is_models_list: bool, + is_count_tokens: bool, + is_head_root: bool, + client_codex: bool, + parsed: Option, + ex_id: u64, + started: std::time::Instant, + req_path: String, + routing: Routing, + ex_req_headers: Value, + ex_req_body: Value, + ex_client_req: Value, + ex_translated: Option, + translate: Option<(crate::protocol::Wire, crate::protocol::Wire, String, bool, bool, + crate::protocol::openai_responses::CodexToolContext, + Value, + String)>, + native_responses_history: Option, + request_session: Option, + is_gemini_upstream: bool, + need_rewrite: bool, + active_pid: Option, + client_wire: crate::protocol::Wire, +) -> Response { + // Forward with the existing 429 retry plus one compatibility attempt at `/v1`. The first + // response is retained until the fallback succeeds, so a failed fallback never masks the + // upstream's original error. + let (resp, used_v1_fallback, ex_url) = match forward_with_retry( + st, client, &method, target, v1_fallback_target, &up_headers, &out_body, + &original_target, ex_url, retry_enabled, retry_max, retry_base, &provider_name, + is_models_list, is_count_tokens, config, client_codex, &parsed, ex_id, started, + &req_path, &routing, + ) + .await + { + Ok(forwarded) => forwarded, + Err(response) => return response, + }; + + if used_v1_fallback { + if let Some(saved) = store::migrate_provider_base_url_to_v1(&routing.provider_id, base_url) { + st.log("info", format!("provider base URL updated with /v1 ({})", provider_name)); + st.emit("config:changed", saved); + } + } + + let status = resp.status(); + let ct = resp.headers().get("content-type").and_then(|v| v.to_str().ok()).unwrap_or("").to_string(); + + if is_head_root && status.as_u16() == 404 { + return finish_head_root( + st, ex_id, started, method, req_path, provider_name, routing, ex_req_headers, + ex_req_body, ex_client_req, ex_translated, ex_url, + ) + .await; + } + + let mut out_headers: Vec<(String, String)> = vec![]; + for (k, v) in resp.headers().iter() { + let kn = k.as_str().to_ascii_lowercase(); + if HOP_BY_HOP_RES.contains(&kn.as_str()) { + continue; + } + if let Ok(s) = v.to_str() { + out_headers.push((k.as_str().to_string(), s.to_string())); + } + } + + // streaming SSE — rewrite model + sniff usage, line-buffered + if ct.contains("text/event-stream") { + // Incremental cross-protocol transcode: feed each upstream SSE line through a stateful + // transcoder that emits the client protocol's events as they arrive (true token-by-token). + if let Some((client_wire, provider_wire, tc, history_request, history_scope)) = translate + .as_ref() + .filter(|t| t.4) + .and_then(|t| { + // can_transcode_stream guarded `incremental`, so new() matches a wired pair. + crate::protocol::stream::Transcoder::new_with_context(t.1, t.0, &t.2, t.5.clone()).map(|tc| (t.0, t.1, tc, t.6.clone(), t.7.clone())) + }) + { + return stream_transcoded( + st, resp, tc, client_wire, provider_wire, history_request, history_scope, routing, + request_session, is_gemini_upstream, status, ex_id, started, method, req_path, + provider_name, out_headers, ex_req_headers, ex_req_body, ex_client_req, + ex_translated, ex_url, + ); + } + return stream_passthrough( + st, resp, need_rewrite, routing, method, req_path, provider_name, status, ex_id, + started, out_headers, ex_req_headers, ex_req_body, ex_client_req, ex_translated, + ex_url, native_responses_history, client_wire, + ); + } + + // buffered (reqwest auto-decoded gzip/br/deflate) + let buf = match resp.bytes().await { + Ok(buf) => buf, + Err(error) => { + let message = format!("upstream response body transport error: {}", error); + st.log("error", format!("{} ({})", message, provider_name)); + st.emit_request( + ex_id, + started, + &method, + &req_path, + &provider_name, + &routing, + StatusCode::BAD_GATEWAY.as_u16(), + None, + ); + return error_response(StatusCode::BAD_GATEWAY, &format!("CC Buddy: {}", message), "api_error"); + } + }; + + if is_count_tokens { + return finish_count_tokens( + st, status, buf, out_headers, &parsed, ex_id, started, method, req_path, + provider_name, routing, ex_req_headers, ex_req_body, ex_client_req, ex_translated, + ex_url, + ) + .await; + } + + if is_models_list { + return finish_models_list( + st, status, buf, config, client_codex, active_pid, ex_id, started, method, req_path, + provider_name, routing, ex_req_headers, ex_req_body, ex_client_req, ex_translated, + ex_url, + ) + .await; + } + + // Translated response: decode the (buffered) upstream reply → IR → re-encode to the client's + // protocol. We forced stream=false upstream, so the reply is always buffered here. + if let Some((client_wire, provider_wire, ref client_model, wanted_stream, _incremental, + ref tool_context, ref history_request, ref history_scope)) = translate { + return finish_translated( + st, buf, status, out_headers, client_wire, provider_wire, client_model, wanted_stream, + tool_context, history_request, history_scope, &routing, request_session, + is_gemini_upstream, ex_id, started, &method, &req_path, &provider_name, + &ex_req_headers, &ex_req_body, &ex_client_req, &ex_translated, &ex_url, + ) + .await; + } + + finish_buffered( + st, buf, &ct, native_responses_history, need_rewrite, routing, status, out_headers, ex_id, + started, method, req_path, provider_name, ex_req_headers, ex_req_body, ex_client_req, + ex_translated, ex_url, + ) + .await +} diff --git a/src-tauri/src/gateway/handler.rs b/src-tauri/src/gateway/handler.rs new file mode 100644 index 0000000..2e7c376 --- /dev/null +++ b/src-tauri/src/gateway/handler.rs @@ -0,0 +1,213 @@ +use axum::{ + body::to_bytes, + extract::State, + http::{Method, StatusCode}, + response::Response, +}; +use bytes::Bytes; +use serde_json::{json, Value}; +use std::sync::Arc; + +use crate::store; + +use super::forward::forward_and_finish; +use super::history_prep::prepare_responses_history; +use super::prepare::{gateway_token_error, parse_request_model, prepare_translation, upstream_headers}; +use super::redact::{cap_text, redact_headers}; +use super::responses_history::NativeResponsesHistoryContext; +use super::routing::{client_is_codex, resolve_routing}; +use super::session::{codex_history_scope_for_session, request_session_id}; +use super::sse::is_responses_compact_path; +use super::state::GatewayState; +use super::targets::{build_target, cross_wire_compact_error, endpoint_targets, error_response}; + +/// The localhost reverse-proxy handler. Mirrors proxy.js `handle`. +pub(super) async fn handle(State(st): State>, req: axum::extract::Request) -> Response { + let started = std::time::Instant::now(); + let (parts, body) = req.into_parts(); + let method = parts.method; + let uri = parts.uri; + let in_headers = parts.headers; + let req_path = uri.path().to_string(); + let body_bytes = to_bytes(body, 64 * 1024 * 1024).await.unwrap_or_default(); + + let config = store::read_config(); + + if let Some(response) = gateway_token_error(&config, &in_headers) { + return response; + } + + let (parsed, requested_model) = parse_request_model(&in_headers, &body_bytes); + + let providers = config.get("providers").and_then(|v| v.as_array()).cloned().unwrap_or_default(); + let active_id = config.get("activeProviderId").and_then(|v| v.as_str()); + let active_pid = providers + .iter() + .find(|p| p.get("id").and_then(|v| v.as_str()) == active_id) + .or_else(|| providers.first()) + .and_then(|p| p.get("id").and_then(|v| v.as_str())) + .map(|s| s.to_string()); + let known = match &active_pid { + Some(pid) => st.known.lock().await.get(pid).cloned(), + None => None, + }; + + let routing = match resolve_routing(requested_model.as_deref(), &config, known.as_ref()) { + Some(r) => r, + None => { + st.log("warn", "request rejected: no provider configured"); + return error_response(StatusCode::BAD_GATEWAY, "CC Buddy: no provider configured. Add one in the app.", "api_error"); + } + }; + let provider = match providers.iter().find(|p| p.get("id").and_then(|v| v.as_str()) == Some(routing.provider_id.as_str())) { + Some(p) => p, + None => return error_response(StatusCode::BAD_GATEWAY, "CC Buddy: no provider configured.", "api_error"), + }; + let base_url = provider.get("baseUrl").and_then(|v| v.as_str()).unwrap_or(""); + let auth_token = provider.get("authToken").and_then(|v| v.as_str()).unwrap_or("").to_string(); + let provider_name = provider + .get("name") + .and_then(|v| v.as_str()) + .unwrap_or(routing.provider_id.as_str()) + .to_string(); + + let need_rewrite = match (routing.client_facing_model.as_ref(), routing.outgoing_model.as_ref()) { + (Some(c), Some(o)) => c != o, + _ => false, + }; + let mut out_body = body_bytes.clone(); + if let (Some(p0), Some(out_model)) = (parsed.as_ref(), routing.outgoing_model.as_ref()) { + if Some(out_model) != requested_model.as_ref() { + let mut p = p0.clone(); + p["model"] = json!(out_model); + if let Ok(b) = serde_json::to_vec(&p) { + out_body = Bytes::from(b); + } + } + } + + let endpoint_pair = if method == Method::POST { + endpoint_targets(base_url, &uri) + } else { + None + }; + let (mut target, mut v1_fallback_target) = match endpoint_pair { + Some(pair) => pair, + None => match build_target(base_url, &uri) { + Some(t) => (t, None), + None => return error_response(StatusCode::BAD_GATEWAY, "CC Buddy: invalid provider baseUrl", "api_error"), + }, + }; + let is_models_list = method == Method::GET && (req_path.ends_with("/v1/models") || req_path.ends_with("/v1/models/")); + // Codex and Claude clients both GET /v1/models — tell them apart by client identity + // so each gets its own family's default model list. + let client_codex = client_is_codex(&in_headers); + let is_head_root = method == Method::HEAD && req_path == "/"; + let is_count_tokens = method == Method::POST + && (req_path.ends_with("/v1/messages/count_tokens") || req_path.ends_with("/v1/messages/count_tokens/")); + + // ---- protocol translation ---- + // When the client's wire protocol (inferred from the request path) differs from the provider's + // declared protocol, translate the request into the provider's format and remember to translate + // the response back. Same-protocol requests skip this entirely and keep the verbatim passthrough + // fast path below (so Anthropic→Anthropic behavior is byte-for-byte unchanged). Streaming pairs + // with an incremental transcoder (see protocol::stream::Transcoder) stream token-by-token; the + // rest force the upstream buffered (stream=false) and synthesize the client SSE from the full + // response. + let client_wire = crate::protocol::Wire::from_request_path(&uri); + let provider_wire = + crate::protocol::Wire::from_provider(provider.get("protocol").and_then(|v| v.as_str())); + let is_responses_compact = method == Method::POST && is_responses_compact_path(&req_path); + if method == Method::POST { + if let Some(response) = cross_wire_compact_error(&req_path, provider_wire) { + return response; + } + } + let request_session = parsed.as_ref().and_then(request_session_id); + // Conversation history belongs to the client session, not the provider: users may switch the + // active provider mid-turn and previous_response_id must still restore the same transcript. + // Sessionless requests can use direct response-id lookup, but never call-id fallback because + // call ids are routinely reused across unrelated agent runs. + let codex_history_scope = codex_history_scope_for_session(request_session.as_deref()); + let allow_codex_call_fallback = request_session.is_some(); + let mut prepared_responses_request: Option = None; + let mut native_responses_history: Option = None; + let mut history_localized = false; + if let Some(response) = prepare_responses_history( + &st, &parsed, &method, client_wire, provider_wire, &routing, &provider_name, + &codex_history_scope, allow_codex_call_fallback, is_responses_compact, + &mut out_body, &mut history_localized, &mut prepared_responses_request, + &mut native_responses_history, + ) + .await + { + return response; + } + let is_gemini_upstream = provider_wire == crate::protocol::Wire::OpenAiChat + && routing.outgoing_model.as_deref().unwrap_or("").to_ascii_lowercase().contains("gemini"); + // translate ctx: (client wire, provider wire, client model, wanted stream, incremental, + // request-scoped Responses tool metadata, full translated client request for history, + // client-session history scope) + // `incremental` = we can transcode the upstream stream event-by-event to the client (true + // token-by-token). Otherwise we force the upstream buffered and synthesize the client response. + let mut translate: Option<(crate::protocol::Wire, crate::protocol::Wire, String, bool, bool, + crate::protocol::openai_responses::CodexToolContext, + Value, + String)> = None; + if client_wire != provider_wire && method == Method::POST && !is_models_list && !is_count_tokens { + if let Some(p) = parsed.as_ref() { + if let Some(response) = prepare_translation( + &st, p, &routing, client_wire, provider_wire, base_url, is_gemini_upstream, + request_session.as_deref(), &prepared_responses_request, &codex_history_scope, + &mut out_body, &mut target, &mut v1_fallback_target, &mut translate, + ) + .await + { + return response; + } + } + } + + // upstream headers (sanitized + provider token swapped in) + let up_headers = upstream_headers(&in_headers, &translate, &auth_token); + + let ex_id = st.next_id(); + let ex_req_headers = redact_headers(&up_headers); + let ex_req_body = cap_text(&out_body, 4 * 1024 * 1024); + let original_target = target.clone(); + let ex_url = target.clone(); + // Client-side view of the exchange — what the gateway RECEIVED, before any translation — so + // the monitor can show a protocol translation's exact before/after (inbound URL/headers/body + // vs. the upstream URL/headers/body above). The body is duplicated only when a translation + // applies; for passthrough, reqBody already IS the client body (modulo the model rewrite). + let ex_translated = translate.as_ref().map(|t| format!("{} → {}", t.0.label(), t.1.label())); + let ex_client_req = { + let mut o = json!({ + "url": uri.path_and_query().map(|p| p.as_str().to_string()).unwrap_or_else(|| req_path.clone()), + "headers": redact_headers(&in_headers), + }); + if ex_translated.is_some() || history_localized { + o["body"] = cap_text(&body_bytes, 1024 * 1024); + } + o + }; + + let insecure = config.get("insecureSkipVerify").and_then(|v| v.as_bool()).unwrap_or(false) + && target.starts_with("https:"); + let client = if insecure { &st.client_insecure } else { &st.client }; + + let rc = config.get("retry429").cloned().unwrap_or(json!({})); + let retry_enabled = rc.get("enabled").map(|v| v.as_bool().unwrap_or(true)).unwrap_or(true); + let retry_max = rc.get("max").and_then(|v| v.as_i64()).unwrap_or(3); + let retry_base = rc.get("baseMs").and_then(|v| v.as_i64()).unwrap_or(500); + + forward_and_finish( + &st, &config, client, method, target, v1_fallback_target, up_headers, out_body, + original_target, ex_url, retry_enabled, retry_max, retry_base, provider_name, base_url, + is_models_list, is_count_tokens, is_head_root, client_codex, parsed, ex_id, started, + req_path, routing, ex_req_headers, ex_req_body, ex_client_req, ex_translated, translate, + native_responses_history, request_session, is_gemini_upstream, need_rewrite, active_pid, + client_wire, + ) + .await +} diff --git a/src-tauri/src/gateway/history_args.rs b/src-tauri/src/gateway/history_args.rs new file mode 100644 index 0000000..e5d830f --- /dev/null +++ b/src-tauri/src/gateway/history_args.rs @@ -0,0 +1,63 @@ +use serde_json::{json, Value}; + +pub(super) fn canonical_tool_arguments(arguments: &str) -> String { + if arguments.trim().is_empty() { + return "{}".to_string(); + } + serde_json::from_str::(arguments) + .map(|v| v.to_string()) + .unwrap_or_else(|_| arguments.to_string()) +} + +/// Codex records a model-emitted function call even when the host cannot parse its arguments, then +/// sends that failed call back on the next Responses turn beside the router error. OpenAI accepts +/// the arguments as an opaque string, but stricter chat providers (notably Gemini) parse every +/// historical `tool_calls[].function.arguments` value and reject the whole request when a model +/// appended prose or a second object. Preserve valid object arguments byte-for-byte so cached +/// thought signatures still match; otherwise salvage the first complete object, or wrap the raw +/// text in a valid object as a last resort. +pub(super) fn provider_safe_history_tool_arguments(arguments: &str) -> Option { + let trimmed = arguments.trim(); + if trimmed.is_empty() { + return Some("{}".to_string()); + } + match serde_json::from_str::(arguments) { + Ok(Value::Object(_)) => return None, + Ok(value) => return Some(json!({ "_ccbuddy_value": value }).to_string()), + Err(_) => {} + } + if let Some(Ok(Value::Object(object))) = serde_json::Deserializer::from_str(trimmed) + .into_iter::() + .next() + { + return Some(Value::Object(object).to_string()); + } + Some(json!({ "_ccbuddy_raw_arguments": arguments }).to_string()) +} + +pub(super) fn sanitize_provider_history_tool_arguments( + request: &mut llm_connector::types::ChatRequest, +) -> usize { + let mut repaired = 0usize; + for message in &mut request.messages { + let Some(calls) = message.tool_calls.as_mut() else { continue }; + for call in calls { + let Some(arguments) = provider_safe_history_tool_arguments(&call.function.arguments) + else { continue }; + call.function.arguments = arguments; + // A provider signature authenticates the exact call payload. Repaired arguments must + // use the documented synthetic-history fallback instead of a now-stale real signature. + call.thought_signature = None; + call.function.thought_signature = None; + repaired += 1; + } + } + repaired +} + +pub(super) fn current_tool_turn_start(request: &llm_connector::types::ChatRequest) -> usize { + request.messages.iter() + .rposition(|message| message.role == llm_connector::types::Role::User) + .map(|index| index + 1) + .unwrap_or(0) +} diff --git a/src-tauri/src/gateway/history_prep.rs b/src-tauri/src/gateway/history_prep.rs new file mode 100644 index 0000000..bee1532 --- /dev/null +++ b/src-tauri/src/gateway/history_prep.rs @@ -0,0 +1,152 @@ +use axum::{ + http::{Method, StatusCode}, + response::Response, +}; +use bytes::Bytes; +use serde_json::Value; +use std::sync::Arc; + +use super::responses_history::{ + decide_responses_compact_history, decide_responses_history, request_body_with_model, + NativeResponsesHistoryContext, ResponsesForwardMode, ResponsesHistoryError, +}; +use super::routing::Routing; +use super::state::GatewayState; +use super::targets::error_response; + +#[allow(clippy::too_many_arguments)] +pub(super) async fn prepare_responses_history( + st: &Arc, + parsed: &Option, + method: &Method, + client_wire: crate::protocol::Wire, + provider_wire: crate::protocol::Wire, + routing: &Routing, + provider_name: &str, + codex_history_scope: &str, + allow_codex_call_fallback: bool, + is_responses_compact: bool, + out_body: &mut Bytes, + history_localized: &mut bool, + prepared_responses_request: &mut Option, + native_responses_history: &mut Option, +) -> Option { + if is_responses_compact { + if let Some(request) = parsed.as_ref() { + let previous_response_id = request + .get("previous_response_id") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToString::to_string); + let mut materialized_request = request.clone(); + let resolution = st + .codex_history + .materialize_request_scoped( + &codex_history_scope, + allow_codex_call_fallback, + &mut materialized_request, + ) + .await; + let forward = match decide_responses_compact_history( + &routing.provider_id, + &resolution, + ) { + Ok(forward) => forward, + Err(ResponsesHistoryError::Unavailable) => { + return Some(error_response( + StatusCode::BAD_REQUEST, + &format!( + "CC Buddy cannot compact previous_response_id '{}' with provider '{}': its complete context cannot be materialized; retry with the owning Responses provider", + previous_response_id.as_deref().unwrap_or(""), + provider_name + ), + "invalid_request_error", + )); + } + }; + if forward == ResponsesForwardMode::Materialized { + *history_localized = true; + let Some(body) = request_body_with_model( + &materialized_request, + routing.outgoing_model.as_deref(), + ) else { + return Some(error_response( + StatusCode::INTERNAL_SERVER_ERROR, + "CC Buddy failed to serialize locally materialized compact history", + "api_error", + )); + }; + *out_body = body; + } + } + } else if client_wire == crate::protocol::Wire::OpenAiResponses && *method == Method::POST { + if let Some(request) = parsed.as_ref() { + let previous_response_id = request + .get("previous_response_id") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToString::to_string); + let mut materialized_request = request.clone(); + let resolution = st + .codex_history + .materialize_request_scoped( + &codex_history_scope, + allow_codex_call_fallback, + &mut materialized_request, + ) + .await; + let decision = match decide_responses_history( + provider_wire, + &routing.provider_id, + &resolution, + ) { + Ok(decision) => decision, + Err(ResponsesHistoryError::Unavailable) => { + let detail = if resolution.previous_found { + "is known locally but its complete context cannot be materialized" + } else { + "is not available in local history" + }; + return Some(error_response( + StatusCode::BAD_REQUEST, + &format!( + "CC Buddy cannot continue previous_response_id '{}' through provider '{}': it {}; retry with the owning Responses provider or start a new conversation", + previous_response_id.as_deref().unwrap_or(""), + provider_name, + detail + ), + "invalid_request_error", + )); + } + }; + if decision.forward == ResponsesForwardMode::Materialized { + *history_localized = true; + if provider_wire == crate::protocol::Wire::OpenAiResponses { + let Some(body) = request_body_with_model( + &materialized_request, + routing.outgoing_model.as_deref(), + ) else { + return Some(error_response( + StatusCode::INTERNAL_SERVER_ERROR, + "CC Buddy failed to serialize locally materialized Responses history", + "api_error", + )); + }; + *out_body = body; + } + } + if provider_wire == crate::protocol::Wire::OpenAiResponses { + *native_responses_history = Some(NativeResponsesHistoryContext { + scope: codex_history_scope.to_string(), + request: materialized_request.clone(), + provider_id: routing.provider_id.clone(), + materializable: decision.descendant_materializable, + }); + } + *prepared_responses_request = Some(materialized_request); + } + } + None +} diff --git a/src-tauri/src/gateway/mock.rs b/src-tauri/src/gateway/mock.rs new file mode 100644 index 0000000..ff93b40 --- /dev/null +++ b/src-tauri/src/gateway/mock.rs @@ -0,0 +1,104 @@ +use axum::{ + body::{to_bytes, Body}, + http::StatusCode, + response::Response, + Router, +}; +use serde_json::{json, Value}; + +use super::targets::json_response; + +// ---- mock upstream + end-to-end gateway selftest (debug only) ---- + +/// Spawn an in-process mock Anthropic-style upstream on a random port. Echoes back the model +/// the gateway forwarded (proving the outgoing rewrite), with usage, as JSON or SSE. +pub async fn start_mock_upstream() -> Option { + let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0)).await.ok()?; + let port = listener.local_addr().ok()?.port(); + let app: Router = Router::new().fallback(mock_handler); + tauri::async_runtime::spawn(async move { + let _ = axum::serve(listener, app).await; + }); + Some(port) +} + +async fn mock_handler(req: axum::extract::Request) -> Response { + let (parts, body) = req.into_parts(); + let path = parts.uri.path().to_string(); + let bytes = to_bytes(body, 1024 * 1024).await.unwrap_or_default(); + if path.ends_with("/count_tokens") || path == "/" { + // Simulate a provider that implements neither count_tokens nor `HEAD /` → the gateway + // estimates locally / serves the health-probe fallback. + return Response::builder() + .status(404) + .header("content-type", "application/json") + .body(Body::from("{\"error\":\"not found\"}")) + .unwrap(); + } + let v: Value = serde_json::from_slice(&bytes).unwrap_or_else(|_| json!({})); + let stream = v.get("stream").and_then(|s| s.as_bool()).unwrap_or(false); + let model = v.get("model").and_then(|m| m.as_str()).unwrap_or("upstream-model").to_string(); + // OpenAI Chat endpoint: answer in Chat Completions shape so the gateway's protocol translation + // (Anthropic→chat request, chat→Anthropic response) can be exercised end-to-end. The gateway + // forces stream=false upstream when translating, so we only need the buffered form here. + if path.contains("/chat/completions") { + if stream { + // OpenAI Chat streaming chunks (text split across two chunks + a usage-bearing final + // chunk), so the incremental transcoder is exercised end-to-end. + let sse = format!( + "data: {{\"choices\":[{{\"index\":0,\"delta\":{{\"role\":\"assistant\"}}}}]}}\n\n\ + data: {{\"choices\":[{{\"index\":0,\"delta\":{{\"content\":\"hi \"}}}}]}}\n\n\ + data: {{\"choices\":[{{\"index\":0,\"delta\":{{\"content\":\"from chat\"}}}}]}}\n\n\ + data: {{\"choices\":[{{\"index\":0,\"delta\":{{}},\"finish_reason\":\"stop\"}}],\"usage\":{{\"prompt_tokens\":12,\"completion_tokens\":5}}}}\n\n\ + data: [DONE]\n\n" + ); + let _ = &model; + return Response::builder() + .status(200) + .header("content-type", "text/event-stream") + .body(Body::from(sse)) + .unwrap(); + } + return json_response( + StatusCode::OK, + &json!({ + "id": "chatcmpl-mock", "object": "chat.completion", "created": 1, "model": model, + "choices": [{ "index": 0, "finish_reason": "stop", + "message": { "role": "assistant", "content": "hi from chat" } }], + "usage": { "prompt_tokens": 12, "completion_tokens": 5, "total_tokens": 17 }, + }), + ); + } + // OpenAI Responses endpoint: reply in Responses shape (buffered) so messages→responses can be + // exercised end-to-end. (The gateway forces stream=false upstream for the responses direction.) + if path.ends_with("/responses") || path.ends_with("/responses/") { + return json_response( + StatusCode::OK, + &json!({ + "id": "resp-mock", "object": "response", "created_at": 1, "model": model, "status": "completed", + "output": [{ "type": "message", "role": "assistant", + "content": [{ "type": "output_text", "text": "hi from responses" }] }], + "output_text": "hi from responses", + "usage": { "input_tokens": 14, "output_tokens": 6, "total_tokens": 20 }, + }), + ); + } + if stream { + // Anthropic streaming with a real text block, so the Anthropic→Responses incremental + // transcoder (Codex client) has content to carry, not just usage bookkeeping. + let sse = format!( + "event: message_start\ndata: {{\"type\":\"message_start\",\"message\":{{\"id\":\"msg_mock\",\"model\":\"{m}\",\"usage\":{{\"input_tokens\":10,\"cache_read_input_tokens\":0,\"cache_creation_input_tokens\":0}}}}}}\n\nevent: content_block_start\ndata: {{\"type\":\"content_block_start\",\"index\":0,\"content_block\":{{\"type\":\"text\",\"text\":\"\"}}}}\n\nevent: content_block_delta\ndata: {{\"type\":\"content_block_delta\",\"index\":0,\"delta\":{{\"type\":\"text_delta\",\"text\":\"hi from anthropic\"}}}}\n\nevent: content_block_stop\ndata: {{\"type\":\"content_block_stop\",\"index\":0}}\n\nevent: message_delta\ndata: {{\"type\":\"message_delta\",\"usage\":{{\"output_tokens\":7}}}}\n\nevent: message_stop\ndata: {{\"type\":\"message_stop\"}}\n\n", + m = model + ); + Response::builder() + .status(200) + .header("content-type", "text/event-stream") + .body(Body::from(sse)) + .unwrap() + } else { + json_response( + StatusCode::OK, + &json!({ "id":"msg_mock", "type":"message", "role":"assistant", "model":model, "content":[{"type":"text","text":"hi"}], "stop_reason":"end_turn", "usage":{"input_tokens":10,"output_tokens":7} }), + ) + } +} diff --git a/src-tauri/src/gateway/mod.rs b/src-tauri/src/gateway/mod.rs new file mode 100644 index 0000000..46ebb9f --- /dev/null +++ b/src-tauri/src/gateway/mod.rs @@ -0,0 +1,51 @@ +// Gateway core. +// +// Implements deterministic model routing and the localhost reverse proxy: header sanitizing, +// upstream forwarding, 429 retry, SSE streaming with model rewrite + usage sniffing, buffered-JSON +// model rewrite, /v1/models merge/synthesize, count_tokens fallback, HEAD / fallback, and bounded +// monitor exchange capture. +#![allow(dead_code)] + +mod capture; +mod finish; +mod finish_buffered; +mod finish_translated; +mod forward; +mod handler; +mod history_args; +mod history_prep; +mod mock; +mod models; +mod monitor; +mod prepare; +mod redact; +mod responses_history; +mod retry; +mod routing; +mod selftest; +mod selftest_routing; +mod selftest_xlate; +mod session; +mod signatures; +mod sse; +mod state; +mod stream_passthrough; +mod stream_transcode; +mod targets; + +#[allow(unused_imports)] +pub use mock::start_mock_upstream; +#[allow(unused_imports)] +pub use routing::{resolve_routing, Routing, CLAUDE_TIER_MODELS, CODEX_TIER_MODELS}; +pub use selftest::gateway_selftest; +pub use selftest_routing::routing_selftest; +pub use state::GatewayState; + +#[cfg(test)] +mod tests; +#[cfg(test)] +mod tests_history; +#[cfg(test)] +mod tests_routing; +#[cfg(test)] +mod tests_signatures; diff --git a/src-tauri/src/gateway/models.rs b/src-tauri/src/gateway/models.rs new file mode 100644 index 0000000..e6ed67a --- /dev/null +++ b/src-tauri/src/gateway/models.rs @@ -0,0 +1,88 @@ +use serde_json::{json, Value}; +use std::collections::HashSet; + +use super::routing::{CLAUDE_TIER_MODELS, CODEX_TIER_MODELS}; + +// ---- /v1/models augmentation ---- +fn model_entry(id: &str) -> Value { + json!({ "type": "model", "id": id, "display_name": id, "created_at": "2025-01-01T00:00:00Z" }) +} +fn alias_entries(config: &Value) -> Vec { + let mut out = vec![]; + let mut seen = HashSet::new(); + if let Some(ps) = config.get("providers").and_then(|v| v.as_array()) { + for p in ps { + if let Some(ms) = p.get("models").and_then(|v| v.as_array()) { + for m in ms { + if let Some(a) = m.get("alias").and_then(|v| v.as_str()) { + if !a.is_empty() && seen.insert(a.to_string()) { + out.push(model_entry(a)); + } + } + } + } + } + } + out +} +/// Default tier models for the requesting client's family (Codex → gpt tiers, +/// Claude → claude tiers). +fn tier_entries(is_codex: bool) -> Vec { + if is_codex { + CODEX_TIER_MODELS.iter().map(|n| model_entry(n)).collect() + } else { + CLAUDE_TIER_MODELS.iter().map(|n| model_entry(n)).collect() + } +} +pub(super) fn merge_models(upstream: &Value, config: &Value, is_codex: bool) -> Value { + let data = upstream.get("data").and_then(|d| d.as_array()).cloned().unwrap_or_default(); + let mut have: HashSet = data + .iter() + .filter_map(|m| m.get("id").and_then(|v| v.as_str()).map(|s| s.to_string())) + .collect(); + let mut adds = vec![]; + for a in alias_entries(config).into_iter().chain(tier_entries(is_codex)) { + let id = a.get("id").and_then(|v| v.as_str()).unwrap_or("").to_string(); + if have.insert(id) { + adds.push(a); + } + } + let mut merged = upstream.clone(); + adds.extend(data); + merged["data"] = json!(adds); + merged +} +pub(super) fn synthesize_models(config: &Value, is_codex: bool) -> Value { + let mut out = alias_entries(config); + if out.is_empty() { + let ps = config.get("providers").and_then(|v| v.as_array()).cloned().unwrap_or_default(); + let active_id = config.get("activeProviderId").and_then(|v| v.as_str()); + let active = ps + .iter() + .find(|p| p.get("id").and_then(|v| v.as_str()) == active_id) + .or_else(|| ps.first()); + let mut seen = HashSet::new(); + if let Some(a) = active { + for k in ["defaultModel", "smallFastModel"] { + if let Some(id) = a.get(k).and_then(|v| v.as_str()) { + if !id.is_empty() && seen.insert(id.to_string()) { + out.push(model_entry(id)); + } + } + } + } + } + let mut have: HashSet = out + .iter() + .filter_map(|m| m.get("id").and_then(|v| v.as_str()).map(|s| s.to_string())) + .collect(); + for e in tier_entries(is_codex) { + let id = e.get("id").and_then(|v| v.as_str()).unwrap_or("").to_string(); + if have.insert(id) { + out.push(e); + } + } + let first = out.first().and_then(|m| m.get("id").cloned()).unwrap_or(Value::Null); + let last = out.last().and_then(|m| m.get("id").cloned()).unwrap_or(Value::Null); + json!({ "data": out, "has_more": false, "first_id": first, "last_id": last }) +} diff --git a/src-tauri/src/gateway/monitor.rs b/src-tauri/src/gateway/monitor.rs new file mode 100644 index 0000000..45e1ee3 --- /dev/null +++ b/src-tauri/src/gateway/monitor.rs @@ -0,0 +1,66 @@ +use axum::http::Method; +use serde_json::{json, Value}; +use std::sync::atomic::Ordering; +use tauri::Emitter; + +use super::routing::Routing; +use super::state::GatewayState; + +impl GatewayState { + pub(super) fn next_id(&self) -> u64 { + self.seq.fetch_add(1, Ordering::Relaxed) + 1 + } + /// Bounded live-debugging capture: keep only the most recent exchanges (matches the monitor + /// stream's 100-row window so every visible row can open its detail). + pub async fn record_exchange(&self, ex: Value) { + let mut buf = self.exchanges.lock().await; + buf.push_back(ex); + while buf.len() > 100 { + buf.pop_front(); + } + } + pub async fn monitor_get(&self, id: i64) -> Value { + let buf = self.exchanges.lock().await; + buf.iter() + .rev() + .find(|e| e.get("id").and_then(|v| v.as_i64()) == Some(id)) + .cloned() + .unwrap_or(Value::Null) + } + pub async fn monitor_clear(&self) { + self.exchanges.lock().await.clear(); + } + pub async fn monitor_recent(&self) -> Value { + self.exchanges.lock().await.back().cloned().unwrap_or(Value::Null) + } + + pub(super) fn emit_request(&self, id: u64, started: std::time::Instant, method: &Method, path: &str, provider: &str, routing: &Routing, status: u16, usage: Option<&UsageAcc>) { + let (it, ot, cr, cc) = usage + .map(|u| (u.input, u.output, u.cache_read, u.cache_creation)) + .unwrap_or((0, 0, 0, 0)); + let _ = self.app.emit( + "gateway:request", + json!({ + "id": id, + "method": method.as_str(), + "path": path, + "provider": provider, + "requestedModel": routing.client_facing_model, + "outgoingModel": routing.outgoing_model, + "clientFacingModel": routing.client_facing_model, + "status": status, + "ms": started.elapsed().as_millis() as u64, + "inputTokens": it, "outputTokens": ot, "cacheRead": cr, "cacheCreation": cc, + }), + ); + } +} + +#[derive(Default, Clone)] +pub(super) struct UsageAcc { + pub(super) input: i64, + pub(super) output: i64, + pub(super) cache_read: i64, + pub(super) cache_creation: i64, + pub(super) saw: bool, +} diff --git a/src-tauri/src/gateway/prepare.rs b/src-tauri/src/gateway/prepare.rs new file mode 100644 index 0000000..8232b51 --- /dev/null +++ b/src-tauri/src/gateway/prepare.rs @@ -0,0 +1,194 @@ +use axum::{ + http::{HeaderMap, HeaderValue, StatusCode}, + response::Response, +}; +use bytes::Bytes; +use serde_json::{json, Value}; +use std::sync::Arc; + +use super::redact::HOP_BY_HOP_REQ; +use super::responses_history::apply_responses_chat_request_controls; +use super::routing::Routing; +use super::signatures::apply_gemini_signature_fallback; +use super::state::GatewayState; +use super::targets::error_response; +use super::history_args::sanitize_provider_history_tool_arguments; + +pub(super) fn gateway_token_error(config: &Value, in_headers: &HeaderMap) -> Option { + // Optional local gateway token (defense in depth; already bound to localhost). + if config.get("requireToken").and_then(|v| v.as_bool()).unwrap_or(false) { + let token = config.get("gatewayToken").and_then(|v| v.as_str()).unwrap_or(""); + if !token.is_empty() { + let auth = in_headers.get("authorization").and_then(|v| v.to_str().ok()).unwrap_or(""); + let bearer = auth + .strip_prefix("Bearer ") + .or_else(|| auth.strip_prefix("bearer ")); + let presented = bearer.unwrap_or_else(|| { + in_headers.get("x-api-key").and_then(|v| v.to_str().ok()).unwrap_or("") + }); + if presented != token { + return Some(error_response(StatusCode::UNAUTHORIZED, "CC Buddy: invalid gateway token", "authentication_error")); + } + } + } + None +} + +pub(super) fn parse_request_model( + in_headers: &HeaderMap, + body_bytes: &Bytes, +) -> (Option, Option) { + let is_json = in_headers + .get("content-type") + .and_then(|v| v.to_str().ok()) + .map(|s| s.contains("application/json")) + .unwrap_or(false); + let mut parsed: Option = None; + let mut requested_model: Option = None; + if !body_bytes.is_empty() && is_json { + if let Ok(v) = serde_json::from_slice::(&body_bytes) { + requested_model = v.get("model").and_then(|m| m.as_str()).map(|s| s.to_string()); + parsed = Some(v); + } + } + (parsed, requested_model) +} + +pub(super) fn upstream_headers( + in_headers: &HeaderMap, + translate: &Option<(crate::protocol::Wire, crate::protocol::Wire, String, bool, bool, + crate::protocol::openai_responses::CodexToolContext, + Value, + String)>, + auth_token: &str, +) -> HeaderMap { + let mut up_headers = HeaderMap::new(); + for (k, v) in in_headers.iter() { + let kn = k.as_str().to_ascii_lowercase(); + if HOP_BY_HOP_REQ.contains(&kn.as_str()) { + continue; + } + up_headers.insert(k.clone(), v.clone()); + } + up_headers.insert(axum::http::header::ACCEPT_ENCODING, HeaderValue::from_static("identity")); + // A translated Anthropic upstream needs the anthropic-version header; OpenAI-family clients + // (Codex) never send one. + if translate.as_ref().map(|t| t.1) == Some(crate::protocol::Wire::Anthropic) + && !up_headers.contains_key("anthropic-version") + { + up_headers.insert("anthropic-version", HeaderValue::from_static("2023-06-01")); + } + if !auth_token.is_empty() { + // Auth via Authorization: Bearer only. Sending both authorization and x-api-key trips + // providers that reject having the two auth headers present at once (matches provider_test). + // Both inbound auth headers are already stripped by HOP_BY_HOP_REQ above. + if let Ok(val) = HeaderValue::from_str(&format!("Bearer {}", auth_token)) { + up_headers.insert(axum::http::header::AUTHORIZATION, val); + } + } + up_headers +} + +#[allow(clippy::too_many_arguments)] +pub(super) async fn prepare_translation( + st: &Arc, + p: &Value, + routing: &Routing, + client_wire: crate::protocol::Wire, + provider_wire: crate::protocol::Wire, + base_url: &str, + is_gemini_upstream: bool, + request_session: Option<&str>, + prepared_responses_request: &Option, + codex_history_scope: &str, + out_body: &mut Bytes, + target: &mut String, + v1_fallback_target: &mut Option, + translate: &mut Option<(crate::protocol::Wire, crate::protocol::Wire, String, bool, bool, + crate::protocol::openai_responses::CodexToolContext, + Value, + String)>, +) -> Option { + let wanted_stream = p.get("stream").and_then(|v| v.as_bool()).unwrap_or(false); + let incremental = wanted_stream + && crate::protocol::can_transcode_stream(provider_wire, client_wire); + let client_model = routing.client_facing_model.clone().unwrap_or_default(); + let outgoing = routing.outgoing_model.clone().unwrap_or_default(); + let request_for_translation = prepared_responses_request + .clone() + .unwrap_or_else(|| p.clone()); + let decoded = if client_wire == crate::protocol::Wire::OpenAiResponses { + crate::protocol::openai_responses::decode_request_with_context( + &request_for_translation, + ) + } else { + crate::protocol::decode_client_request(client_wire, &request_for_translation).map( + |request| { + ( + request, + crate::protocol::openai_responses::CodexToolContext::default(), + ) + }, + ) + }; + let (mut ir, tool_context) = match decoded { + Ok(decoded) => decoded, + Err(e) => { + st.log( + "warn", + format!("client protocol decode ({:?}) failed: {}", client_wire, e), + ); + return Some(error_response( + StatusCode::BAD_REQUEST, + &format!("CC Buddy invalid client request: {}", e), + "invalid_request_error", + )); + } + }; + // Neither the Anthropic nor the Responses client wire round-trips Gemini's thought + // signature, so every translated client (Claude Code AND Codex) needs the session-cache + // restore + documented fallback sentinel — Gemini 3 rejects current-turn function calls + // without a signature (400). + if is_gemini_upstream { + st.thought_signatures.lock().await.restore( + &routing.provider_id, + request_session.as_deref(), + &mut ir, + ); + } + // Repair after signature restoration so any call whose provider-visible payload changes + // cannot accidentally regain a cached signature that authenticated different bytes. + if provider_wire == crate::protocol::Wire::OpenAiChat { + sanitize_provider_history_tool_arguments(&mut ir); + } + if is_gemini_upstream { + apply_gemini_signature_fallback(&mut ir); + } + let translated_body = crate::protocol::encode_upstream_request(provider_wire, &ir, &outgoing, incremental); + match translated_body { + Ok(mut body) => { + if client_wire == crate::protocol::Wire::OpenAiResponses + && provider_wire == crate::protocol::Wire::OpenAiChat + { + apply_responses_chat_request_controls(&mut body, &request_for_translation); + } + // Ask OpenAI-family upstreams to include usage in the final stream chunk. + if incremental && provider_wire == crate::protocol::Wire::OpenAiChat { + body["stream_options"] = json!({ "include_usage": true }); + } + if let Ok(b) = serde_json::to_vec(&body) { + *out_body = Bytes::from(b); + } + // Send to the provider protocol's endpoint (drop the inbound path/query). + *target = provider_wire.upstream_url(base_url); + *v1_fallback_target = provider_wire.v1_fallback_url(base_url); + *translate = Some((client_wire, provider_wire, client_model, wanted_stream, incremental, + tool_context, request_for_translation, codex_history_scope.to_string())); + } + Err(e) => { + st.log("error", format!("protocol translate ({:?}→{:?}) failed: {}", client_wire, provider_wire, e)); + return Some(error_response(StatusCode::BAD_GATEWAY, &format!("CC Buddy protocol translation failed: {}", e), "api_error")); + } + } + None +} diff --git a/src-tauri/src/gateway/redact.rs b/src-tauri/src/gateway/redact.rs new file mode 100644 index 0000000..6abf855 --- /dev/null +++ b/src-tauri/src/gateway/redact.rs @@ -0,0 +1,43 @@ +use axum::http::HeaderMap; +use serde_json::{json, Value}; + +fn redact_value(key: &str, val: &str) -> String { + let k = key.to_ascii_lowercase(); + if matches!(k.as_str(), "authorization" | "x-api-key" | "cookie" | "set-cookie" | "proxy-authorization" | "x-goog-api-key") { + "••••••(已隐藏)".to_string() + } else { + val.to_string() + } +} +pub(super) fn redact_headers(h: &HeaderMap) -> Value { + let mut o = serde_json::Map::new(); + for (k, v) in h.iter() { + o.insert(k.as_str().to_string(), Value::String(redact_value(k.as_str(), v.to_str().unwrap_or("")))); + } + Value::Object(o) +} +pub(super) fn vec_headers(pairs: &[(String, String)]) -> Value { + let mut o = serde_json::Map::new(); + for (k, v) in pairs { + o.insert(k.clone(), Value::String(redact_value(k, v))); + } + Value::Object(o) +} +pub(super) fn cap_text(bytes: &[u8], cap: usize) -> Value { + let total = bytes.len(); + let end = total.min(cap); + json!({ "text": String::from_utf8_lossy(&bytes[..end]), "bytes": total, "truncated": total.saturating_sub(cap) }) +} + +pub(super) fn now_ms() -> i64 { + std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_millis() as i64).unwrap_or(0) +} + +pub(super) const HOP_BY_HOP_REQ: &[&str] = &[ + "host", "content-length", "authorization", "x-api-key", "accept-encoding", "cookie", + "proxy-authorization", "connection", "proxy-connection", "transfer-encoding", +]; +pub(super) const HOP_BY_HOP_RES: &[&str] = &[ + "content-length", "transfer-encoding", "content-encoding", "connection", "keep-alive", + "proxy-authenticate", "proxy-connection", "set-cookie", +]; diff --git a/src-tauri/src/gateway/responses_history.rs b/src-tauri/src/gateway/responses_history.rs new file mode 100644 index 0000000..0a03977 --- /dev/null +++ b/src-tauri/src/gateway/responses_history.rs @@ -0,0 +1,112 @@ +use bytes::Bytes; +use serde_json::{json, Value}; + +use crate::protocol::codex_history::{HistoryResolution, ResponseOrigin}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum ResponsesForwardMode { + Original, + Materialized, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) struct ResponsesHistoryDecision { + pub(super) forward: ResponsesForwardMode, + pub(super) descendant_materializable: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum ResponsesHistoryError { + Unavailable, +} + +#[derive(Clone)] +pub(super) struct NativeResponsesHistoryContext { + pub(super) scope: String, + pub(super) request: Value, + pub(super) provider_id: String, + pub(super) materializable: bool, +} + +pub(super) fn decide_responses_history( + provider_wire: crate::protocol::Wire, + provider_id: &str, + resolution: &HistoryResolution, +) -> Result { + if !resolution.had_previous_response_id { + return Ok(ResponsesHistoryDecision { + forward: if resolution.changed > 0 { + ResponsesForwardMode::Materialized + } else { + ResponsesForwardMode::Original + }, + descendant_materializable: true, + }); + } + + if provider_wire != crate::protocol::Wire::OpenAiResponses { + return (resolution.previous_found && resolution.previous_materialized) + .then_some(ResponsesHistoryDecision { + forward: ResponsesForwardMode::Materialized, + descendant_materializable: true, + }) + .ok_or(ResponsesHistoryError::Unavailable); + } + + if !resolution.previous_found { + // Restart compatibility: the selected native provider may still own this id even though + // the gateway cache does not. Keep the id intact, but do not make descendants portable. + return Ok(ResponsesHistoryDecision { + forward: ResponsesForwardMode::Original, + descendant_materializable: false, + }); + } + + let same_native_owner = matches!( + resolution.previous_origin.as_ref(), + Some(ResponseOrigin::Native(owner)) if owner == provider_id + ); + if same_native_owner { + return Ok(ResponsesHistoryDecision { + forward: ResponsesForwardMode::Original, + descendant_materializable: resolution.previous_materialized, + }); + } + + resolution + .previous_materialized + .then_some(ResponsesHistoryDecision { + forward: ResponsesForwardMode::Materialized, + descendant_materializable: true, + }) + .ok_or(ResponsesHistoryError::Unavailable) +} + +pub(super) fn decide_responses_compact_history( + provider_id: &str, + resolution: &HistoryResolution, +) -> Result { + decide_responses_history( + crate::protocol::Wire::OpenAiResponses, + provider_id, + resolution, + ) + .map(|decision| decision.forward) +} + +pub(super) fn request_body_with_model(request: &Value, outgoing_model: Option<&str>) -> Option { + let mut request = request.clone(); + if let (Some(object), Some(model)) = (request.as_object_mut(), outgoing_model) { + object.insert("model".to_string(), Value::String(model.to_string())); + } + serde_json::to_vec(&request).ok().map(Bytes::from) +} + +pub(super) fn apply_responses_chat_request_controls(body: &mut Value, request: &Value) { + if let Some(parallel_tool_calls) = request + .get("parallel_tool_calls") + .and_then(Value::as_bool) + { + body["parallel_tool_calls"] = json!(parallel_tool_calls); + } +} diff --git a/src-tauri/src/gateway/retry.rs b/src-tauri/src/gateway/retry.rs new file mode 100644 index 0000000..1f70cb6 --- /dev/null +++ b/src-tauri/src/gateway/retry.rs @@ -0,0 +1,122 @@ +use axum::{ + body::Body, + http::{HeaderMap, Method, StatusCode}, + response::Response, +}; +use bytes::Bytes; +use serde_json::{json, Value}; +use std::sync::Arc; + +use super::models::synthesize_models; +use super::routing::Routing; +use super::state::GatewayState; +use super::targets::{error_response, json_response, retry_delay}; + +#[allow(clippy::too_many_arguments)] +pub(super) async fn forward_with_retry( + st: &Arc, + client: &reqwest::Client, + method: &Method, + mut target: String, + mut v1_fallback_target: Option, + up_headers: &HeaderMap, + out_body: &Bytes, + original_target: &str, + mut ex_url: String, + retry_enabled: bool, + retry_max: i64, + retry_base: i64, + provider_name: &str, + is_models_list: bool, + is_count_tokens: bool, + config: &Value, + client_codex: bool, + parsed: &Option, + ex_id: u64, + started: std::time::Instant, + req_path: &str, + routing: &Routing, +) -> Result<(reqwest::Response, bool, String), Response> { + // Forward with the existing 429 retry plus one compatibility attempt at `/v1`. The first + // response is retained until the fallback succeeds, so a failed fallback never masks the + // upstream's original error. + let mut attempt = 0i64; + let mut tried_v1_fallback = false; + let mut used_v1_fallback = false; + let mut first_path_error: Option = None; + let resp = loop { + let r = client + .request(method.clone(), &target) + .headers(up_headers.clone()) + .body(out_body.clone()) + .send() + .await; + match r { + Ok(resp) => { + if !tried_v1_fallback + && retry_enabled + && resp.status().as_u16() == 429 + && attempt < retry_max + { + let ra = resp.headers().get("retry-after").and_then(|v| v.to_str().ok()).map(|s| s.to_string()); + let delay = retry_delay(ra.as_deref(), attempt, retry_base); + st.log("warn", format!("upstream 429 — retry {}/{} in {}ms ({})", attempt + 1, retry_max, delay, provider_name)); + tokio::time::sleep(std::time::Duration::from_millis(delay)).await; + attempt += 1; + continue; + } + if !tried_v1_fallback + && crate::protocol::should_try_v1_fallback(resp.status().as_u16()) + { + if let Some(fallback) = v1_fallback_target.take() { + first_path_error = Some(resp); + target = fallback; + ex_url = target.clone(); + tried_v1_fallback = true; + attempt = 0; + continue; + } + } + if tried_v1_fallback { + if resp.status().is_success() { + used_v1_fallback = true; + ex_url = target.clone(); + break resp; + } + ex_url = original_target.to_string(); + break first_path_error.take().expect("v1 fallback keeps the first response"); + } + break resp; + } + Err(e) => { + if tried_v1_fallback { + if let Some(first) = first_path_error.take() { + ex_url = original_target.to_string(); + st.log("info", format!("/v1 compatibility retry failed: {} ({})", e, provider_name)); + break first; + } + } + if is_models_list { + st.emit_request(ex_id, started, &method, &req_path, &provider_name, &routing, 200, None); + return Err(json_response(StatusCode::OK, &synthesize_models(&config, client_codex))); + } + if is_count_tokens { + let est = crate::counttokens::estimate_input_tokens(parsed.as_ref().unwrap_or(&Value::Null)); + st.emit_request(ex_id, started, &method, &req_path, &provider_name, &routing, 200, None); + return Err(Response::builder() + .status(200) + .header("content-type", "application/json") + .header("x-ccbud-tokens", "estimated") + .header("x-ccbud-upstream-status", "error") + .body(Body::from(serde_json::to_vec(&json!({ "input_tokens": est })).unwrap_or_default())) + .unwrap()); + } + st.log("error", format!("upstream error: {}", e)); + st.emit_request(ex_id, started, &method, &req_path, &provider_name, &routing, 502, None); + return Err(error_response(StatusCode::BAD_GATEWAY, &format!("CC Buddy upstream error: {}", e), "api_error")); + } + } + }; + + Ok((resp, used_v1_fallback, ex_url)) +} diff --git a/src-tauri/src/gateway/routing.rs b/src-tauri/src/gateway/routing.rs new file mode 100644 index 0000000..ab326e8 --- /dev/null +++ b/src-tauri/src/gateway/routing.rs @@ -0,0 +1,178 @@ +use axum::http::HeaderMap; +use serde_json::Value; +use std::collections::HashSet; + +/// Default Claude tier models ccbud advertises to Claude-family clients (Claude Code). +pub const CLAUDE_TIER_MODELS: &[&str] = &[ + "claude-fable-5", + "claude-opus-4-8", + "claude-sonnet-5", + "claude-haiku-4-5", + "claude-haiku-4-5-20251001", +]; + +/// Stable Codex model identities advertised by the gateway. These names are understood by the +/// current Codex CLI and keep its ordinary function/custom tool registry enabled; synthetic +/// `gpt-5.6-sol*` identities select Codex's code-mode metadata and produce an empty Responses +/// `tools` array against a generic custom provider. +pub const CODEX_TIER_MODELS: &[&str] = &["gpt-5.4", "gpt-5.4-mini"]; + +/// Which coding-agent family a model name belongs to. Claude Code sends `claude-*`, +/// Codex sends `gpt-*`; each names its primary vs fast tier differently. +enum ModelFamily { + Claude, + Codex, + Other, +} +fn model_family(name: &str) -> ModelFamily { + let n = name.to_ascii_lowercase(); + if n.starts_with("claude-") || n.starts_with("claude_") { + ModelFamily::Claude + } else if n.starts_with("gpt-") || n.starts_with("gpt_") { + ModelFamily::Codex + } else { + ModelFamily::Other + } +} +/// Claude fast/light tier = the haiku models; fable/opus/sonnet (and any other +/// claude-*) route to the primary model. +fn is_claude_fast(name: &str) -> bool { + name.to_ascii_lowercase().contains("haiku") +} +/// The stable auto-connect identity and legacy `sol` / `terra` aliases route to primary. Explicit +/// small-model identities route to fast; other foreign `gpt-*` names retain the historical fast +/// fallback instead of unexpectedly consuming the primary provider model. +fn is_codex_primary(name: &str) -> bool { + let lower = name.to_ascii_lowercase(); + if lower == "gpt-5.4" { + return true; + } + let segments = lower + .split(|c| c == '-' || c == '_') + .collect::>(); + !segments + .iter() + .any(|seg| matches!(*seg, "mini" | "nano" | "luna" | "spark")) + && segments.iter().any(|seg| matches!(*seg, "sol" | "terra")) +} +/// True if the request comes from a Codex/OpenAI-family client (vs Claude), detected by +/// the client's self-reported identity — User-Agent, or Codex's `originator` header. +pub(super) fn client_is_codex(h: &HeaderMap) -> bool { + let field = |k: &str| h.get(k).and_then(|v| v.to_str().ok()).unwrap_or("").to_ascii_lowercase(); + field("user-agent").contains("codex") || field("originator").contains("codex") +} + +#[derive(Debug, Clone)] +pub struct Routing { + pub provider_id: String, + pub outgoing_model: Option, + pub client_facing_model: Option, +} + +/// Decide how to route a request and translate its model name. Mirrors proxy.js `resolveRouting`. +pub fn resolve_routing( + requested_model: Option<&str>, + config: &Value, + known_models: Option<&HashSet>, +) -> Option { + let providers = config.get("providers")?.as_array()?; + if providers.is_empty() { + return None; + } + let active_id = config.get("activeProviderId").and_then(|v| v.as_str()); + let active = providers + .iter() + .find(|p| p.get("id").and_then(|v| v.as_str()) == active_id) + .or_else(|| providers.first())?; + let pid = active.get("id").and_then(|v| v.as_str()).unwrap_or("").to_string(); + + let pass = |m: &str| { + Some(Routing { + provider_id: pid.clone(), + outgoing_model: Some(m.to_string()), + client_facing_model: Some(m.to_string()), + }) + }; + + let requested = match requested_model { + None => { + return Some(Routing { + provider_id: pid.clone(), + outgoing_model: None, + client_facing_model: None, + }) + } + Some(m) => m, + }; + + let primary = active.get("defaultModel").and_then(|v| v.as_str()).unwrap_or(""); + let light = active.get("smallFastModel").and_then(|v| v.as_str()).unwrap_or(""); + let models = active.get("models").and_then(|v| v.as_array()); + + if let Some(ms) = models { + for m in ms { + let alias = m.get("alias").and_then(|v| v.as_str()).unwrap_or(""); + let upstream = m.get("upstream").and_then(|v| v.as_str()).unwrap_or(""); + if !alias.is_empty() && alias == requested && !upstream.is_empty() { + return Some(Routing { + provider_id: pid.clone(), + outgoing_model: Some(upstream.to_string()), + client_facing_model: Some(requested.to_string()), + }); + } + } + } + if requested == primary || requested == light { + return pass(requested); + } + if let Some(ms) = models { + for m in ms { + if m.get("upstream").and_then(|v| v.as_str()) == Some(requested) { + return pass(requested); + } + } + } + if let Some(known) = known_models { + if known.contains(requested) { + return pass(requested); + } + } + // Codex connects with the sentinel model "gpt-5.5-ccbud" — a name Codex's model-family + // detection accepts (gpt-5.5 prefix), so it doesn't warn about an unknown model. Route the + // sentinel to the active provider's PRIMARY model (never the lightweight fallback). + if requested.ends_with("-ccbud") { + let target = if !primary.is_empty() { primary } else { light }; + if !target.is_empty() { + return Some(Routing { + provider_id: pid.clone(), + outgoing_model: Some(target.to_string()), + client_facing_model: Some(requested.to_string()), + }); + } + } + let map_default = active + .get("mapDefaultModels") + .map(|v| v.as_bool().unwrap_or(true)) + .unwrap_or(true); + if !map_default { + return pass(requested); + } + let big = if !primary.is_empty() { primary } else { light }; + let small = if !light.is_empty() { light } else { primary }; + // Claude and Codex name their primary vs fast tiers differently, so classify by + // family: claude-haiku* → fast, other claude-* → primary; gpt-*-sol / gpt-*-terra + // → primary, other gpt-* → fast; anything else → fast. + let target = match model_family(requested) { + ModelFamily::Claude => if is_claude_fast(requested) { small } else { big }, + ModelFamily::Codex => if is_codex_primary(requested) { big } else { small }, + ModelFamily::Other => small, + }; + if !target.is_empty() { + return Some(Routing { + provider_id: pid.clone(), + outgoing_model: Some(target.to_string()), + client_facing_model: Some(requested.to_string()), + }); + } + pass(requested) +} diff --git a/src-tauri/src/gateway/selftest.rs b/src-tauri/src/gateway/selftest.rs new file mode 100644 index 0000000..f376705 --- /dev/null +++ b/src-tauri/src/gateway/selftest.rs @@ -0,0 +1,118 @@ +use serde_json::{json, Value}; + +use crate::store; + +use super::mock::start_mock_upstream; +use super::selftest_xlate::{selftest_reverse_and_codex, selftest_translation}; + +/// End-to-end gateway test against the mock upstream: routing + response model rewrite for both +/// buffered JSON and streaming SSE. Mutates CCBUD_HOME config (only called in a throwaway run). +pub async fn gateway_selftest(gport: u16) -> Value { + if gport == 0 { + return json!({ "err": "gateway not running" }); + } + let mock = match start_mock_upstream().await { + Some(p) => p, + None => return json!({ "err": "mock failed to start" }), + }; + let cfg = json!({ "port": gport, "activeProviderId":"mock", "providers":[ + { "id":"mock","name":"Mock","baseUrl":format!("http://127.0.0.1:{}", mock),"authToken":"k","defaultModel":"upstream-model","smallFastModel":"upstream-model","mapDefaultModels":true,"models":[{"alias":"test-alias","upstream":"upstream-model"}] } + ]}); + store::write_config(cfg); + tokio::time::sleep(std::time::Duration::from_millis(80)).await; + + let client = reqwest::Client::new(); + let base = format!("http://127.0.0.1:{}/v1/messages", gport); + + let ns = client + .post(&base) + .json(&json!({ "model":"test-alias","max_tokens":8,"messages":[{"role":"user","content":"hi"}] })) + .send() + .await; + let (ns_status, ns_model) = match ns { + Ok(r) => { + let s = r.status().as_u16(); + let j: Value = r.json().await.unwrap_or_else(|_| json!({})); + (s, j.get("model").and_then(|m| m.as_str()).unwrap_or("").to_string()) + } + Err(e) => (0, format!("ERR:{}", e)), + }; + + let stm = client + .post(&base) + .json(&json!({ "model":"test-alias","stream":true,"max_tokens":8,"messages":[{"role":"user","content":"hi"}] })) + .send() + .await; + let (st_status, st_text) = match stm { + Ok(r) => (r.status().as_u16(), r.text().await.unwrap_or_default()), + Err(e) => (0, format!("ERR:{}", e)), + }; + + // count_tokens — mock 404s, so the gateway must estimate locally + let ct = client + .post(format!("http://127.0.0.1:{}/v1/messages/count_tokens", gport)) + .json(&json!({ "model":"test-alias","messages":[{"role":"user","content":"hello world this is a token counting test"}] })) + .send() + .await; + let (ct_status, ct_tokens, ct_estimated) = match ct { + Ok(r) => { + let s = r.status().as_u16(); + let estimated = r.headers().get("x-ccbud-tokens").and_then(|v| v.to_str().ok()) == Some("estimated"); + let j: Value = r.json().await.unwrap_or_else(|_| json!({})); + (s, j.get("input_tokens").and_then(|v| v.as_i64()).unwrap_or(-1), estimated) + } + Err(_) => (0, -1, false), + }; + let (cfg2, tx_ns_status, tx_ns_anthropic, tx_ns_text, tx_ns_model, tx_st_status, tx_st_text, + rx_status, rx_anthropic, rx_text) = selftest_translation(&client, &base, gport, mock).await; + + let (rev_status, rev_is_chat, rev_text, cdx_status, cdx_is_response, cdx_text, cdx_st_status, + cdx_st_text, cdx_chat_status, cdx_chat_text) = + selftest_reverse_and_codex(&client, gport, mock, &cfg2).await; + + json!({ + "nonStreamStatus": ns_status, + "nonStreamModel": ns_model, + "nonStreamRewritten": ns_model == "test-alias", + "xlateResponsesStatus": rx_status, + "xlateResponsesIsAnthropic": rx_anthropic, + "xlateResponsesText": rx_text, + "revChatStatus": rev_status, + "revChatIsChatCompletion": rev_is_chat, + "revChatText": rev_text, + "streamStatus": st_status, + "streamHasStart": st_text.contains("message_start"), + "streamRewritten": st_text.contains("\"test-alias\"") && !st_text.contains("upstream-model"), + "countTokensStatus": ct_status, + "countTokensEstimated": ct_estimated, + "countTokens": ct_tokens, + // protocol translation (messages→chat) + "xlateNonStreamStatus": tx_ns_status, + "xlateNonStreamIsAnthropic": tx_ns_anthropic, + "xlateNonStreamText": tx_ns_text, + "xlateNonStreamModel": tx_ns_model, + "xlateStreamStatus": tx_st_status, + "xlateStreamHasStart": tx_st_text.contains("message_start"), + "xlateStreamHasStop": tx_st_text.contains("message_stop"), + // incremental transcode: OpenAI chunks → Anthropic text_delta events (text split across + // chunks), a real content_block_delta, and end_turn stop. + "xlateStreamIncremental": tx_st_text.contains("content_block_delta") && tx_st_text.contains("text_delta"), + "xlateStreamText": tx_st_text.contains("from chat"), + "xlateStreamStop": tx_st_text.contains("\"stop_reason\":\"end_turn\""), + // Codex (Responses client): buffered translate + incremental stream transcoders. Codex + // materializes items from response.output_item.done and requires response.completed. + "codexNonStreamStatus": cdx_status, + "codexNonStreamIsResponse": cdx_is_response, + "codexNonStreamText": cdx_text, + "codexAnthropicStreamStatus": cdx_st_status, + "codexAnthropicStreamDelta": cdx_st_text.contains("response.output_text.delta"), + "codexAnthropicStreamItemDone": cdx_st_text.contains("response.output_item.done"), + "codexAnthropicStreamCompleted": cdx_st_text.contains("response.completed"), + "codexAnthropicStreamText": cdx_st_text.contains("hi from anthropic"), + "codexChatStreamStatus": cdx_chat_status, + "codexChatStreamDelta": cdx_chat_text.contains("response.output_text.delta"), + "codexChatStreamItemDone": cdx_chat_text.contains("response.output_item.done"), + "codexChatStreamCompleted": cdx_chat_text.contains("response.completed"), + "codexChatStreamText": cdx_chat_text.contains("from chat"), + }) +} diff --git a/src-tauri/src/gateway/selftest_routing.rs b/src-tauri/src/gateway/selftest_routing.rs new file mode 100644 index 0000000..e9766bb --- /dev/null +++ b/src-tauri/src/gateway/selftest_routing.rs @@ -0,0 +1,57 @@ +use serde_json::{json, Value}; +use std::collections::HashSet; + +use super::routing::{resolve_routing, Routing}; + +/// In-binary equivalent of test/selftest.js's 8 routing unit checks. +pub fn routing_selftest() -> Value { + let config = json!({ "port":0, "activeProviderId":"glm", "providers":[ + { "id":"glm","name":"GLM","baseUrl":"https://x","authToken":"","defaultModel":"glm-5.1","smallFastModel":"glm-5.1","mapDefaultModels":true,"models":[{"alias":"claude-opus-4.8[1m]","upstream":"glm-5.1"}] } + ]}); + let cfg2 = json!({ "port":0, "activeProviderId":"main", "providers":[ + { "id":"main","name":"Main","baseUrl":"http://127.0.0.1:1","authToken":"k","defaultModel":"big-model","smallFastModel":"small-model","mapDefaultModels":true,"models":[{"alias":"my-alias","upstream":"aliased-up"}] }, + { "id":"other","name":"Other","baseUrl":"http://127.0.0.1:2","authToken":"k","defaultModel":"other-big","smallFastModel":"other-small","mapDefaultModels":true,"models":[{"alias":"other-alias","upstream":"other-up"}] } + ]}); + let off = json!({ "port":0, "activeProviderId":"m", "providers":[ + { "id":"m","name":"M","baseUrl":"http://127.0.0.1:1","authToken":"k","defaultModel":"big","smallFastModel":"small","mapDefaultModels":false,"models":[] } + ]}); + + let out = |r: &Option| r.as_ref().and_then(|x| x.outgoing_model.clone()); + let cf = |r: &Option| r.as_ref().and_then(|x| x.client_facing_model.clone()); + let pidf = |r: &Option| r.as_ref().map(|x| x.provider_id.clone()); + + let mut fails: Vec = vec![]; + let mut n = 0; + let mut chk = |name: &str, cond: bool| { + n += 1; + if !cond { + fails.push(name.to_string()); + } + }; + + let r = resolve_routing(Some("claude-opus-4.8[1m]"), &config, None); + chk("1 alias→upstream", out(&r).as_deref() == Some("glm-5.1") && cf(&r).as_deref() == Some("claude-opus-4.8[1m]")); + let r = resolve_routing(Some("glm-5.1"), &config, None); + chk("2 real passthrough", out(&r).as_deref() == Some("glm-5.1") && cf(&r).as_deref() == Some("glm-5.1")); + let r = resolve_routing(Some("claude-3-5-haiku-20241022"), &cfg2, None); + chk("3 haiku→light", out(&r).as_deref() == Some("small-model")); + let r = resolve_routing(Some("claude-sonnet-4-6"), &cfg2, None); + chk("4 sonnet→primary", out(&r).as_deref() == Some("big-model")); + let r = resolve_routing(Some("gpt-4-turbo"), &cfg2, None); + chk("5 foreign→light", out(&r).as_deref() == Some("small-model")); + let mut known = HashSet::new(); + known.insert("glm-5.2".to_string()); + let r = resolve_routing(Some("glm-5.2"), &cfg2, Some(&known)); + chk("6 known passthrough", out(&r).as_deref() == Some("glm-5.2")); + let r = resolve_routing(Some("other-alias"), &cfg2, None); + chk("7 stays on active", pidf(&r).as_deref() == Some("main") && out(&r).as_deref() == Some("small-model")); + let r = resolve_routing(Some("whatever-x"), &off, None); + chk("8 mapoff passthrough", out(&r).as_deref() == Some("whatever-x")); + let r = resolve_routing(Some("gpt-5.5-ccbud"), &cfg2, None); + chk( + "9 codex sentinel→primary", + out(&r).as_deref() == Some("big-model") && cf(&r).as_deref() == Some("gpt-5.5-ccbud"), + ); + + json!({ "total": n, "passed": n - fails.len(), "failed": fails.len(), "fails": fails }) +} diff --git a/src-tauri/src/gateway/selftest_xlate.rs b/src-tauri/src/gateway/selftest_xlate.rs new file mode 100644 index 0000000..8d551ca --- /dev/null +++ b/src-tauri/src/gateway/selftest_xlate.rs @@ -0,0 +1,154 @@ +use serde_json::{json, Value}; + +use crate::store; + +#[allow(clippy::type_complexity)] +pub(super) async fn selftest_translation( + client: &reqwest::Client, + base: &str, + gport: u16, + mock: u16, +) -> (Value, u16, bool, String, String, u16, String, u16, bool, String) { + // ---- protocol translation: Claude Code (Anthropic /v1/messages) → an OpenAI-Chat provider ---- + // Reconfigure the mock provider to speak openai-chat, then hit /v1/messages and prove the + // response comes back Anthropic-shaped (non-stream) and as a valid Anthropic SSE (stream). + let cfg2 = json!({ "port": gport, "activeProviderId":"mockoa", "providers":[ + { "id":"mockoa","name":"MockOpenAI","baseUrl":format!("http://127.0.0.1:{}", mock),"authToken":"k","protocol":"openai-chat","defaultModel":"gpt-mock","smallFastModel":"gpt-mock","mapDefaultModels":true,"models":[{"alias":"test-alias","upstream":"gpt-mock"}] } + ]}); + store::write_config(cfg2.clone()); + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + + let tx_ns = client + .post(base) + .json(&json!({ "model":"test-alias","max_tokens":8,"messages":[{"role":"user","content":"hi"}] })) + .send() + .await; + let (tx_ns_status, tx_ns_anthropic, tx_ns_text, tx_ns_model) = match tx_ns { + Ok(r) => { + let s = r.status().as_u16(); + let j: Value = r.json().await.unwrap_or_else(|_| json!({})); + let is_msg = j.get("type").and_then(|v| v.as_str()) == Some("message"); + let text = j.get("content").and_then(|c| c.as_array()).and_then(|a| a.first()) + .and_then(|b| b.get("text")).and_then(|v| v.as_str()).unwrap_or("").to_string(); + let model = j.get("model").and_then(|v| v.as_str()).unwrap_or("").to_string(); + (s, is_msg, text, model) + } + Err(e) => (0, false, format!("ERR:{}", e), String::new()), + }; + + let tx_st = client + .post(base) + .json(&json!({ "model":"test-alias","stream":true,"max_tokens":8,"messages":[{"role":"user","content":"hi"}] })) + .send() + .await; + let (tx_st_status, tx_st_text) = match tx_st { + Ok(r) => (r.status().as_u16(), r.text().await.unwrap_or_default()), + Err(e) => (0, format!("ERR:{}", e)), + }; + + // ---- protocol translation: Claude Code (Anthropic /v1/messages) → an OpenAI-Responses provider ---- + let cfg3 = json!({ "port": gport, "activeProviderId":"mockre", "providers":[ + { "id":"mockre","name":"MockResponses","baseUrl":format!("http://127.0.0.1:{}", mock),"authToken":"k","protocol":"openai-responses","defaultModel":"gpt-mock","smallFastModel":"gpt-mock","mapDefaultModels":true,"models":[{"alias":"test-alias","upstream":"gpt-mock"}] } + ]}); + store::write_config(cfg3); + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + let rx = client + .post(base) + .json(&json!({ "model":"test-alias","max_tokens":8,"messages":[{"role":"user","content":"hi"}] })) + .send() + .await; + let (rx_status, rx_anthropic, rx_text) = match rx { + Ok(r) => { + let s = r.status().as_u16(); + let j: Value = r.json().await.unwrap_or_else(|_| json!({})); + let is_msg = j.get("type").and_then(|v| v.as_str()) == Some("message"); + let text = j.get("content").and_then(|c| c.as_array()).and_then(|a| a.first()) + .and_then(|b| b.get("text")).and_then(|v| v.as_str()).unwrap_or("").to_string(); + (s, is_msg, text) + } + Err(e) => (0, false, format!("ERR:{}", e)), + }; + (cfg2, tx_ns_status, tx_ns_anthropic, tx_ns_text, tx_ns_model, tx_st_status, tx_st_text, + rx_status, rx_anthropic, rx_text) +} + +#[allow(clippy::type_complexity)] +pub(super) async fn selftest_reverse_and_codex( + client: &reqwest::Client, + gport: u16, + mock: u16, + cfg2: &Value, +) -> (u16, bool, String, u16, bool, String, u16, String, u16, String) { + // ---- reverse: an OpenAI-Chat client (/v1/chat/completions) → an Anthropic provider ---- + let cfg4 = json!({ "port": gport, "activeProviderId":"mockan", "providers":[ + { "id":"mockan","name":"MockAnthropic","baseUrl":format!("http://127.0.0.1:{}", mock),"authToken":"k","protocol":"anthropic","defaultModel":"claude-mock","smallFastModel":"claude-mock","mapDefaultModels":true,"models":[{"alias":"test-alias","upstream":"claude-mock"}] } + ]}); + store::write_config(cfg4); + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + let rev = client + .post(format!("http://127.0.0.1:{}/v1/chat/completions", gport)) + .json(&json!({ "model":"test-alias","messages":[{"role":"user","content":"hi"}] })) + .send() + .await; + let (rev_status, rev_is_chat, rev_text) = match rev { + Ok(r) => { + let s = r.status().as_u16(); + let j: Value = r.json().await.unwrap_or_else(|_| json!({})); + let is_chat = j.get("object").and_then(|v| v.as_str()) == Some("chat.completion"); + let text = j.get("choices").and_then(|c| c.as_array()).and_then(|a| a.first()) + .and_then(|c| c.get("message")).and_then(|m| m.get("content")).and_then(|v| v.as_str()).unwrap_or("").to_string(); + (s, is_chat, text) + } + Err(e) => (0, false, format!("ERR:{}", e)), + }; + + // ---- Codex (OpenAI-Responses client, /v1/responses) → an Anthropic provider ---- + // The shape Codex sends with wire_api="responses": instructions + item-based input + flattened + // function tools. Non-stream proves the buffered translate; stream proves the incremental + // Anthropic→Responses transcoder (item done events + terminal response.completed). + let codex_body = json!({ "model":"test-alias", "instructions":"be nice", + "input":[{ "type":"message","role":"user","content":[{ "type":"input_text","text":"hi" }] }], + "tools":[{ "type":"function","name":"shell","description":"run","parameters":{ "type":"object" } }], + "tool_choice":"auto", "store": false }); + let cdx = client + .post(format!("http://127.0.0.1:{}/v1/responses", gport)) + .json(&codex_body) + .send() + .await; + let (cdx_status, cdx_is_response, cdx_text) = match cdx { + Ok(r) => { + let s = r.status().as_u16(); + let j: Value = r.json().await.unwrap_or_else(|_| json!({})); + let is_resp = j.get("object").and_then(|v| v.as_str()) == Some("response"); + let text = j.get("output_text").and_then(|v| v.as_str()).unwrap_or("").to_string(); + (s, is_resp, text) + } + Err(e) => (0, false, format!("ERR:{}", e)), + }; + let mut codex_stream_body = codex_body.clone(); + codex_stream_body["stream"] = json!(true); + let cdx_st = client + .post(format!("http://127.0.0.1:{}/v1/responses", gport)) + .json(&codex_stream_body) + .send() + .await; + let (cdx_st_status, cdx_st_text) = match cdx_st { + Ok(r) => (r.status().as_u16(), r.text().await.unwrap_or_default()), + Err(e) => (0, format!("ERR:{}", e)), + }; + + // ---- Codex → an OpenAI-Chat provider (incremental chat→Responses transcoding) ---- + store::write_config(cfg2.clone()); + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + let cdx_chat = client + .post(format!("http://127.0.0.1:{}/v1/responses", gport)) + .json(&codex_stream_body) + .send() + .await; + let (cdx_chat_status, cdx_chat_text) = match cdx_chat { + Ok(r) => (r.status().as_u16(), r.text().await.unwrap_or_default()), + Err(e) => (0, format!("ERR:{}", e)), + }; + (rev_status, rev_is_chat, rev_text, cdx_status, cdx_is_response, cdx_text, cdx_st_status, + cdx_st_text, cdx_chat_status, cdx_chat_text) +} diff --git a/src-tauri/src/gateway/session.rs b/src-tauri/src/gateway/session.rs new file mode 100644 index 0000000..8c4ae4a --- /dev/null +++ b/src-tauri/src/gateway/session.rs @@ -0,0 +1,61 @@ +use serde_json::Value; + +pub(super) fn request_session_id(body: &Value) -> Option { + // Claude Code: metadata.user_id is a JSON string carrying session_id. + if let Some(raw) = body.pointer("/metadata/user_id").and_then(Value::as_str) { + if let Ok(metadata) = serde_json::from_str::(raw.trim()) { + if let Some(session) = metadata.get("session_id").and_then(Value::as_str) + .filter(|session| !session.is_empty()) + { + return Some(session.to_string()); + } + } + } + // Codex (Responses client): prompt_cache_key carries the conversation id. + body.get("prompt_cache_key").and_then(Value::as_str) + .map(str::trim) + .filter(|session| !session.is_empty()) + .map(str::to_string) +} + +pub(super) fn codex_history_scope_for_session(request_session: Option<&str>) -> String { + request_session.unwrap_or("").to_string() +} + +pub(super) fn response_tool_calls( + response: &llm_connector::types::ChatResponse, +) -> Vec { + response.choices.first().and_then(|choice| choice.message.tool_calls.as_ref()) + .map(|calls| calls.iter().map(|call| { + crate::protocol::stream::CapturedToolCall { + call_id: call.id.clone(), + name: call.function.name.clone(), + arguments: call.function.arguments.clone(), + thought_signature: crate::protocol::tool_call_thought_signature(call), + } + }).collect()) + .unwrap_or_default() +} + +pub(super) fn response_tool_calls_with_client_ids( + response: &llm_connector::types::ChatResponse, + encoded_response: &Value, +) -> Vec { + let mut captured = response_tool_calls(response); + let client_ids = encoded_response + .get("output") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter(|item| { + matches!( + item.get("type").and_then(Value::as_str), + Some("function_call" | "custom_tool_call" | "tool_search_call") + ) + }) + .filter_map(|item| item.get("call_id").and_then(Value::as_str)); + for (call, client_id) in captured.iter_mut().zip(client_ids) { + call.call_id = client_id.to_string(); + } + captured +} diff --git a/src-tauri/src/gateway/signatures.rs b/src-tauri/src/gateway/signatures.rs new file mode 100644 index 0000000..be51d21 --- /dev/null +++ b/src-tauri/src/gateway/signatures.rs @@ -0,0 +1,145 @@ +use std::collections::HashMap; + +use super::history_args::{canonical_tool_arguments, current_tool_turn_start}; +use super::redact::now_ms; + +// Claude Code rebuilds assistant tool_use history from its known fields and drops provider +// metadata, so Gemini's signature cannot round-trip through the Anthropic wire. Keep a bounded, +// session-scoped server-side copy and restore it before the next Google/OpenAI-compatible request. +pub(super) const THOUGHT_SIGNATURE_TTL_MS: i64 = 6 * 60 * 60 * 1000; +const THOUGHT_SIGNATURE_CACHE_MAX: usize = 2048; +pub(super) const GEMINI_SIGNATURE_FALLBACK: &str = "skip_thought_signature_validator"; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(super) struct CachedToolCall { + call_id: String, + name: String, + arguments: String, + signature: Option, +} + +#[derive(Clone, Debug)] +pub(super) struct ThoughtSignatureBatch { + pub(super) calls: Vec, + pub(super) touched_at: i64, +} + +#[derive(Default)] +pub(super) struct ThoughtSignatureCache { + pub(super) batches: HashMap<(String, String), ThoughtSignatureBatch>, +} + +impl ThoughtSignatureCache { + fn prune(&mut self, now: i64) { + self.batches.retain(|_, batch| { + now.saturating_sub(batch.touched_at) <= THOUGHT_SIGNATURE_TTL_MS + }); + } + + pub(super) fn remember( + &mut self, + provider_id: &str, + session_id: Option<&str>, + captured_calls: &[crate::protocol::stream::CapturedToolCall], + ) { + let now = now_ms(); + self.prune(now); + let Some(session_id) = session_id else { return }; + let calls: Vec = captured_calls.iter().map(|call| CachedToolCall { + call_id: call.call_id.clone(), + name: call.name.clone(), + arguments: canonical_tool_arguments(&call.arguments), + signature: call.thought_signature.as_deref() + .filter(|signature| !signature.is_empty()) + .map(str::to_string), + }).collect(); + let key = (provider_id.to_string(), session_id.to_string()); + if !calls.iter().any(|call| call.signature.is_some()) { + if !calls.is_empty() { + self.batches.remove(&key); + } + return; + } + + if self.batches.len() >= THOUGHT_SIGNATURE_CACHE_MAX && !self.batches.contains_key(&key) { + if let Some(oldest) = self.batches.iter() + .min_by_key(|(_, batch)| batch.touched_at) + .map(|(key, _)| key.clone()) + { + self.batches.remove(&oldest); + } + } + // Replacing the latest batch also makes terminal/EOF observations idempotent. + self.batches.insert(key, ThoughtSignatureBatch { + calls, + touched_at: now, + }); + } + + pub(super) fn restore( + &mut self, + provider_id: &str, + session_id: Option<&str>, + request: &mut llm_connector::types::ChatRequest, + ) -> usize { + let now = now_ms(); + self.prune(now); + let Some(session_id) = session_id else { return 0 }; + let current_turn = current_tool_turn_start(request); + let Some(message_index) = request.messages.iter() + .enumerate() + .skip(current_turn) + .rev() + .find_map(|(message_index, message)| { + message.tool_calls.as_ref() + .filter(|calls| !calls.is_empty()) + .map(|_| message_index) + }) + else { return 0 }; + let Some(calls) = request.messages[message_index].tool_calls.as_mut() else { return 0 }; + let key = (provider_id.to_string(), session_id.to_string()); + let Some(batch) = self.batches.get_mut(&key) else { return 0 }; + if batch.calls.len() != calls.len() + || !batch.calls.iter().zip(calls.iter()).all(|(cached, current)| { + cached.call_id == current.id + && cached.name == current.function.name + && cached.arguments == canonical_tool_arguments(¤t.function.arguments) + }) + { + return 0; + } + batch.touched_at = now; + let mut restored = 0usize; + for (call, cached) in calls.iter_mut().zip(&batch.calls) { + if crate::protocol::tool_call_thought_signature(call).is_none() { + if let Some(signature) = &cached.signature { + call.thought_signature = Some(signature.clone()); + restored += 1; + } + } + } + restored + } +} + +/// Google documents this sentinel for function-call history that did not originate from the +/// current API response (transferred/synthetic history). We use it only when Claude stripped the +/// real signature and the session cache cannot recover it. For parallel calls, only the first call +/// in a model step gets a signature, matching Gemini's validation contract. +pub(super) fn apply_gemini_signature_fallback(request: &mut llm_connector::types::ChatRequest) -> usize { + let mut applied = 0usize; + // Gemini validates only the current turn: everything after the most recent ordinary user + // message. Tool results decode as Role::Tool, so sequential tool steps remain in this slice. + let current_turn = current_tool_turn_start(request); + for message in request.messages.iter_mut().skip(current_turn) { + let Some(calls) = message.tool_calls.as_mut() else { continue }; + if calls.is_empty() + || crate::protocol::tool_call_thought_signature(&calls[0]).is_some() + { + continue; + } + calls[0].thought_signature = Some(GEMINI_SIGNATURE_FALLBACK.to_string()); + applied += 1; + } + applied +} diff --git a/src-tauri/src/gateway/sse.rs b/src-tauri/src/gateway/sse.rs new file mode 100644 index 0000000..7c832f6 --- /dev/null +++ b/src-tauri/src/gateway/sse.rs @@ -0,0 +1,102 @@ +use serde_json::Value; + +use super::monitor::UsageAcc; + +fn model_rewrite_re() -> &'static regex::Regex { + static RE: std::sync::OnceLock = std::sync::OnceLock::new(); + RE.get_or_init(|| regex::Regex::new(r#"("model"\s*:\s*")[^"]*(")"#).unwrap()) +} + +fn absorb_usage_sse(obj: &Value, usage: &mut UsageAcc) { + match obj.get("type").and_then(|v| v.as_str()) { + Some("message_start") => { + if let Some(u) = obj.get("message").and_then(|m| m.get("usage")) { + usage.input += u.get("input_tokens").and_then(|v| v.as_i64()).unwrap_or(0); + usage.cache_read += u.get("cache_read_input_tokens").and_then(|v| v.as_i64()).unwrap_or(0); + usage.cache_creation += u.get("cache_creation_input_tokens").and_then(|v| v.as_i64()).unwrap_or(0); + usage.saw = true; + } + } + Some("message_delta") => { + if let Some(o) = obj.get("usage").and_then(|u| u.get("output_tokens")).and_then(|v| v.as_i64()) { + usage.output = o; + usage.saw = true; + } + } + _ => {} + } +} + +pub(super) fn process_sse_line(line: &str, rewrite_model: Option<&str>, usage: &mut UsageAcc) -> String { + if line.contains("\"usage\"") { + if let Some(i) = line.find('{') { + if let Ok(obj) = serde_json::from_str::(line[i..].trim()) { + absorb_usage_sse(&obj, usage); + } + } + } + if let Some(m) = rewrite_model { + if line.contains("\"model\"") { + return model_rewrite_re() + .replace_all(line, |caps: ®ex::Captures| format!("{}{}{}", &caps[1], m, &caps[2])) + .into_owned(); + } + } + line.to_string() +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum ResponsesTerminalKind { + Completed, + Incomplete, + Failed, +} + +impl ResponsesTerminalKind { + pub(super) fn is_resumable(self) -> bool { + matches!(self, Self::Completed | Self::Incomplete) + } +} + +#[derive(Debug, Clone)] +pub(super) struct ResponsesTerminal { + pub(super) kind: ResponsesTerminalKind, + pub(super) response: Option, +} + +pub(super) fn responses_terminal_event(sse: &str) -> Option { + sse.lines().rev().find_map(|line| { + let payload = line.trim().strip_prefix("data:")?.trim(); + let event: Value = serde_json::from_str(payload).ok()?; + let kind = match event.get("type").and_then(Value::as_str)? { + "response.completed" => ResponsesTerminalKind::Completed, + "response.incomplete" => ResponsesTerminalKind::Incomplete, + "response.failed" => ResponsesTerminalKind::Failed, + _ => return None, + }; + Some(ResponsesTerminal { + kind, + response: event.get("response").cloned(), + }) + }) +} + +pub(super) fn responses_terminal_object(response: &Value) -> Option { + let kind = match response.get("status").and_then(Value::as_str)? { + "completed" => ResponsesTerminalKind::Completed, + "incomplete" => ResponsesTerminalKind::Incomplete, + "failed" => ResponsesTerminalKind::Failed, + _ => return None, + }; + Some(ResponsesTerminal { + kind, + response: Some(response.clone()), + }) +} + +pub(super) fn is_responses_compact_path(path: &str) -> bool { + matches!( + path.trim_end_matches('/'), + "/responses/compact" | "/v1/responses/compact" + ) +} diff --git a/src-tauri/src/gateway/state.rs b/src-tauri/src/gateway/state.rs new file mode 100644 index 0000000..3786483 --- /dev/null +++ b/src-tauri/src/gateway/state.rs @@ -0,0 +1,148 @@ +use axum::Router; +use serde_json::{json, Value}; +use std::collections::{HashMap, HashSet, VecDeque}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Arc; +use tauri::Emitter; +use tokio::sync::{oneshot, Mutex}; + +use super::handler::handle; +use super::signatures::ThoughtSignatureCache; + +// ---------------- gateway runtime ---------------- + +pub struct GatewayState { + pub(super) app: tauri::AppHandle, + pub(super) known: Mutex>>, + pub(super) thought_signatures: Mutex, + pub(super) codex_history: crate::protocol::codex_history::CodexHistoryStore, + pub(super) seq: AtomicU64, + running: Mutex>, + // Sync mirror of the bound port (0 = stopped) for callers that can't await (tray refresh). + running_port: std::sync::atomic::AtomicU32, + pub(super) exchanges: Mutex>, + pub(super) client: reqwest::Client, + pub(super) client_insecure: reqwest::Client, + // Ring buffer of recent gateway log lines (seq+ts stamped) so the settings Logs panel can + // backfill on open — mirrors main.js gatewayLogs (cap 80). std Mutex: log() is sync. + logs: std::sync::Mutex>, + log_seq: AtomicU64, +} +struct RunningServer { + port: u16, + shutdown: oneshot::Sender<()>, +} + +impl GatewayState { + pub fn new(app: tauri::AppHandle) -> Arc { + let client = reqwest::Client::builder() + .build() + .unwrap_or_else(|_| reqwest::Client::new()); + let client_insecure = reqwest::Client::builder() + .danger_accept_invalid_certs(true) + .build() + .unwrap_or_else(|_| reqwest::Client::new()); + Arc::new(Self { + app, + known: Mutex::new(HashMap::new()), + thought_signatures: Mutex::new(ThoughtSignatureCache::default()), + codex_history: crate::protocol::codex_history::CodexHistoryStore::default(), + seq: AtomicU64::new(0), + running: Mutex::new(None), + running_port: std::sync::atomic::AtomicU32::new(0), + exchanges: Mutex::new(VecDeque::new()), + client, + client_insecure, + logs: std::sync::Mutex::new(VecDeque::new()), + log_seq: AtomicU64::new(0), + }) + } + + pub fn log(&self, level: &str, msg: impl AsRef) { + let seq = self.log_seq.fetch_add(1, Ordering::Relaxed) + 1; + let ts = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(0); + let entry = json!({ "seq": seq, "ts": ts, "level": level, "msg": msg.as_ref() }); + if let Ok(mut buf) = self.logs.lock() { + buf.push_back(entry.clone()); + while buf.len() > 80 { + buf.pop_front(); + } + } + let _ = self.app.emit("gateway:log", entry); + } + + /// Snapshot of the recent-log ring, oldest→newest (logs_get backfill). + pub fn logs_snapshot(&self) -> Value { + self.logs + .lock() + .map(|b| Value::Array(b.iter().cloned().collect())) + .unwrap_or_else(|_| json!([])) + } + pub fn logs_clear(&self) { + if let Ok(mut b) = self.logs.lock() { + b.clear(); + } + } + + pub async fn status(&self) -> Value { + match self.running.lock().await.as_ref() { + Some(rs) => json!({ "running": true, "port": rs.port }), + None => json!({ "running": false, "port": Value::Null }), + } + } + + pub async fn current_port(&self) -> Option { + self.running.lock().await.as_ref().map(|r| r.port) + } + + /// Sync view of the running state (tray menu refresh runs on the main thread, no await). + pub fn port_sync(&self) -> Option { + match self.running_port.load(Ordering::Relaxed) { + 0 => None, + p => Some(p as u16), + } + } + + pub fn emit(&self, event: &str, payload: Value) { + let _ = self.app.emit(event, payload); + } + + pub async fn start(self: &Arc, port: u16) -> Result { + if let Some(rs) = self.running.lock().await.as_ref() { + return Ok(rs.port); + } + let listener = tokio::net::TcpListener::bind(("127.0.0.1", port)) + .await + .map_err(|e| e.to_string())?; + let actual = listener.local_addr().map_err(|e| e.to_string())?.port(); + let (tx, rx) = oneshot::channel::<()>(); + let router = Router::new().fallback(handle).with_state(self.clone()); + tauri::async_runtime::spawn(async move { + let _ = axum::serve(listener, router) + .with_graceful_shutdown(async move { + let _ = rx.await; + }) + .await; + }); + *self.running.lock().await = Some(RunningServer { port: actual, shutdown: tx }); + self.running_port.store(actual as u32, Ordering::Relaxed); + self.log("info", format!("gateway listening on http://127.0.0.1:{}", actual)); + let status = self.status().await; + let _ = self.app.emit("gateway:status", status); + Ok(actual) + } + + pub async fn stop(self: &Arc) { + self.running_port.store(0, Ordering::Relaxed); + let taken = self.running.lock().await.take(); + if let Some(rs) = taken { + let _ = rs.shutdown.send(()); + self.log("info", "gateway stopped"); + } + let status = self.status().await; + let _ = self.app.emit("gateway:status", status); + } +} diff --git a/src-tauri/src/gateway/stream_passthrough.rs b/src-tauri/src/gateway/stream_passthrough.rs new file mode 100644 index 0000000..e6933c6 --- /dev/null +++ b/src-tauri/src/gateway/stream_passthrough.rs @@ -0,0 +1,166 @@ +use axum::{ + body::Body, + http::{Method, StatusCode}, + response::Response, +}; +use bytes::Bytes; +use futures_util::StreamExt; +use serde_json::{json, Value}; +use std::sync::Arc; + +use crate::protocol::codex_history::ResponseOrigin; + +use super::capture::StreamAbortGuard; +use super::monitor::UsageAcc; +use super::redact::{now_ms, vec_headers}; +use super::responses_history::NativeResponsesHistoryContext; +use super::routing::Routing; +use super::sse::{process_sse_line, responses_terminal_event}; +use super::state::GatewayState; + +#[allow(clippy::too_many_arguments)] +pub(super) fn stream_passthrough( + st: &Arc, + resp: reqwest::Response, + need_rewrite: bool, + routing: Routing, + method: Method, + req_path: String, + provider_name: String, + status: StatusCode, + ex_id: u64, + started: std::time::Instant, + out_headers: Vec<(String, String)>, + ex_req_headers: Value, + ex_req_body: Value, + ex_client_req: Value, + ex_translated: Option, + ex_url: String, + native_responses_history: Option, + client_wire: crate::protocol::Wire, +) -> Response { + let rewrite_model = if need_rewrite { routing.client_facing_model.clone() } else { None }; + let st2 = st.clone(); + let status_code = status.as_u16(); + let ex_id2 = ex_id; + let started2 = started; + let res_headers = vec_headers(&out_headers); + let mut guard = StreamAbortGuard::new( + st.clone(), ex_id, started, method.clone(), req_path.clone(), provider_name.clone(), + routing.clone(), status_code, + json!({ + "id": ex_id, "ts": now_ms(), "method": method.as_str(), "path": req_path, "url": ex_url, + "provider": provider_name, "requestedModel": routing.client_facing_model, + "outgoingModel": routing.outgoing_model, "clientFacingModel": routing.client_facing_model, + "status": status_code, "reqHeaders": ex_req_headers, "reqBody": ex_req_body, + "clientReq": ex_client_req, "translated": ex_translated, + "resHeaders": res_headers, + "resBody": json!({ "text": "", "bytes": 0, "truncated": 0 }), + }), + None, + ); + let method2 = method.clone(); + let path2 = req_path.clone(); + let pname2 = provider_name.clone(); + let routing2 = routing.clone(); + let native_history = native_responses_history.clone(); + let native_responses_stream = client_wire == crate::protocol::Wire::OpenAiResponses; + let body_stream = async_stream::stream! { + let mut s = resp.bytes_stream(); + let mut buf = String::new(); + let mut usage = UsageAcc::default(); + let mut history_recorded = false; + while let Some(chunk) = s.next().await { + match chunk { + Ok(bytes) => { + buf.push_str(&String::from_utf8_lossy(&bytes)); + let mut out = String::new(); + while let Some(idx) = buf.find('\n') { + let line: String = buf.drain(..=idx).collect(); + out.push_str(&process_sse_line(&line, rewrite_model.as_deref(), &mut usage)); + } + let terminal = native_responses_stream + .then(|| responses_terminal_event(&out)) + .flatten(); + guard.push_res(&out); + if let Some(terminal) = terminal { + guard.finished = true; + if !history_recorded + && (200..300).contains(&status_code) + && terminal.kind.is_resumable() + { + if let (Some(history), Some(response)) = + (native_history.as_ref(), terminal.response.as_ref()) + { + st2.codex_history + .record_response_scoped_with_metadata( + &history.scope, + ResponseOrigin::Native(history.provider_id.clone()), + history.materializable, + &history.request, + response, + ) + .await; + } + } + history_recorded = true; + } + guard.usage = Some(usage.clone()); + if !out.is_empty() { + yield Ok::(Bytes::from(out)); + } + } + Err(error) => { + let message = format!("upstream stream transport error: {}", error); + st2.log("error", format!("{} ({})", message, pname2)); + yield Err(std::io::Error::new(std::io::ErrorKind::Other, message)); + return; + } + } + } + if !buf.is_empty() { + let line = process_sse_line(&buf, rewrite_model.as_deref(), &mut usage); + let terminal = native_responses_stream + .then(|| responses_terminal_event(&line)) + .flatten(); + guard.push_res(&line); + if let Some(terminal) = terminal { + guard.finished = true; + if !history_recorded + && (200..300).contains(&status_code) + && terminal.kind.is_resumable() + { + if let (Some(history), Some(response)) = + (native_history.as_ref(), terminal.response.as_ref()) + { + st2.codex_history + .record_response_scoped_with_metadata( + &history.scope, + ResponseOrigin::Native(history.provider_id.clone()), + history.materializable, + &history.request, + response, + ) + .await; + } + } + } + yield Ok(Bytes::from(line)); + } + if native_responses_stream && !guard.finished { + let message = "upstream Responses stream ended before a terminal event"; + st2.log("error", format!("{} ({})", message, pname2)); + yield Err(std::io::Error::new(std::io::ErrorKind::UnexpectedEof, message)); + return; + } + st2.emit_request(ex_id2, started2, &method2, &path2, &pname2, &routing2, status_code, Some(&usage)); + let mut ex = guard.complete(); + ex["ms"] = json!(started2.elapsed().as_millis() as u64); + st2.record_exchange(ex).await; + }; + let mut builder = Response::builder().status(status.as_u16()); + for (k, v) in &out_headers { + builder = builder.header(k, v); + } + return builder.body(Body::from_stream(body_stream)).unwrap(); +} diff --git a/src-tauri/src/gateway/stream_transcode.rs b/src-tauri/src/gateway/stream_transcode.rs new file mode 100644 index 0000000..0ae964d --- /dev/null +++ b/src-tauri/src/gateway/stream_transcode.rs @@ -0,0 +1,206 @@ +use axum::{ + body::Body, + http::{Method, StatusCode}, + response::Response, +}; +use bytes::Bytes; +use futures_util::StreamExt; +use serde_json::{json, Value}; +use std::sync::Arc; + +use crate::protocol::codex_history::ResponseOrigin; + +use super::capture::StreamAbortGuard; +use super::monitor::UsageAcc; +use super::redact::{now_ms, vec_headers}; +use super::routing::Routing; +use super::sse::responses_terminal_event; +use super::state::GatewayState; + +#[allow(clippy::too_many_arguments)] +pub(super) fn stream_transcoded( + st: &Arc, + resp: reqwest::Response, + mut tc: crate::protocol::stream::Transcoder, + client_wire: crate::protocol::Wire, + provider_wire: crate::protocol::Wire, + history_request: Value, + history_scope: String, + routing: Routing, + request_session: Option, + is_gemini_upstream: bool, + status: StatusCode, + ex_id: u64, + started: std::time::Instant, + method: Method, + req_path: String, + provider_name: String, + out_headers: Vec<(String, String)>, + ex_req_headers: Value, + ex_req_body: Value, + ex_client_req: Value, + ex_translated: Option, + ex_url: String, +) -> Response { + let st2 = st.clone(); + let signature_provider_id = routing.provider_id.clone(); + let signature_session = request_session.clone(); + // Any Gemini-backed transcoded stream (Claude Code or Codex client) feeds the + // signature cache; transcoders that don't track calls return an empty capture. + let capture_thought_signatures = is_gemini_upstream; + let status_code = status.as_u16(); + let ex_id2 = ex_id; + let started2 = started; + let xlabel = format!("{:?}->{:?}", provider_wire, client_wire); + let up_res_headers = vec_headers(&out_headers); + let mut guard = StreamAbortGuard::new( + st.clone(), ex_id, started, method.clone(), req_path.clone(), provider_name.clone(), + routing.clone(), status_code, + json!({ + "id": ex_id, "ts": now_ms(), "method": method.as_str(), "path": req_path, "url": ex_url, + "provider": provider_name, "requestedModel": routing.client_facing_model, + "outgoingModel": routing.outgoing_model, "clientFacingModel": routing.client_facing_model, + "status": status_code, "reqHeaders": ex_req_headers, "reqBody": ex_req_body, + "clientReq": ex_client_req, "translated": ex_translated, + "resHeaders": json!({ "content-type": "text/event-stream", "x-ccbud-translated": xlabel }), + "resBody": json!({ "text": "", "bytes": 0, "truncated": 0 }), + }), + // raw upstream capture (pre-translation), so the monitor can show the exact + // upstream stream next to the translated one the client received + Some((status_code, up_res_headers)), + ); + let method2 = method.clone(); + let path2 = req_path.clone(); + let pname2 = provider_name.clone(); + let routing2 = routing.clone(); + let body_stream = async_stream::stream! { + let mut s = resp.bytes_stream(); + let mut buf = String::new(); + let mut history_recorded = false; + while let Some(chunk) = s.next().await { + match chunk { + Ok(bytes) => { + let raw = String::from_utf8_lossy(&bytes); + guard.push_up(&raw); + buf.push_str(&raw); + let mut out = String::new(); + while let Some(idx) = buf.find('\n') { + let line: String = buf.drain(..=idx).collect(); + out.push_str(&tc.push(&line)); + } + if capture_thought_signatures && tc.succeeded() { + let captured_calls = tc.captured_tool_calls(); + st2.thought_signatures.lock().await.remember( + &signature_provider_id, + signature_session.as_deref(), + &captured_calls, + ); + } + // Keep the guard current BEFORE suspending: once the terminal event is + // out, Codex closes the socket and the generator is dropped mid-await. + guard.push_res(&out); + guard.finished = tc.done(); + if client_wire == crate::protocol::Wire::OpenAiResponses + && !history_recorded + { + if let Some(terminal) = responses_terminal_event(&out) { + history_recorded = true; + if terminal.kind.is_resumable() { + if let Some(response) = terminal.response.as_ref() { + st2.codex_history + .record_response_scoped_with_metadata( + &history_scope, + ResponseOrigin::Local, + true, + &history_request, + response, + ) + .await; + } + } + } + } + guard.usage = Some(UsageAcc { + input: tc.input_tokens(), output: tc.output_tokens(), saw: true, ..Default::default() + }); + if !out.is_empty() { + yield Ok::(Bytes::from(out)); + } + } + Err(error) => { + let message = format!("upstream stream transport error: {}", error); + st2.log("error", format!("{} ({})", message, pname2)); + buf.clear(); + let out = tc.fail(&message); + guard.push_res(&out); + guard.finished = tc.done(); + guard.usage = Some(UsageAcc { + input: tc.input_tokens(), output: tc.output_tokens(), saw: true, ..Default::default() + }); + if !out.is_empty() { + yield Ok(Bytes::from(out)); + } + break; + } + } + } + let mut tail = String::new(); + if !buf.is_empty() { tail.push_str(&tc.push(&buf)); } + tail.push_str(&tc.finish()); + guard.finished = tc.done(); + guard.usage = Some(UsageAcc { + input: tc.input_tokens(), output: tc.output_tokens(), saw: true, ..Default::default() + }); + if capture_thought_signatures && tc.succeeded() { + let captured_calls = tc.captured_tool_calls(); + st2.thought_signatures.lock().await.remember( + &signature_provider_id, + signature_session.as_deref(), + &captured_calls, + ); + } + if !tail.is_empty() { + guard.push_res(&tail); + if client_wire == crate::protocol::Wire::OpenAiResponses + && !history_recorded + { + if let Some(terminal) = responses_terminal_event(&tail) { + if terminal.kind.is_resumable() { + if let Some(response) = terminal.response.as_ref() { + st2.codex_history + .record_response_scoped_with_metadata( + &history_scope, + ResponseOrigin::Local, + true, + &history_request, + response, + ) + .await; + } + } + } + } + yield Ok(Bytes::from(tail)); + } + let mut usage = UsageAcc::default(); + usage.input = tc.input_tokens(); + usage.output = tc.output_tokens(); + usage.saw = true; + st2.emit_request(ex_id2, started2, &method2, &path2, &pname2, &routing2, status_code, Some(&usage)); + let mut ex = guard.complete(); + ex["ms"] = json!(started2.elapsed().as_millis() as u64); + st2.record_exchange(ex).await; + }; + let mut builder = Response::builder() + .status(status.as_u16()) + .header("content-type", "text/event-stream") + .header("x-ccbud-translated", format!("{:?}->{:?}", provider_wire, client_wire)); + // Forward the upstream request id — clients (Claude Code) persist it as `requestId`, + // which usage analytics use as half of the de-dup key. + for (k, v) in &out_headers { + if k == "request-id" || k == "x-request-id" { + builder = builder.header(k, v); + } + } + return builder.body(Body::from_stream(body_stream)).unwrap(); +} diff --git a/src-tauri/src/gateway/targets.rs b/src-tauri/src/gateway/targets.rs new file mode 100644 index 0000000..731d405 --- /dev/null +++ b/src-tauri/src/gateway/targets.rs @@ -0,0 +1,110 @@ +use axum::{ + body::Body, + http::{StatusCode, Uri}, + response::Response, +}; +use serde_json::{json, Value}; + +use super::sse::is_responses_compact_path; + +pub(super) fn retry_delay(retry_after: Option<&str>, attempt: i64, base: i64) -> u64 { + let cap = 30_000u64; + if let Some(ra) = retry_after { + let s = ra.trim(); + if let Ok(n) = s.parse::() { + return (n.saturating_mul(1000)).min(cap); + } + // HTTP-date form (RFC 7231 IMF-fixdate) — honor the absolute time the upstream named + // (proxy.js parity). chrono is already a dep, so no extra crate is pulled in for this. + if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(s, "%a, %d %b %Y %H:%M:%S GMT") { + let ms = (dt.and_utc() - chrono::Utc::now()).num_milliseconds().max(0) as u64; + return ms.min(cap); + } + } + let base = if base > 0 { base as u64 } else { 500 }; + base.saturating_mul(2u64.saturating_pow(attempt.clamp(0, 20) as u32)) + .min(8000) +} + +pub(super) fn build_target(base_url: &str, uri: &Uri) -> Option { + if base_url.is_empty() { + return None; + } + let base = base_url.trim_end_matches('/'); + let path = uri.path(); + let query = uri.query().map(|q| format!("?{}", q)).unwrap_or_default(); + // If the provider baseUrl already carries a path prefix (e.g. ".../v1") and the + // inbound path repeats it (e.g. "/v1/responses"), collapse the overlap so we don't + // forward to ".../v1/v1/responses". This is what bites an openai-* provider whose + // baseUrl ends in /v1 (incl. the sidecar plugins) on same-protocol passthrough. + // Segment-aware so a "/v1" base won't eat a "/v1beta" path. + let base_path = base_url_path(base).trim_end_matches('/'); + let path_out: &str = if base_path.is_empty() || base_path == "/" { + path + } else if path == base_path { + "" + } else { + match path.strip_prefix(base_path) { + Some(rest) if rest.starts_with('/') => rest, + _ => path, + } + }; + Some(format!("{}{}{}", base, path_out, query)) +} + +/// Resolve one of the three primary API endpoints against the configured base URL. The base is +/// authoritative: an inbound `/v1/...` path does not cause ccbud to insert `/v1` upstream. +pub(super) fn endpoint_targets(base_url: &str, uri: &Uri) -> Option<(String, Option)> { + if base_url.trim().is_empty() { + return None; + } + let wire = crate::protocol::Wire::from_request_endpoint(uri.path())?; + let with_query = |mut url: String| { + if let Some(query) = uri.query() { + url.push('?'); + url.push_str(query); + } + url + }; + Some(( + with_query(wire.upstream_url_for_request(base_url, uri.path())), + wire.v1_fallback_url_for_request(base_url, uri.path()).map(with_query), + )) +} + +/// Standalone Responses compaction returns a distinct `response.compaction` object whose output +/// is the canonical replacement context window. Chat and Anthropic upstreams cannot provide that +/// contract through the ordinary response transcoder, so fail explicitly instead of turning a +/// compact request into an unrelated model turn. Responses providers keep the passthrough path. +pub(super) fn cross_wire_compact_error(path: &str, provider_wire: crate::protocol::Wire) -> Option { + (is_responses_compact_path(path) + && provider_wire != crate::protocol::Wire::OpenAiResponses) + .then(|| { + error_response( + StatusCode::NOT_IMPLEMENTED, + "CC Buddy: /v1/responses/compact requires an openai-responses provider; cross-protocol compaction is not supported", + "invalid_request_error", + ) + }) +} + +/// The path component of a base URL (everything after scheme://authority), or "". +fn base_url_path(base: &str) -> &str { + let after_scheme = base.split_once("://").map(|(_, rest)| rest).unwrap_or(base); + match after_scheme.find('/') { + Some(i) => &after_scheme[i..], + None => "", + } +} + +pub(super) fn error_response(status: StatusCode, msg: &str, etype: &str) -> Response { + json_response(status, &json!({ "type": "error", "error": { "type": etype, "message": msg } })) +} +pub(super) fn json_response(status: StatusCode, body: &Value) -> Response { + let bytes = serde_json::to_vec(body).unwrap_or_default(); + Response::builder() + .status(status) + .header("content-type", "application/json") + .body(Body::from(bytes)) + .unwrap() +} diff --git a/src-tauri/src/gateway/tests.rs b/src-tauri/src/gateway/tests.rs new file mode 100644 index 0000000..00f74c1 --- /dev/null +++ b/src-tauri/src/gateway/tests.rs @@ -0,0 +1,124 @@ +use axum::body::to_bytes; +use axum::http::{StatusCode, Uri}; +use serde_json::{json, Value}; + +use super::models::synthesize_models; +use super::responses_history::apply_responses_chat_request_controls; +use super::selftest_routing::routing_selftest; +use super::targets::{build_target, cross_wire_compact_error, endpoint_targets}; + +#[test] +fn routing_parity_with_proxy_js() { + let r = routing_selftest(); + assert_eq!(r.get("failed").and_then(|v| v.as_i64()), Some(0), "routing mismatch: {:?}", r); + assert_eq!(r.get("passed").and_then(|v| v.as_i64()), Some(9)); +} +#[test] +fn synthesize_models_includes_claude_tiers() { + let cfg = json!({ "providers": [{ "id": "p", "defaultModel": "m", "smallFastModel": "m" }], "activeProviderId": "p" }); + let s = synthesize_models(&cfg, false); + let ids: Vec<&str> = s["data"].as_array().unwrap().iter().filter_map(|m| m["id"].as_str()).collect(); + assert!(ids.contains(&"claude-sonnet-5")); + assert!(ids.contains(&"claude-fable-5")); + assert!(!ids.iter().any(|id| id.starts_with("gpt-"))); +} +#[test] +fn synthesize_models_codex_returns_gpt_tiers() { + let cfg = json!({ "providers": [{ "id": "p", "defaultModel": "m", "smallFastModel": "m" }], "activeProviderId": "p" }); + let s = synthesize_models(&cfg, true); + let ids: Vec<&str> = s["data"].as_array().unwrap().iter().filter_map(|m| m["id"].as_str()).collect(); + assert!(ids.contains(&"gpt-5.4")); + assert!(ids.contains(&"gpt-5.4-mini")); + assert!(!ids.iter().any(|id| id.starts_with("claude-"))); +} +#[test] +fn responses_chat_translation_preserves_parallel_tool_calls() { + let mut body = json!({ "model": "upstream", "messages": [] }); + apply_responses_chat_request_controls( + &mut body, + &json!({ "parallel_tool_calls": false }), + ); + assert_eq!(body["parallel_tool_calls"], false); + + let mut absent = json!({ "model": "upstream", "messages": [] }); + apply_responses_chat_request_controls(&mut absent, &json!({})); + assert!(absent.get("parallel_tool_calls").is_none()); +} +#[test] +fn build_target_collapses_path_overlap() { + let u = |s: &str| s.parse::().unwrap(); + // openai-* provider / sidecar plugin: base ends in /v1 and the client path + // repeats /v1 → collapse (was ".../v1/v1/responses" → 404). + assert_eq!(build_target("http://127.0.0.1:57085/v1", &u("/v1/responses")).unwrap(), "http://127.0.0.1:57085/v1/responses"); + assert_eq!(build_target("http://127.0.0.1:57085/v1", &u("/v1/models?x=1")).unwrap(), "http://127.0.0.1:57085/v1/models?x=1"); + // non-overlapping prefix (anthropic providers) → plain concat, unchanged. + assert_eq!(build_target("https://api.deepseek.com/anthropic", &u("/v1/messages")).unwrap(), "https://api.deepseek.com/anthropic/v1/messages"); + // base without a path → unchanged. + assert_eq!(build_target("http://127.0.0.1:9", &u("/v1/responses")).unwrap(), "http://127.0.0.1:9/v1/responses"); + // segment-aware: a /v1 base must NOT eat a /v1beta path. + assert_eq!(build_target("http://h/v1", &u("/v1beta/x")).unwrap(), "http://h/v1/v1beta/x"); +} +#[test] +fn primary_endpoints_use_the_configured_base_and_offer_one_v1_fallback() { + let u = |s: &str| s.parse::().unwrap(); + assert_eq!( + endpoint_targets("https://example.com/api", &u("/v1/messages?x=1")), + Some(( + "https://example.com/api/messages?x=1".to_string(), + Some("https://example.com/api/v1/messages?x=1".to_string()), + )) + ); + assert_eq!( + endpoint_targets("https://example.com/v4", &u("/v1/chat/completions")), + Some(("https://example.com/v4/chat/completions".to_string(), None)) + ); + assert_eq!( + endpoint_targets("https://example.com/v1", &u("/v1/responses")), + Some(("https://example.com/v1/responses".to_string(), None)) + ); + assert_eq!( + endpoint_targets("https://example.com/v1", &u("/v1/responses/compact")), + Some(("https://example.com/v1/responses/compact".to_string(), None)) + ); + assert_eq!( + endpoint_targets( + "https://generativelanguage.googleapis.com/v1beta/openai", + &u("/v1/chat/completions"), + ), + Some(( + "https://generativelanguage.googleapis.com/v1beta/openai/chat/completions".to_string(), + None, + )) + ); + assert_eq!(endpoint_targets("https://example.com/api", &u("/v1/models")), None); + assert_eq!(endpoint_targets("https://example.com/api", &u("/v1/messages/count_tokens")), None); +} +#[tokio::test] +async fn compact_rejects_cross_wire_and_allows_responses_passthrough() { + for provider_wire in [ + crate::protocol::Wire::OpenAiChat, + crate::protocol::Wire::Anthropic, + ] { + let response = cross_wire_compact_error("/v1/responses/compact/", provider_wire) + .expect("cross-wire compact must be rejected locally"); + assert_eq!(response.status(), StatusCode::NOT_IMPLEMENTED); + let body = to_bytes(response.into_body(), 4096).await.unwrap(); + let error: Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(error["error"]["type"], "invalid_request_error"); + assert!(error["error"]["message"] + .as_str() + .unwrap() + .contains("cross-protocol compaction is not supported")); + } + + assert!(cross_wire_compact_error( + "/v1/responses/compact", + crate::protocol::Wire::OpenAiResponses, + ) + .is_none()); + assert!(cross_wire_compact_error( + "/v1/responses", + crate::protocol::Wire::OpenAiChat, + ) + .is_none()); +} diff --git a/src-tauri/src/gateway/tests_history.rs b/src-tauri/src/gateway/tests_history.rs new file mode 100644 index 0000000..60624ea --- /dev/null +++ b/src-tauri/src/gateway/tests_history.rs @@ -0,0 +1,189 @@ +use crate::protocol::codex_history::{HistoryResolution, ResponseOrigin}; + +use super::responses_history::{ + decide_responses_compact_history, decide_responses_history, ResponsesForwardMode, + ResponsesHistoryDecision, ResponsesHistoryError, +}; +use super::sse::{responses_terminal_event, ResponsesTerminalKind}; + +#[test] +fn responses_history_policy_covers_native_and_translated_provider_switches() { + let known = |origin: ResponseOrigin, materializable: bool| HistoryResolution { + changed: 3, + had_previous_response_id: true, + previous_found: true, + previous_materialized: materializable, + previous_origin: Some(origin), + }; + let portable = ResponsesHistoryDecision { + forward: ResponsesForwardMode::Materialized, + descendant_materializable: true, + }; + + // Native Responses A → translated chat/Anthropic. + assert_eq!( + decide_responses_history( + crate::protocol::Wire::OpenAiChat, + "provider-chat", + &known(ResponseOrigin::Native("provider-a".to_string()), true), + ), + Ok(portable) + ); + // Translated/local → native Responses. + assert_eq!( + decide_responses_history( + crate::protocol::Wire::OpenAiResponses, + "provider-b", + &known(ResponseOrigin::Local, true), + ), + Ok(portable) + ); + // Native Responses A → native Responses B. + assert_eq!( + decide_responses_history( + crate::protocol::Wire::OpenAiResponses, + "provider-b", + &known(ResponseOrigin::Native("provider-a".to_string()), true), + ), + Ok(portable) + ); + // Same native owner keeps the provider-side id while the materialized local copy is retained + // for recording its descendant. + assert_eq!( + decide_responses_history( + crate::protocol::Wire::OpenAiResponses, + "provider-a", + &known(ResponseOrigin::Native("provider-a".to_string()), true), + ), + Ok(ResponsesHistoryDecision { + forward: ResponsesForwardMode::Original, + descendant_materializable: true, + }) + ); + assert_eq!( + decide_responses_history( + crate::protocol::Wire::OpenAiResponses, + "provider-a", + &known(ResponseOrigin::Native("provider-a".to_string()), false), + ), + Ok(ResponsesHistoryDecision { + forward: ResponsesForwardMode::Original, + descendant_materializable: false, + }) + ); + + let missing = HistoryResolution { + had_previous_response_id: true, + ..HistoryResolution::default() + }; + assert_eq!( + decide_responses_history( + crate::protocol::Wire::OpenAiResponses, + "provider-a", + &missing, + ), + Ok(ResponsesHistoryDecision { + forward: ResponsesForwardMode::Original, + descendant_materializable: false, + }) + ); + assert_eq!( + decide_responses_history( + crate::protocol::Wire::Anthropic, + "provider-anthropic", + &missing, + ), + Err(ResponsesHistoryError::Unavailable) + ); + assert_eq!( + decide_responses_history( + crate::protocol::Wire::OpenAiResponses, + "provider-a", + &HistoryResolution { + changed: 2, + ..HistoryResolution::default() + }, + ), + Ok(ResponsesHistoryDecision { + forward: ResponsesForwardMode::Materialized, + descendant_materializable: true, + }) + ); + assert_eq!( + decide_responses_history( + crate::protocol::Wire::OpenAiResponses, + "provider-b", + &known(ResponseOrigin::Local, false), + ), + Err(ResponsesHistoryError::Unavailable) + ); +} +#[test] +fn responses_compact_localizes_portable_foreign_history_and_allows_owner_or_cache_miss() { + let known = |origin: ResponseOrigin, materializable: bool| HistoryResolution { + changed: 2, + had_previous_response_id: true, + previous_found: true, + previous_materialized: materializable, + previous_origin: Some(origin), + }; + let missing = HistoryResolution { + had_previous_response_id: true, + ..HistoryResolution::default() + }; + assert_eq!( + decide_responses_compact_history("provider-a", &missing), + Ok(ResponsesForwardMode::Original) + ); + assert_eq!( + decide_responses_compact_history( + "provider-a", + &known(ResponseOrigin::Native("provider-a".to_string()), false), + ), + Ok(ResponsesForwardMode::Original) + ); + for origin in [ + ResponseOrigin::Local, + ResponseOrigin::Native("provider-b".to_string()), + ] { + assert_eq!( + decide_responses_compact_history("provider-a", &known(origin, true)), + Ok(ResponsesForwardMode::Materialized) + ); + } + assert_eq!( + decide_responses_compact_history( + "provider-a", + &known(ResponseOrigin::Local, false), + ), + Err(ResponsesHistoryError::Unavailable) + ); +} +#[test] +fn responses_terminal_parser_keeps_completed_and_incomplete_but_marks_failed() { + for (event_type, status, expected) in [ + ( + "response.completed", + "completed", + ResponsesTerminalKind::Completed, + ), + ( + "response.incomplete", + "incomplete", + ResponsesTerminalKind::Incomplete, + ), + ("response.failed", "failed", ResponsesTerminalKind::Failed), + ] { + let sse = format!( + "event: {event_type}\ndata: {{\"type\":\"{event_type}\",\"response\":{{\"id\":\"resp_1\",\"object\":\"response\",\"status\":\"{status}\",\"output\":[]}}}}\n\n" + ); + let terminal = responses_terminal_event(&sse).unwrap(); + assert_eq!(terminal.kind, expected); + assert_eq!(terminal.kind.is_resumable(), status != "failed"); + assert_eq!(terminal.response.as_ref().unwrap()["status"], status); + } + assert!(responses_terminal_event( + "event: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"delta\":\"partial\"}\n\n" + ) + .is_none()); +} diff --git a/src-tauri/src/gateway/tests_routing.rs b/src-tauri/src/gateway/tests_routing.rs new file mode 100644 index 0000000..c19ab18 --- /dev/null +++ b/src-tauri/src/gateway/tests_routing.rs @@ -0,0 +1,102 @@ +use serde_json::json; + +use super::routing::{resolve_routing, Routing}; +use super::session::{codex_history_scope_for_session, request_session_id}; +use super::signatures::{apply_gemini_signature_fallback, ThoughtSignatureCache, GEMINI_SIGNATURE_FALLBACK}; +use super::targets::retry_delay; + +#[test] +fn routing_classifies_by_family() { + let cfg = json!({ "providers": [{ "id": "p", "baseUrl": "http://127.0.0.1:1", "authToken": "k", + "defaultModel": "big", "smallFastModel": "small", "mapDefaultModels": true, "models": [] }], "activeProviderId": "p" }); + let out = |r: Option| r.and_then(|x| x.outgoing_model); + // Claude: haiku → fast, fable/opus/sonnet → primary. + assert_eq!(out(resolve_routing(Some("claude-haiku-4-5"), &cfg, None)).as_deref(), Some("small")); + assert_eq!(out(resolve_routing(Some("claude-fable-5"), &cfg, None)).as_deref(), Some("big")); + assert_eq!(out(resolve_routing(Some("claude-opus-4-8"), &cfg, None)).as_deref(), Some("big")); + // Codex: stable/default identities → primary; explicit small tiers → fast. Legacy + // sol/terra names remain primary for existing configs. + assert_eq!( + out(resolve_routing(Some("gpt-5.4"), &cfg, None)).as_deref(), + Some("big") + ); + assert_eq!( + out(resolve_routing(Some("gpt-5.4-mini"), &cfg, None)).as_deref(), + Some("small") + ); + assert_eq!(out(resolve_routing(Some("gpt-5.6-sol"), &cfg, None)).as_deref(), Some("big")); + assert_eq!(out(resolve_routing(Some("gpt-5.6-terra"), &cfg, None)).as_deref(), Some("big")); + assert_eq!(out(resolve_routing(Some("gpt-5.6-sol-pro"), &cfg, None)).as_deref(), Some("big")); + assert_eq!(out(resolve_routing(Some("gpt-5.6-luna"), &cfg, None)).as_deref(), Some("small")); +} +#[test] +fn retry_delay_honors_seconds_and_backoff() { + assert_eq!(retry_delay(Some("2"), 0, 500), 2000); + assert_eq!(retry_delay(None, 0, 500), 500); + assert_eq!(retry_delay(None, 1, 500), 1000); + // HTTP-date in the past → no wait (clamped to 0), NOT a fall-through to backoff. + assert_eq!(retry_delay(Some("Wed, 21 Oct 2015 07:28:00 GMT"), 3, 500), 0); + // Unparseable Retry-After → exponential backoff (base * 2^attempt). + assert_eq!(retry_delay(Some("soon"), 2, 500), 2000); +} +#[test] +fn extracts_claude_session_id_from_metadata() { + let nested = json!({ "metadata": { "user_id": "{\"session_id\":\"session-123\",\"account_id\":\"a\"}" } }); + assert_eq!(request_session_id(&nested).as_deref(), Some("session-123")); + assert!(request_session_id(&json!({ "metadata": { "user_id": "user-123" } })).is_none()); + // Codex (Responses client) identifies its conversation via prompt_cache_key. + assert_eq!( + request_session_id(&json!({ "prompt_cache_key": "conv-42" })).as_deref(), + Some("conv-42") + ); + assert!(request_session_id(&json!({ "prompt_cache_key": " " })).is_none()); + assert_eq!(codex_history_scope_for_session(Some("conv-42")), "conv-42"); + assert_eq!(codex_history_scope_for_session(None), ""); +} + +// The full Codex ⇄ Gemini(chat) signature round-trip: what ChatToResponses captured last turn +// is restored onto the function_call history Codex echoes back, and earlier steps get the +// documented fallback sentinel — without it Gemini 3 rejects the request with a 400. +#[test] +fn restores_signatures_for_codex_responses_requests() { + let mut cache = ThoughtSignatureCache::default(); + cache.remember("google", Some("conv-42"), &[ + crate::protocol::stream::CapturedToolCall { + call_id: "call_9".to_string(), + name: "shell".to_string(), + arguments: "{\"command\":[\"ls\"]}".to_string(), + thought_signature: Some("sig-codex".to_string()), + }, + ]); + let codex = json!({ + "model": "gpt-5.5-ccbud", + "instructions": "You are Codex.", + "prompt_cache_key": "conv-42", + "input": [ + { "type": "message", "role": "user", "content": [{ "type": "input_text", "text": "list, then read" }] }, + { "type": "function_call", "call_id": "call_1", "name": "shell", "arguments": "{\"command\":[\"pwd\"]}" }, + { "type": "function_call_output", "call_id": "call_1", "output": "/tmp" }, + { "type": "function_call", "call_id": "call_9", "name": "shell", "arguments": "{ \"command\": [\"ls\"] }" }, + { "type": "function_call_output", "call_id": "call_9", "output": "a.txt" } + ], + "store": false, "stream": true + }); + let mut ir = crate::protocol::decode_client_request(crate::protocol::Wire::OpenAiResponses, &codex).unwrap(); + assert_eq!(request_session_id(&codex).as_deref(), Some("conv-42")); + assert_eq!(cache.restore("google", Some("conv-42"), &mut ir), 1); + assert_eq!(apply_gemini_signature_fallback(&mut ir), 1); + let steps: Vec<_> = ir.messages.iter().filter_map(|message| message.tool_calls.as_ref()).collect(); + assert_eq!(crate::protocol::tool_call_thought_signature(&steps[0][0]).as_deref(), + Some(GEMINI_SIGNATURE_FALLBACK)); + assert_eq!(crate::protocol::tool_call_thought_signature(&steps[1][0]).as_deref(), + Some("sig-codex")); + // …and the encoded Gemini chat body carries them where Gemini validates them. + let body = crate::protocol::encode_upstream_request( + crate::protocol::Wire::OpenAiChat, &ir, "gemini-3-flash-preview", true, + ).unwrap(); + let signatures: Vec<_> = body["messages"].as_array().unwrap().iter() + .filter(|message| message["role"] == "assistant") + .map(|message| message["tool_calls"][0]["extra_content"]["google"]["thought_signature"].clone()) + .collect(); + assert_eq!(signatures, vec![json!(GEMINI_SIGNATURE_FALLBACK), json!("sig-codex")]); +} diff --git a/src-tauri/src/gateway/tests_signatures.rs b/src-tauri/src/gateway/tests_signatures.rs new file mode 100644 index 0000000..3e414ea --- /dev/null +++ b/src-tauri/src/gateway/tests_signatures.rs @@ -0,0 +1,196 @@ +use serde_json::{json, Value}; + +use super::history_args::{ + provider_safe_history_tool_arguments, sanitize_provider_history_tool_arguments, +}; +use super::redact::now_ms; +use super::signatures::{ + apply_gemini_signature_fallback, ThoughtSignatureBatch, ThoughtSignatureCache, + GEMINI_SIGNATURE_FALLBACK, THOUGHT_SIGNATURE_TTL_MS, +}; + +#[test] +fn repairs_malformed_history_arguments_before_strict_chat_forwarding() { + let body = json!({ + "model": "gpt-5.4", + "input": [ + { "type": "message", "role": "user", "content": [{ + "type": "input_text", "text": "Use the helper and continue" + }] }, + { "type": "function_call", "call_id": "call_bad", "name": "helper", + "arguments": "{\"value\":1} trailing-garbage" }, + { "type": "function_call_output", "call_id": "call_bad", + "output": "failed to parse function arguments" } + ], + "tools": [{ "type": "function", "name": "helper", "description": "test", + "parameters": { "type": "object", "properties": { "value": { "type": "number" } } } }] + }); + let mut ir = crate::protocol::decode_client_request( + crate::protocol::Wire::OpenAiResponses, + &body, + ) + .unwrap(); + let call = ir.messages[1].tool_calls.as_mut().unwrap().first_mut().unwrap(); + call.thought_signature = Some("stale-signature".to_string()); + + assert_eq!(sanitize_provider_history_tool_arguments(&mut ir), 1); + let call = &ir.messages[1].tool_calls.as_ref().unwrap()[0]; + assert_eq!(serde_json::from_str::(&call.function.arguments).unwrap()["value"], 1); + assert!(crate::protocol::tool_call_thought_signature(call).is_none()); + assert_eq!(apply_gemini_signature_fallback(&mut ir), 1); + + let encoded = crate::protocol::encode_upstream_request( + crate::protocol::Wire::OpenAiChat, + &ir, + "gemini-3.5-flash", + false, + ) + .unwrap(); + let outgoing = &encoded["messages"][1]["tool_calls"][0]; + assert_eq!( + serde_json::from_str::(outgoing["function"]["arguments"].as_str().unwrap()) + .unwrap()["value"], + 1 + ); + assert_eq!( + outgoing["extra_content"]["google"]["thought_signature"], + GEMINI_SIGNATURE_FALLBACK + ); +} + +#[test] +fn history_argument_repair_preserves_valid_objects_and_wraps_unrecoverable_text() { + assert_eq!(provider_safe_history_tool_arguments(" { \"value\": 1 } "), None); + let scalar = provider_safe_history_tool_arguments("42").unwrap(); + assert_eq!(serde_json::from_str::(&scalar).unwrap()["_ccbuddy_value"], 42); + let raw = provider_safe_history_tool_arguments("not json at all").unwrap(); + assert_eq!( + serde_json::from_str::(&raw).unwrap()["_ccbuddy_raw_arguments"], + "not json at all" + ); + + for arguments in ["{\"value\":1}\u{00a0}", "\u{000b}{\"value\":1}"] { + let repaired = provider_safe_history_tool_arguments(arguments).unwrap(); + assert_eq!( + serde_json::from_str::(&repaired).unwrap(), + json!({ "value": 1 }) + ); + } +} + +#[test] +fn history_argument_repair_clears_a_signature_restored_for_different_bytes() { + let captured = crate::protocol::stream::CapturedToolCall { + call_id: "call_empty".to_string(), + name: "helper".to_string(), + arguments: String::new(), + thought_signature: Some("real-signature".to_string()), + }; + let mut cache = ThoughtSignatureCache::default(); + cache.remember("google", Some("session-empty"), &[captured]); + let body = json!({ + "model": "gpt-5.4", + "input": [ + { "role": "user", "content": "Call helper" }, + { "type": "function_call", "call_id": "call_empty", "name": "helper", + "arguments": "" }, + { "type": "function_call_output", "call_id": "call_empty", "output": "invalid" } + ] + }); + let mut ir = crate::protocol::decode_client_request( + crate::protocol::Wire::OpenAiResponses, + &body, + ) + .unwrap(); + + assert_eq!(cache.restore("google", Some("session-empty"), &mut ir), 1); + assert_eq!(sanitize_provider_history_tool_arguments(&mut ir), 1); + let call = &ir.messages[1].tool_calls.as_ref().unwrap()[0]; + assert_eq!(call.function.arguments, "{}"); + assert!(crate::protocol::tool_call_thought_signature(call).is_none()); + assert_eq!(apply_gemini_signature_fallback(&mut ir), 1); + assert_eq!( + crate::protocol::tool_call_thought_signature( + &ir.messages[1].tool_calls.as_ref().unwrap()[0] + ) + .as_deref(), + Some(GEMINI_SIGNATURE_FALLBACK) + ); +} + +#[test] +fn restores_latest_batch_and_falls_back_for_prior_steps() { + let call = |id: &str, name: &str, arguments: &str, signature: Option<&str>| { + crate::protocol::stream::CapturedToolCall { + call_id: id.to_string(), + name: name.to_string(), + arguments: arguments.to_string(), + thought_signature: signature.map(str::to_string), + } + }; + let mut cache = ThoughtSignatureCache::default(); + cache.remember("google", Some("session-1"), &[ + call("default_api:Bash", "default_api:Bash", "{\"command\":\"pwd\"}", Some("sig-old")), + ]); + cache.remember("google", Some("session-1"), &[ + call("call_paris", "weather", "{ \"city\": \"Paris\" }", Some("sig-latest")), + call("call_london", "weather", "{\"city\":\"London\"}", None), + ]); + let claude = json!({ + "model": "claude-sonnet-5", "max_tokens": 1024, + "messages": [ + { "role": "user", "content": "Run pwd, then check Paris and London" }, + { "role": "assistant", "content": [{ + "type": "tool_use", "id": "default_api:Bash", "name": "default_api:Bash", + "input": { "command": "pwd" } + }] }, + { "role": "user", "content": [{ + "type": "tool_result", "tool_use_id": "default_api:Bash", "content": "/tmp" + }] }, + { "role": "assistant", "content": [ + { "type": "tool_use", "id": "call_paris", "name": "weather", + "input": { "city": "Paris" } }, + { "type": "tool_use", "id": "call_london", "name": "weather", + "input": { "city": "London" } } + ] }, + { "role": "user", "content": [ + { "type": "tool_result", "tool_use_id": "call_paris", "content": "15C" }, + { "type": "tool_result", "tool_use_id": "call_london", "content": "12C" } + ] } + ] + }); + let mut ir = crate::protocol::decode_client_request(crate::protocol::Wire::Anthropic, &claude).unwrap(); + assert_eq!(cache.restore("google", Some("session-1"), &mut ir), 1); + assert_eq!(apply_gemini_signature_fallback(&mut ir), 1); + let steps: Vec<_> = ir.messages.iter().filter_map(|message| message.tool_calls.as_ref()).collect(); + assert_eq!(crate::protocol::tool_call_thought_signature(&steps[0][0]).as_deref(), + Some(GEMINI_SIGNATURE_FALLBACK)); + assert_eq!(crate::protocol::tool_call_thought_signature(&steps[1][0]).as_deref(), + Some("sig-latest")); + assert!(crate::protocol::tool_call_thought_signature(&steps[1][1]).is_none()); +} + +#[test] +fn sessionless_cache_access_prunes_expired_batches() { + let stale = ThoughtSignatureBatch { + calls: vec![], + touched_at: now_ms().saturating_sub(THOUGHT_SIGNATURE_TTL_MS + 1), + }; + let mut cache = ThoughtSignatureCache::default(); + cache.batches.insert(("google".into(), "stale".into()), stale.clone()); + cache.remember("google", None, &[]); + assert!(cache.batches.is_empty()); + + cache.batches.insert(("google".into(), "stale".into()), stale); + let body = json!({ + "model": "claude-sonnet-5", + "max_tokens": 1, + "messages": [{ "role": "user", "content": "ping" }] + }); + let mut request = crate::protocol::decode_client_request( + crate::protocol::Wire::Anthropic, + &body, + ).unwrap(); + assert_eq!(cache.restore("google", None, &mut request), 0); + assert!(cache.batches.is_empty()); +} diff --git a/src-tauri/src/grok.rs b/src-tauri/src/grok.rs deleted file mode 100644 index 6217dd6..0000000 --- a/src-tauri/src/grok.rs +++ /dev/null @@ -1,537 +0,0 @@ -// Grok Build CLI session support — reads xAI Grok's on-disk sessions -// (`~/.grok/sessions///chat_history.jsonl`, sibling `summary.json` -// carrying id/cwd/title/model/git/timestamps) and normalizes them into the SAME session/message -// shape the renderer consumes (see history::Norm), so the 对话 view browses Grok sessions -// without renderer forks. -// -// A chat_history line is one of: `system` (harness prompt — skipped), `user` (content blocks of -// text / data-URL image; the human prose is wrapped in tags, harness wrappers like -// / are dropped), `reasoning` ({summary:[{summary_text}]} → thinking), -// `assistant` ({content, tool_calls:[{id,name,arguments-json}]}), and `tool_result` -// ({tool_call_id, content, images?}). Tool names are mapped onto the renderer's native -// vocabulary (both grok tool-name generations: read_file/Read → Read, Shell → Bash, …). -// -// The same uuid dir also holds events/updates/rewind_points/hunk_records .jsonl — only -// chat_history.jsonl is the conversation; walkers must never sweep the rest. -// -// Title/tags/soft-delete live in the shared foreign-CLI sidecar (~/.ccbud/agent-meta.json) -// keyed `grok:` — chat_history stems aren't unique, and the files belong to another tool. - -#![allow(dead_code)] - -use crate::history::{image_block, Norm}; -use serde_json::{json, Value}; -use std::fs; -use std::path::{Path, PathBuf}; - -fn home() -> PathBuf { - std::env::var("HOME").map(PathBuf::from).unwrap_or_else(|_| PathBuf::from(".")) -} - -/// Grok's DEFAULT config dir as a history-dir entry string (`~/.grok`). Honors GROK_HOME the way -/// the grok CLI does (summary.json echoes it as `grok_home`). Only the auto-add migration keys -/// off this — browsing walks every configured dir's `sessions/` tree. -pub fn default_root() -> PathBuf { - match std::env::var("GROK_HOME") { - Ok(h) if !h.trim().is_empty() => PathBuf::from(h), - _ => home().join(".grok"), - } -} - -pub fn grok_label() -> String { - crate::store::collapse_home(&default_root().to_string_lossy()) -} - -/// A grok install exists when its sessions tree holds at least one percent-encoded cwd dir. -pub fn root_exists() -> bool { - let sessions = default_root().join("sessions"); - fs::read_dir(&sessions) - .map(|entries| { - entries - .flatten() - .any(|e| is_cwd_dir_name(&e.file_name().to_string_lossy()) && e.path().is_dir()) - }) - .unwrap_or(false) -} - -/// Grok encodes each workspace cwd as a percent-encoded absolute path dir ("%2FUsers%2F…") — -/// the marker that distinguishes a grok sessions/ child from Codex's YYYY date shards. -pub fn is_cwd_dir_name(name: &str) -> bool { - let lower = name.to_ascii_lowercase(); - lower.starts_with("%2f") || lower.starts_with("%3a%5c") // unix "/", windows "X:\" oddity-proof -} - -/// Session files under one encoded-cwd dir: `//chat_history.jsonl`. -pub fn walk_cwd_dir(dir: &Path, cb: &mut F) { - let entries = match fs::read_dir(dir) { - Ok(e) => e, - Err(_) => return, - }; - for ent in entries.flatten() { - let p = ent.path(); - if !p.is_dir() { - continue; - } - let chat = p.join("chat_history.jsonl"); - if chat.is_file() { - cb(chat); - } - } -} - -/// Container-shape test for detail/edit routing: `…/sessions///chat_history.jsonl`. -pub fn looks_grok_path(file: &Path) -> bool { - if file.file_name().and_then(|n| n.to_str()) != Some("chat_history.jsonl") { - return false; - } - file.parent() - .and_then(|uuid_dir| uuid_dir.parent()) - .and_then(|enc| enc.file_name()) - .map(|n| is_cwd_dir_name(&n.to_string_lossy())) - .unwrap_or(false) -} - -/// The session uuid (its dir name) — sidecar key and renderer id both build on it. -fn session_uuid(file: &Path) -> String { - file.parent() - .and_then(|d| d.file_name()) - .map(|n| n.to_string_lossy().into_owned()) - .unwrap_or_default() -} - -fn sidecar_key(file: &Path) -> String { - format!("grok:{}", session_uuid(file)) -} - -fn sidecar_meta(file: &Path) -> (Option, Vec, bool) { - crate::sidecar::meta(&crate::sidecar::agent_file(), &sidecar_key(file)) -} - -pub fn is_deleted(file: &Path) -> bool { - sidecar_meta(file).2 -} - -pub fn set_meta(file: &str, patch: &Value) -> Value { - let key = sidecar_key(Path::new(file)); - if key == "grok:" { - return json!({ "ok": false, "reason": "empty" }); - } - crate::sidecar::set_meta(&crate::sidecar::agent_file(), &key, patch) -} - -/// Sibling summary.json of a chat_history.jsonl (grok's own session metadata). -fn summary_of(file: &Path) -> Option { - let p = file.parent()?.join("summary.json"); - serde_json::from_str(&fs::read_to_string(p).ok()?).ok() -} - -/// Minimal percent-decoding for grok's encoded-cwd dir names (fallback when summary.json -/// is missing; the record cwd wins when present). Also used on Antigravity's file:// uris. -pub(crate) fn percent_decode(s: &str) -> String { - let bytes = s.as_bytes(); - let mut out: Vec = Vec::with_capacity(bytes.len()); - let mut i = 0; - while i < bytes.len() { - if bytes[i] == b'%' && i + 2 < bytes.len() { - if let Ok(b) = u8::from_str_radix(&s[i + 1..i + 3], 16) { - out.push(b); - i += 3; - continue; - } - } - out.push(bytes[i]); - i += 1; - } - String::from_utf8_lossy(&out).into_owned() -} - -fn rfc3339_ms(s: &str) -> Option { - chrono::DateTime::parse_from_rfc3339(s) - .ok() - .map(|d| d.timestamp_millis() as f64) -} - -/// Harness-injected user text (environment wrappers) — hidden from the timeline. -fn is_meta_user_text(t: &str) -> bool { - let t = t.trim_start(); - ["", "", "", "…` (the human prose envelope grok writes). -fn unwrap_user_query(t: &str) -> String { - match t.split_once("") { - Some((_, rest)) => rest.split("").next().unwrap_or(rest).trim().to_string(), - None => t.trim().to_string(), - } -} - -/// Grok tool name + parsed arguments → (renderer tool name, renderer input). Covers both grok -/// tool-name generations (snake_case and CamelCase). -fn map_tool(name: &str, args: &Value) -> (String, Value) { - let s = |k: &str| args.get(k).and_then(|v| v.as_str()).unwrap_or("").to_string(); - let keep = |v: &Value| if v.is_object() { v.clone() } else { json!({}) }; - match name { - "run_terminal_command" | "Shell" => { - let mut input = json!({ "command": s("command") }); - if !s("description").is_empty() { - input["description"] = json!(s("description")); - } - ("Bash".into(), input) - } - "read_file" | "Read" => { - let path = if !s("target_file").is_empty() { s("target_file") } else { s("path") }; - let mut input = json!({ "file_path": path }); - for k in ["offset", "limit"] { - if let Some(v) = args.get(k) { - if !v.is_null() { - input[k] = v.clone(); - } - } - } - ("Read".into(), input) - } - "grep" | "Grep" | "grep_search" => { - let mut input = json!({ "pattern": if !s("pattern").is_empty() { s("pattern") } else { s("query") } }); - if !s("path").is_empty() { - input["path"] = json!(s("path")); - } - ("Grep".into(), input) - } - "search_replace" => ("Edit".into(), keep(args)), - "StrReplace" => ( - "Edit".into(), - json!({ "file_path": s("path"), "old_string": s("old_string"), "new_string": s("new_string") }), - ), - "write" => ("Write".into(), keep(args)), - "Write" => ("Write".into(), json!({ "file_path": s("path"), "content": s("contents") })), - "list_dir" => ("LS".into(), json!({ "path": s("target_directory") })), - "Glob" => ("Glob".into(), json!({ "pattern": s("glob_pattern"), "path": s("target_directory") })), - "todo_write" | "TodoWrite" => ("TodoWrite".into(), keep(args)), - "web_fetch" | "WebFetch" => ("WebFetch".into(), json!({ "url": s("url") })), - "WebSearch" => ("WebSearch".into(), json!({ "query": s("search_term") })), - _ => (name.to_string(), keep(args)), - } -} - -/// Normalize parsed chat_history records (+ the sibling summary) into the renderer's message -/// model. Lines carry no timestamps — session-level times come from summary.json. -pub fn normalize(recs: &[Value], summary: Option<&Value>) -> Norm { - let mut n = Norm::default(); - let sum = summary.cloned().unwrap_or(Value::Null); - n.model = sum.get("current_model_id").and_then(|v| v.as_str()).map(|s| s.to_string()); - n.cwd = sum - .get("info") - .and_then(|i| i.get("cwd")) - .and_then(|v| v.as_str()) - .map(|s| s.to_string()); - n.session_id = sum - .get("info") - .and_then(|i| i.get("id")) - .and_then(|v| v.as_str()) - .map(|s| s.to_string()); - n.git_branch = sum.get("head_branch").and_then(|v| v.as_str()).map(|s| s.to_string()); - n.first_ts = sum.get("created_at").and_then(|v| v.as_str()).map(|s| s.to_string()); - n.last_ts = sum - .get("last_active_at") - .or_else(|| sum.get("updated_at")) - .and_then(|v| v.as_str()) - .map(|s| s.to_string()); - - for rec in recs { - let ty = rec.get("type").and_then(|v| v.as_str()).unwrap_or(""); - match ty { - "user" => { - let mut blocks: Vec = vec![]; - if let Some(arr) = rec.get("content").and_then(|c| c.as_array()) { - for b in arr { - match b.get("type").and_then(|t| t.as_str()).unwrap_or("") { - "text" => { - let raw = b.get("text").and_then(|t| t.as_str()).unwrap_or(""); - if is_meta_user_text(raw) && !raw.contains("") { - continue; - } - let text = unwrap_user_query(raw); - if !text.is_empty() { - blocks.push(json!({ "type": "text", "text": text })); - } - } - "image" => { - if let Some(img) = - b.get("url").and_then(|u| u.as_str()).and_then(image_block) - { - blocks.push(img); - } - } - _ => {} - } - } - } else if let Some(t) = rec.get("content").and_then(|c| c.as_str()) { - let text = unwrap_user_query(t); - if !text.is_empty() && !is_meta_user_text(t) { - blocks.push(json!({ "type": "text", "text": text })); - } - } - if !blocks.is_empty() { - n.messages.push(json!({ "role": "user", "content": blocks })); - } - } - "reasoning" => { - let txt = rec - .get("summary") - .and_then(|s| s.as_array()) - .map(|arr| { - arr.iter() - .filter_map(|b| b.get("text").and_then(|t| t.as_str())) - .collect::>() - .join("\n") - }) - .unwrap_or_default(); - if !txt.trim().is_empty() { - let mut m = json!({ "role": "assistant", "content": [{ "type": "thinking", "thinking": txt }] }); - if let Some(md) = &n.model { - m["modelActual"] = json!(md); - } - n.messages.push(m); - } - } - "assistant" => { - let mut blocks: Vec = vec![]; - let text = rec.get("content").and_then(|c| c.as_str()).unwrap_or(""); - if !text.trim().is_empty() { - blocks.push(json!({ "type": "text", "text": text })); - } - if let Some(calls) = rec.get("tool_calls").and_then(|c| c.as_array()) { - for call in calls { - let name = call.get("name").and_then(|v| v.as_str()).unwrap_or("tool"); - let args: Value = call - .get("arguments") - .and_then(|v| v.as_str()) - .and_then(|s| serde_json::from_str(s).ok()) - .unwrap_or_else(|| call.get("arguments").cloned().unwrap_or(json!({}))); - let (tname, input) = map_tool(name, &args); - let id = call.get("id").and_then(|v| v.as_str()).unwrap_or(""); - blocks.push(json!({ "type": "tool_use", "id": id, "name": tname, "input": input })); - } - } - if !blocks.is_empty() { - let mut m = json!({ "role": "assistant", "content": blocks }); - if let Some(md) = &n.model { - m["modelActual"] = json!(md); - } - n.messages.push(m); - } - } - "tool_result" => { - let id = rec.get("tool_call_id").and_then(|v| v.as_str()).unwrap_or(""); - let text = rec.get("content").and_then(|c| c.as_str()).unwrap_or("").to_string(); - let images: Vec = rec - .get("images") - .and_then(|a| a.as_array()) - .map(|arr| { - arr.iter() - .filter_map(|b| b.get("url").and_then(|u| u.as_str()).and_then(image_block)) - .collect() - }) - .unwrap_or_default(); - let content: Value = if images.is_empty() { - json!(text) - } else { - let mut blocks = vec![json!({ "type": "text", "text": text })]; - blocks.extend(images); - json!(blocks) - }; - n.messages - .push(json!({ "role": "user", "content": [{ "type": "tool_result", "tool_use_id": id, "content": content }] })); - } - _ => {} // system / unknown: harness plumbing, not conversation - } - } - n -} - -/// List-row meta: summary.json carries everything cheap (title/cwd/model/times); the file head -/// is only parsed when grok didn't store a title yet (fallback to first user prose). -pub fn session_meta_from(file: &Path, dir_id: &str, dir_label: &str) -> Option { - let meta = fs::metadata(file).ok()?; - let sum = summary_of(file); - let uuid = session_uuid(file); - let (cc_title, cc_tags, cc_deleted) = sidecar_meta(file); - let sum_title = sum - .as_ref() - .and_then(|s| s.get("generated_title").or_else(|| s.get("session_summary"))) - .and_then(|v| v.as_str()) - .map(|s| s.trim().to_string()) - .filter(|s| !s.is_empty()); - let auto_title = sum_title.unwrap_or_else(|| { - let recs = crate::history::parse_lines(&crate::history::read_head(file, 131072)); - let n = normalize(&recs, sum.as_ref()); - crate::history::first_user_text(&n.messages) - }); - let cwd = sum - .as_ref() - .and_then(|s| s.get("info")) - .and_then(|i| i.get("cwd")) - .and_then(|v| v.as_str()) - .map(|s| s.to_string()) - .or_else(|| { - file.parent() - .and_then(|d| d.parent()) - .and_then(|enc| enc.file_name()) - .map(|nm| percent_decode(&nm.to_string_lossy())) - }); - let created = sum - .as_ref() - .and_then(|s| s.get("created_at")) - .and_then(|v| v.as_str()) - .and_then(rfc3339_ms) - .unwrap_or_else(|| crate::history::created_ms(file)); - let mt = meta - .modified() - .ok() - .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) - .map(|d| d.as_millis() as f64) - .unwrap_or(0.0); - Some(json!({ - "id": format!("grok:{}", uuid), - "file": file.to_string_lossy(), - "source": "grok", - "dirId": dir_id, - "dirLabel": dir_label, - "sessionId": sum - .as_ref() - .and_then(|s| s.get("info")) - .and_then(|i| i.get("id")) - .and_then(|v| v.as_str()) - .unwrap_or(&uuid), - "cwd": cwd.clone(), - "project": cwd.as_deref().map(crate::history::base_name).unwrap_or_default(), - "gitBranch": sum.as_ref().and_then(|s| s.get("head_branch")).cloned().unwrap_or(Value::Null), - "title": cc_title.clone().unwrap_or_else(|| auto_title.clone()), - "autoTitle": auto_title, - "tags": cc_tags, - "model": sum.as_ref().and_then(|s| s.get("current_model_id")).cloned().unwrap_or(Value::Null), - "isSubagent": false, - "imported": false, - "deleted": cc_deleted, - "createdAt": created, - "lastActivity": mt, - "sizeKB": (meta.len() as f64 / 1024.0).round() as i64, - })) -} - -/// Full-detail shape (history.rs get_session routes here). -pub fn session_from_recs(file: &str, recs: &[Value]) -> Value { - let path = Path::new(file); - let sum = summary_of(path); - let n = normalize(recs, sum.as_ref()); - let (cc_title, cc_tags, cc_deleted) = sidecar_meta(path); - let sum_title = sum - .as_ref() - .and_then(|s| s.get("generated_title").or_else(|| s.get("session_summary"))) - .and_then(|v| v.as_str()) - .map(|s| s.trim().to_string()) - .filter(|s| !s.is_empty()); - let auto_title = sum_title.unwrap_or_else(|| crate::history::first_user_text(&n.messages)); - let uuid = session_uuid(path); - json!({ - "meta": { - "id": format!("grok:{}", uuid), - "file": file, - "source": "grok", - "assistant": "Grok", - "title": cc_title.clone().unwrap_or_else(|| auto_title.clone()), - "autoTitle": auto_title, - "tags": cc_tags, - "summary": Value::Null, - "sessionId": n.session_id.clone().unwrap_or_else(|| uuid.clone()), - "cwd": n.cwd.clone(), - "project": n.cwd.as_deref().map(crate::history::base_name).unwrap_or_default(), - "gitBranch": n.git_branch.clone(), - "version": Value::Null, - "isSubagent": false, - "deleted": cc_deleted, - "imported": false, - "importedFrom": Value::Null, - "importedAt": Value::Null, - "model": n.model, - "totals": n.totals, - "messages": n.messages.len(), - "subagentCount": 0, - "firstTs": n.first_ts, - "lastTs": n.last_ts, - }, - "messages": n.messages, - "subagents": {}, - }) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn recs() -> Vec { - vec![ - json!({ "type": "system", "content": "You are Grok…" }), - json!({ "type": "user", "content": [{ "type": "text", "text": "\nOS: macos\n\n\n\nclean\n\n" }] }), - json!({ "type": "user", "content": [ - { "type": "text", "text": "\n修复登录 bug\n" }, - { "type": "image", "url": "data:image/png;base64,QUJD" } - ] }), - json!({ "type": "reasoning", "id": "rs_1", "summary": [{ "type": "summary_text", "text": "Scanning the repo" }] }), - json!({ "type": "assistant", "content": "先看下目录。", "tool_calls": [ - { "id": "call-1", "name": "run_terminal_command", "arguments": "{\"command\":\"ls\",\"description\":\"List files\"}" }, - { "id": "call-2", "name": "read_file", "arguments": "{\"target_file\":\"src/app.js\"}" } - ] }), - json!({ "type": "tool_result", "tool_call_id": "call-1", "content": "a.txt\nb.txt" }), - json!({ "type": "tool_result", "tool_call_id": "call-2", "content": "console.log(1)", "images": [{ "type": "image", "url": "data:image/png;base64,REVG" }] }), - ] - } - - fn summary() -> Value { - json!({ - "info": { "id": "0199-aaaa", "cwd": "/tmp/proj" }, - "generated_title": "Fix login bug", - "created_at": "2026-06-18T06:27:07.777809Z", - "last_active_at": "2026-06-18T06:57:37.242478Z", - "current_model_id": "grok-build", - "head_branch": "main", - }) - } - - #[test] - fn normalizes_conversation() { - let s = summary(); - let n = normalize(&recs(), Some(&s)); - // harness wrapper user turn dropped; real turns: user, thinking, assistant+tools, 2 results - assert_eq!(n.messages.len(), 5); - assert_eq!(n.messages[0]["role"], "user"); - assert_eq!(n.messages[0]["content"][0]["text"], "修复登录 bug"); - assert_eq!(n.messages[0]["content"][1]["type"], "image"); - assert_eq!(n.messages[1]["content"][0]["type"], "thinking"); - let a = &n.messages[2]; - assert_eq!(a["content"][0]["text"], "先看下目录。"); - assert_eq!(a["content"][1]["name"], "Bash"); - assert_eq!(a["content"][1]["input"]["command"], "ls"); - assert_eq!(a["content"][2]["name"], "Read"); - assert_eq!(a["content"][2]["input"]["file_path"], "src/app.js"); - assert_eq!(n.messages[3]["content"][0]["tool_use_id"], "call-1"); - // image-carrying result becomes a block array - assert_eq!(n.messages[4]["content"][0]["content"][1]["type"], "image"); - assert_eq!(n.model.as_deref(), Some("grok-build")); - assert_eq!(n.cwd.as_deref(), Some("/tmp/proj")); - } - - #[test] - fn detects_cwd_dirs_and_paths() { - assert!(is_cwd_dir_name("%2FUsers%2Fme%2Fcode")); - assert!(is_cwd_dir_name("%2fusers%2fme")); - assert!(!is_cwd_dir_name("2026")); - assert_eq!(percent_decode("%2FUsers%2Fme"), "/Users/me"); - let p = Path::new("/x/sessions/%2FUsers%2Fme/0199-aaaa/chat_history.jsonl"); - assert!(looks_grok_path(p)); - assert!(!looks_grok_path(Path::new("/x/sessions/2026/01/01/rollout-1.jsonl"))); - assert_eq!(session_uuid(p), "0199-aaaa"); - } -} diff --git a/src-tauri/src/grok/meta.rs b/src-tauri/src/grok/meta.rs new file mode 100644 index 0000000..b44260b --- /dev/null +++ b/src-tauri/src/grok/meta.rs @@ -0,0 +1,60 @@ +// Per-session customization (title / tags / soft delete) via the shared foreign-CLI sidecar, +// plus the sibling summary.json that carries id/cwd/title/model/git/timestamps. + +use serde_json::{json, Value}; +use std::fs; +use std::path::Path; + +/// The session uuid (its dir name) — sidecar key and renderer id both build on it. +pub(super) fn session_uuid(file: &Path) -> String { + file.parent() + .and_then(|d| d.file_name()) + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_default() +} + +pub(super) fn sidecar_key(file: &Path) -> String { + format!("grok:{}", session_uuid(file)) +} + +pub(super) fn sidecar_meta(file: &Path) -> (Option, Vec, bool) { + crate::sidecar::meta(&crate::sidecar::agent_file(), &sidecar_key(file)) +} + +pub fn is_deleted(file: &Path) -> bool { + sidecar_meta(file).2 +} + +pub fn set_meta(file: &str, patch: &Value) -> Value { + let key = sidecar_key(Path::new(file)); + if key == "grok:" { + return json!({ "ok": false, "reason": "empty" }); + } + crate::sidecar::set_meta(&crate::sidecar::agent_file(), &key, patch) +} + +/// Sibling summary.json of a chat_history.jsonl (grok's own session metadata). +pub(super) fn summary_of(file: &Path) -> Option { + let p = file.parent()?.join("summary.json"); + serde_json::from_str(&fs::read_to_string(p).ok()?).ok() +} + +/// Minimal percent-decoding for grok's encoded-cwd dir names (fallback when summary.json +/// is missing; the record cwd wins when present). Also used on Antigravity's file:// uris. +pub fn percent_decode(s: &str) -> String { + let bytes = s.as_bytes(); + let mut out: Vec = Vec::with_capacity(bytes.len()); + let mut i = 0; + while i < bytes.len() { + if bytes[i] == b'%' && i + 2 < bytes.len() { + if let Ok(b) = u8::from_str_radix(&s[i + 1..i + 3], 16) { + out.push(b); + i += 3; + continue; + } + } + out.push(bytes[i]); + i += 1; + } + String::from_utf8_lossy(&out).into_owned() +} diff --git a/src-tauri/src/grok/mod.rs b/src-tauri/src/grok/mod.rs new file mode 100644 index 0000000..51d1d3e --- /dev/null +++ b/src-tauri/src/grok/mod.rs @@ -0,0 +1,32 @@ +// Grok Build CLI session support — reads xAI Grok's on-disk sessions +// (`~/.grok/sessions///chat_history.jsonl`, sibling `summary.json` +// carrying id/cwd/title/model/git/timestamps) and normalizes them into the SAME session/message +// shape the renderer consumes (see history::Norm), so the 对话 view browses Grok sessions +// without renderer forks. +// +// A chat_history line is one of: `system` (harness prompt — skipped), `user` (content blocks of +// text / data-URL image; the human prose is wrapped in tags, harness wrappers like +// / are dropped), `reasoning` ({summary:[{summary_text}]} → thinking), +// `assistant` ({content, tool_calls:[{id,name,arguments-json}]}), and `tool_result` +// ({tool_call_id, content, images?}). Tool names are mapped onto the renderer's native +// vocabulary (both grok tool-name generations: read_file/Read → Read, Shell → Bash, …). +// +// The same uuid dir also holds events/updates/rewind_points/hunk_records .jsonl — only +// chat_history.jsonl is the conversation; walkers must never sweep the rest. +// +// Title/tags/soft-delete live in the shared foreign-CLI sidecar (~/.ccbud/agent-meta.json) +// keyed `grok:` — chat_history stems aren't unique, and the files belong to another tool. + +#![allow(dead_code)] + +mod meta; +mod normalize; +mod roots; +mod session; +#[cfg(test)] +mod tests; + +pub use meta::{is_deleted, percent_decode, set_meta}; +pub use normalize::normalize; +pub use roots::{grok_label, is_cwd_dir_name, looks_grok_path, root_exists, walk_cwd_dir}; +pub use session::{session_from_recs, session_meta_from}; diff --git a/src-tauri/src/grok/normalize.rs b/src-tauri/src/grok/normalize.rs new file mode 100644 index 0000000..1479440 --- /dev/null +++ b/src-tauri/src/grok/normalize.rs @@ -0,0 +1,211 @@ +// chat_history.jsonl → history::Norm: harness-wrapper stripping, tool-name mapping onto the +// renderer's native vocabulary, and the per-line walk that builds the message timeline. + +use crate::history::{image_block, Norm}; +use serde_json::{json, Value}; + +pub(super) fn rfc3339_ms(s: &str) -> Option { + chrono::DateTime::parse_from_rfc3339(s) + .ok() + .map(|d| d.timestamp_millis() as f64) +} + +/// Harness-injected user text (environment wrappers) — hidden from the timeline. +fn is_meta_user_text(t: &str) -> bool { + let t = t.trim_start(); + ["", "", "", "` (the human prose envelope grok writes). +fn unwrap_user_query(t: &str) -> String { + match t.split_once("") { + Some((_, rest)) => rest.split("").next().unwrap_or(rest).trim().to_string(), + None => t.trim().to_string(), + } +} + +/// Grok tool name + parsed arguments → (renderer tool name, renderer input). Covers both grok +/// tool-name generations (snake_case and CamelCase). +fn map_tool(name: &str, args: &Value) -> (String, Value) { + let s = |k: &str| args.get(k).and_then(|v| v.as_str()).unwrap_or("").to_string(); + let keep = |v: &Value| if v.is_object() { v.clone() } else { json!({}) }; + match name { + "run_terminal_command" | "Shell" => { + let mut input = json!({ "command": s("command") }); + if !s("description").is_empty() { + input["description"] = json!(s("description")); + } + ("Bash".into(), input) + } + "read_file" | "Read" => { + let path = if !s("target_file").is_empty() { s("target_file") } else { s("path") }; + let mut input = json!({ "file_path": path }); + for k in ["offset", "limit"] { + if let Some(v) = args.get(k) { + if !v.is_null() { + input[k] = v.clone(); + } + } + } + ("Read".into(), input) + } + "grep" | "Grep" | "grep_search" => { + let mut input = json!({ "pattern": if !s("pattern").is_empty() { s("pattern") } else { s("query") } }); + if !s("path").is_empty() { + input["path"] = json!(s("path")); + } + ("Grep".into(), input) + } + "search_replace" => ("Edit".into(), keep(args)), + "StrReplace" => ( + "Edit".into(), + json!({ "file_path": s("path"), "old_string": s("old_string"), "new_string": s("new_string") }), + ), + "write" => ("Write".into(), keep(args)), + "Write" => ("Write".into(), json!({ "file_path": s("path"), "content": s("contents") })), + "list_dir" => ("LS".into(), json!({ "path": s("target_directory") })), + "Glob" => ("Glob".into(), json!({ "pattern": s("glob_pattern"), "path": s("target_directory") })), + "todo_write" | "TodoWrite" => ("TodoWrite".into(), keep(args)), + "web_fetch" | "WebFetch" => ("WebFetch".into(), json!({ "url": s("url") })), + "WebSearch" => ("WebSearch".into(), json!({ "query": s("search_term") })), + _ => (name.to_string(), keep(args)), + } +} + +/// Normalize parsed chat_history records (+ the sibling summary) into the renderer's message +/// model. Lines carry no timestamps — session-level times come from summary.json. +pub fn normalize(recs: &[Value], summary: Option<&Value>) -> Norm { + let mut n = Norm::default(); + let sum = summary.cloned().unwrap_or(Value::Null); + n.model = sum.get("current_model_id").and_then(|v| v.as_str()).map(|s| s.to_string()); + n.cwd = sum + .get("info") + .and_then(|i| i.get("cwd")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + n.session_id = sum + .get("info") + .and_then(|i| i.get("id")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + n.git_branch = sum.get("head_branch").and_then(|v| v.as_str()).map(|s| s.to_string()); + n.first_ts = sum.get("created_at").and_then(|v| v.as_str()).map(|s| s.to_string()); + n.last_ts = sum + .get("last_active_at") + .or_else(|| sum.get("updated_at")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + for rec in recs { + let ty = rec.get("type").and_then(|v| v.as_str()).unwrap_or(""); + match ty { + "user" => { + let mut blocks: Vec = vec![]; + if let Some(arr) = rec.get("content").and_then(|c| c.as_array()) { + for b in arr { + match b.get("type").and_then(|t| t.as_str()).unwrap_or("") { + "text" => { + let raw = b.get("text").and_then(|t| t.as_str()).unwrap_or(""); + if is_meta_user_text(raw) && !raw.contains("") { + continue; + } + let text = unwrap_user_query(raw); + if !text.is_empty() { + blocks.push(json!({ "type": "text", "text": text })); + } + } + "image" => { + if let Some(img) = + b.get("url").and_then(|u| u.as_str()).and_then(image_block) + { + blocks.push(img); + } + } + _ => {} + } + } + } else if let Some(t) = rec.get("content").and_then(|c| c.as_str()) { + let text = unwrap_user_query(t); + if !text.is_empty() && !is_meta_user_text(t) { + blocks.push(json!({ "type": "text", "text": text })); + } + } + if !blocks.is_empty() { + n.messages.push(json!({ "role": "user", "content": blocks })); + } + } + "reasoning" => { + let txt = rec + .get("summary") + .and_then(|s| s.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|b| b.get("text").and_then(|t| t.as_str())) + .collect::>() + .join("\n") + }) + .unwrap_or_default(); + if !txt.trim().is_empty() { + let mut m = json!({ "role": "assistant", "content": [{ "type": "thinking", "thinking": txt }] }); + if let Some(md) = &n.model { + m["modelActual"] = json!(md); + } + n.messages.push(m); + } + } + "assistant" => { + let mut blocks: Vec = vec![]; + let text = rec.get("content").and_then(|c| c.as_str()).unwrap_or(""); + if !text.trim().is_empty() { + blocks.push(json!({ "type": "text", "text": text })); + } + if let Some(calls) = rec.get("tool_calls").and_then(|c| c.as_array()) { + for call in calls { + let name = call.get("name").and_then(|v| v.as_str()).unwrap_or("tool"); + let args: Value = call + .get("arguments") + .and_then(|v| v.as_str()) + .and_then(|s| serde_json::from_str(s).ok()) + .unwrap_or_else(|| call.get("arguments").cloned().unwrap_or(json!({}))); + let (tname, input) = map_tool(name, &args); + let id = call.get("id").and_then(|v| v.as_str()).unwrap_or(""); + blocks.push(json!({ "type": "tool_use", "id": id, "name": tname, "input": input })); + } + } + if !blocks.is_empty() { + let mut m = json!({ "role": "assistant", "content": blocks }); + if let Some(md) = &n.model { + m["modelActual"] = json!(md); + } + n.messages.push(m); + } + } + "tool_result" => { + let id = rec.get("tool_call_id").and_then(|v| v.as_str()).unwrap_or(""); + let text = rec.get("content").and_then(|c| c.as_str()).unwrap_or("").to_string(); + let images: Vec = rec + .get("images") + .and_then(|a| a.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|b| b.get("url").and_then(|u| u.as_str()).and_then(image_block)) + .collect() + }) + .unwrap_or_default(); + let content: Value = if images.is_empty() { + json!(text) + } else { + let mut blocks = vec![json!({ "type": "text", "text": text })]; + blocks.extend(images); + json!(blocks) + }; + n.messages + .push(json!({ "role": "user", "content": [{ "type": "tool_result", "tool_use_id": id, "content": content }] })); + } + _ => {} // system / unknown: harness plumbing, not conversation + } + } + n +} diff --git a/src-tauri/src/grok/roots.rs b/src-tauri/src/grok/roots.rs new file mode 100644 index 0000000..0b52d95 --- /dev/null +++ b/src-tauri/src/grok/roots.rs @@ -0,0 +1,73 @@ +// Where Grok keeps its sessions (`~/.grok/sessions///`) and how to +// walk them. The uuid dir also holds events/updates/rewind_points/hunk_records .jsonl — only +// chat_history.jsonl is the conversation, so walkers must never sweep the rest. + +use std::fs; +use std::path::{Path, PathBuf}; + +fn home() -> PathBuf { + std::env::var("HOME").map(PathBuf::from).unwrap_or_else(|_| PathBuf::from(".")) +} + +/// Grok's DEFAULT config dir as a history-dir entry string (`~/.grok`). Honors GROK_HOME the way +/// the grok CLI does (summary.json echoes it as `grok_home`). Only the auto-add migration keys +/// off this — browsing walks every configured dir's `sessions/` tree. +pub fn default_root() -> PathBuf { + match std::env::var("GROK_HOME") { + Ok(h) if !h.trim().is_empty() => PathBuf::from(h), + _ => home().join(".grok"), + } +} + +pub fn grok_label() -> String { + crate::store::collapse_home(&default_root().to_string_lossy()) +} + +/// A grok install exists when its sessions tree holds at least one percent-encoded cwd dir. +pub fn root_exists() -> bool { + let sessions = default_root().join("sessions"); + fs::read_dir(&sessions) + .map(|entries| { + entries + .flatten() + .any(|e| is_cwd_dir_name(&e.file_name().to_string_lossy()) && e.path().is_dir()) + }) + .unwrap_or(false) +} + +/// Grok encodes each workspace cwd as a percent-encoded absolute path dir ("%2FUsers%2F…") — +/// the marker that distinguishes a grok sessions/ child from Codex's YYYY date shards. +pub fn is_cwd_dir_name(name: &str) -> bool { + let lower = name.to_ascii_lowercase(); + lower.starts_with("%2f") || lower.starts_with("%3a%5c") // unix "/", windows "X:\" oddity-proof +} + +/// Session files under one encoded-cwd dir: `//chat_history.jsonl`. +pub fn walk_cwd_dir(dir: &Path, cb: &mut F) { + let entries = match fs::read_dir(dir) { + Ok(e) => e, + Err(_) => return, + }; + for ent in entries.flatten() { + let p = ent.path(); + if !p.is_dir() { + continue; + } + let chat = p.join("chat_history.jsonl"); + if chat.is_file() { + cb(chat); + } + } +} + +/// Container-shape test for detail/edit routing: `…/sessions///chat_history.jsonl`. +pub fn looks_grok_path(file: &Path) -> bool { + if file.file_name().and_then(|n| n.to_str()) != Some("chat_history.jsonl") { + return false; + } + file.parent() + .and_then(|uuid_dir| uuid_dir.parent()) + .and_then(|enc| enc.file_name()) + .map(|n| is_cwd_dir_name(&n.to_string_lossy())) + .unwrap_or(false) +} diff --git a/src-tauri/src/grok/session.rs b/src-tauri/src/grok/session.rs new file mode 100644 index 0000000..27542ab --- /dev/null +++ b/src-tauri/src/grok/session.rs @@ -0,0 +1,124 @@ +// Session list rows and the full session payload the renderer's 对话 view consumes. + +use super::meta::{sidecar_meta, session_uuid, summary_of}; +use super::meta::percent_decode; +use super::normalize::{normalize, rfc3339_ms}; +use serde_json::{json, Value}; +use std::fs; +use std::path::Path; + +/// List-row meta: summary.json carries everything cheap (title/cwd/model/times); the file head +/// is only parsed when grok didn't store a title yet (fallback to first user prose). +pub fn session_meta_from(file: &Path, dir_id: &str, dir_label: &str) -> Option { + let meta = fs::metadata(file).ok()?; + let sum = summary_of(file); + let uuid = session_uuid(file); + let (cc_title, cc_tags, cc_deleted) = sidecar_meta(file); + let sum_title = sum + .as_ref() + .and_then(|s| s.get("generated_title").or_else(|| s.get("session_summary"))) + .and_then(|v| v.as_str()) + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()); + let auto_title = sum_title.unwrap_or_else(|| { + let recs = crate::history::parse_lines(&crate::history::read_head(file, 131072)); + let n = normalize(&recs, sum.as_ref()); + crate::history::first_user_text(&n.messages) + }); + let cwd = sum + .as_ref() + .and_then(|s| s.get("info")) + .and_then(|i| i.get("cwd")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + .or_else(|| { + file.parent() + .and_then(|d| d.parent()) + .and_then(|enc| enc.file_name()) + .map(|nm| percent_decode(&nm.to_string_lossy())) + }); + let created = sum + .as_ref() + .and_then(|s| s.get("created_at")) + .and_then(|v| v.as_str()) + .and_then(rfc3339_ms) + .unwrap_or_else(|| crate::history::created_ms(file)); + let mt = meta + .modified() + .ok() + .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|d| d.as_millis() as f64) + .unwrap_or(0.0); + Some(json!({ + "id": format!("grok:{}", uuid), + "file": file.to_string_lossy(), + "source": "grok", + "dirId": dir_id, + "dirLabel": dir_label, + "sessionId": sum + .as_ref() + .and_then(|s| s.get("info")) + .and_then(|i| i.get("id")) + .and_then(|v| v.as_str()) + .unwrap_or(&uuid), + "cwd": cwd.clone(), + "project": cwd.as_deref().map(crate::history::base_name).unwrap_or_default(), + "gitBranch": sum.as_ref().and_then(|s| s.get("head_branch")).cloned().unwrap_or(Value::Null), + "title": cc_title.clone().unwrap_or_else(|| auto_title.clone()), + "autoTitle": auto_title, + "tags": cc_tags, + "model": sum.as_ref().and_then(|s| s.get("current_model_id")).cloned().unwrap_or(Value::Null), + "isSubagent": false, + "imported": false, + "deleted": cc_deleted, + "createdAt": created, + "lastActivity": mt, + "sizeKB": (meta.len() as f64 / 1024.0).round() as i64, + })) +} + +/// Full-detail shape (history.rs get_session routes here). +pub fn session_from_recs(file: &str, recs: &[Value]) -> Value { + let path = Path::new(file); + let sum = summary_of(path); + let n = normalize(recs, sum.as_ref()); + let (cc_title, cc_tags, cc_deleted) = sidecar_meta(path); + let sum_title = sum + .as_ref() + .and_then(|s| s.get("generated_title").or_else(|| s.get("session_summary"))) + .and_then(|v| v.as_str()) + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()); + let auto_title = sum_title.unwrap_or_else(|| crate::history::first_user_text(&n.messages)); + let uuid = session_uuid(path); + json!({ + "meta": { + "id": format!("grok:{}", uuid), + "file": file, + "source": "grok", + "assistant": "Grok", + "title": cc_title.clone().unwrap_or_else(|| auto_title.clone()), + "autoTitle": auto_title, + "tags": cc_tags, + "summary": Value::Null, + "sessionId": n.session_id.clone().unwrap_or_else(|| uuid.clone()), + "cwd": n.cwd.clone(), + "project": n.cwd.as_deref().map(crate::history::base_name).unwrap_or_default(), + "gitBranch": n.git_branch.clone(), + "version": Value::Null, + "isSubagent": false, + "deleted": cc_deleted, + "imported": false, + "importedFrom": Value::Null, + "importedAt": Value::Null, + "model": n.model, + "totals": n.totals, + "messages": n.messages.len(), + "subagentCount": 0, + "firstTs": n.first_ts, + "lastTs": n.last_ts, + }, + "messages": n.messages, + "subagents": {}, + }) +} diff --git a/src-tauri/src/grok/tests.rs b/src-tauri/src/grok/tests.rs new file mode 100644 index 0000000..9c5a8da --- /dev/null +++ b/src-tauri/src/grok/tests.rs @@ -0,0 +1,69 @@ +use super::meta::{percent_decode, session_uuid}; +use super::normalize::normalize; +use super::roots::{is_cwd_dir_name, looks_grok_path}; +use serde_json::{json, Value}; +use std::path::Path; + +fn recs() -> Vec { + vec![ + json!({ "type": "system", "content": "You are Grok…" }), + json!({ "type": "user", "content": [{ "type": "text", "text": "\nOS: macos\n\n\n\nclean\n\n" }] }), + json!({ "type": "user", "content": [ + { "type": "text", "text": "\n修复登录 bug\n" }, + { "type": "image", "url": "data:image/png;base64,QUJD" } + ] }), + json!({ "type": "reasoning", "id": "rs_1", "summary": [{ "type": "summary_text", "text": "Scanning the repo" }] }), + json!({ "type": "assistant", "content": "先看下目录。", "tool_calls": [ + { "id": "call-1", "name": "run_terminal_command", "arguments": "{\"command\":\"ls\",\"description\":\"List files\"}" }, + { "id": "call-2", "name": "read_file", "arguments": "{\"target_file\":\"src/app.js\"}" } + ] }), + json!({ "type": "tool_result", "tool_call_id": "call-1", "content": "a.txt\nb.txt" }), + json!({ "type": "tool_result", "tool_call_id": "call-2", "content": "console.log(1)", "images": [{ "type": "image", "url": "data:image/png;base64,REVG" }] }), + ] +} + +fn summary() -> Value { + json!({ + "info": { "id": "0199-aaaa", "cwd": "/tmp/proj" }, + "generated_title": "Fix login bug", + "created_at": "2026-06-18T06:27:07.777809Z", + "last_active_at": "2026-06-18T06:57:37.242478Z", + "current_model_id": "grok-build", + "head_branch": "main", + }) +} + +#[test] +fn normalizes_conversation() { + let s = summary(); + let n = normalize(&recs(), Some(&s)); + // harness wrapper user turn dropped; real turns: user, thinking, assistant+tools, 2 results + assert_eq!(n.messages.len(), 5); + assert_eq!(n.messages[0]["role"], "user"); + assert_eq!(n.messages[0]["content"][0]["text"], "修复登录 bug"); + assert_eq!(n.messages[0]["content"][1]["type"], "image"); + assert_eq!(n.messages[1]["content"][0]["type"], "thinking"); + let a = &n.messages[2]; + assert_eq!(a["content"][0]["text"], "先看下目录。"); + assert_eq!(a["content"][1]["name"], "Bash"); + assert_eq!(a["content"][1]["input"]["command"], "ls"); + assert_eq!(a["content"][2]["name"], "Read"); + assert_eq!(a["content"][2]["input"]["file_path"], "src/app.js"); + assert_eq!(n.messages[3]["content"][0]["tool_use_id"], "call-1"); + // image-carrying result becomes a block array + assert_eq!(n.messages[4]["content"][0]["content"][1]["type"], "image"); + assert_eq!(n.model.as_deref(), Some("grok-build")); + assert_eq!(n.cwd.as_deref(), Some("/tmp/proj")); +} + +#[test] +fn detects_cwd_dirs_and_paths() { + assert!(is_cwd_dir_name("%2FUsers%2Fme%2Fcode")); + assert!(is_cwd_dir_name("%2fusers%2fme")); + assert!(!is_cwd_dir_name("2026")); + assert_eq!(percent_decode("%2FUsers%2Fme"), "/Users/me"); + let p = Path::new("/x/sessions/%2FUsers%2Fme/0199-aaaa/chat_history.jsonl"); + assert!(looks_grok_path(p)); + assert!(!looks_grok_path(Path::new("/x/sessions/2026/01/01/rollout-1.jsonl"))); + assert_eq!(session_uuid(p), "0199-aaaa"); +} diff --git a/src-tauri/src/history.rs b/src-tauri/src/history.rs deleted file mode 100644 index f6aacec..0000000 --- a/src-tauri/src/history.rs +++ /dev/null @@ -1,3112 +0,0 @@ -// Conversation history. -// -// Reads Claude Code and Codex on-disk sessions across configured dirs, imported snapshots, and -// the app-managed recycle bin. Shapes list/detail payloads for the renderer, including subagents, -// custom title/tags/delete metadata, bundle import/export helpers, and live-watch roots. - -#![allow(dead_code)] - -use serde_json::{json, Value}; -use std::fs; -use std::path::{Path, PathBuf}; - -/// Synthetic "recycle bin" bucket id. Not a real projects tree (never in all_dirs / -/// each_session_file) — a cross-cutting view of soft-deleted sessions across every dir. -pub const TRASH_ID: &str = "__trash__"; - -fn home() -> PathBuf { - std::env::var("HOME").map(PathBuf::from).unwrap_or_else(|_| PathBuf::from(".")) -} - -fn expand_tilde(p: &str) -> PathBuf { - if let Some(rest) = p.strip_prefix("~/") { - home().join(rest) - } else if p == "~" { - home() - } else { - PathBuf::from(p) - } -} - -/// Configured dirs → (id, label, projects_dir). id == the dir string (historyActive matches it). -fn config_dirs(config: &Value) -> Vec<(String, String, PathBuf)> { - let mut out = vec![]; - if let Some(arr) = config.get("historyDirs").and_then(|v| v.as_array()) { - for d in arr { - if let Some(s) = d.as_str() { - out.push((s.to_string(), s.to_string(), expand_tilde(s).join("projects"))); - } - } - } - out -} - -pub(crate) fn base_name(p: &str) -> String { - p.split('/').filter(|s| !s.is_empty()).last().unwrap_or(p).to_string() -} - -/// Best-effort decode of an encoded project dir name → cwd (record cwd wins when present). -fn decode_dir_name(name: &str) -> Option { - if name.is_empty() { - return None; - } - let trimmed = name.trim_start_matches('-'); - Some(format!("/{}", trimmed.replace('-', "/"))) -} - -pub(crate) fn parse_lines(text: &str) -> Vec { - let mut out = vec![]; - for line in text.split('\n') { - let s = line.trim(); - if s.is_empty() { - continue; - } - if let Ok(v) = serde_json::from_str::(s) { - out.push(v); - } - } - out -} - -fn read_head_result(file: &Path, max: usize) -> std::io::Result { - use std::io::{BufRead, BufReader, Read}; - // Qoder data can be protected as "Other Application Data" on macOS. Its reader first - // attempts the normal filesystem path and uses the installed Qoder CLI only for EPERM; - // keep the same bounded-head contract used by list metadata after that read succeeds. - if crate::qoder::looks_qoder_path(file) { - let mut bytes = crate::qoder::read_bytes(file)?; - bytes.truncate(max); - return Ok(String::from_utf8_lossy(&bytes).into_owned()); - } - let mut file = fs::File::open(file)?; - let mut buf = vec![0u8; max]; - let read = file.read(&mut buf)?; - buf.truncate(read); - // SessionMeta may exceed the ordinary list window because it can embed base instructions and - // dynamic tools. Extend ONLY when the first record itself has no newline yet; a later partial - // record can be ignored, avoiding an accidental multi-megabyte image/tool-result read. - let prefix_len = buf.len().min(4096); - let compact_prefix: String = String::from_utf8_lossy(&buf[..prefix_len]) - .chars() - .filter(|value| !value.is_ascii_whitespace()) - .collect(); - let codex_session_meta = compact_prefix - .find("\"type\":\"session_meta\"") - .is_some_and(|position| position < 512); - if codex_session_meta && read == max && !buf.contains(&b'\n') { - let mut reader = BufReader::new(file); - let _ = reader.read_until(b'\n', &mut buf)?; - } - Ok(String::from_utf8_lossy(&buf).into_owned()) -} - -pub(crate) fn read_head(file: &Path, max: usize) -> String { - read_head_result(file, max).unwrap_or_default() -} - -fn read_session_text(file: &Path) -> std::io::Result { - if crate::qoder::looks_qoder_path(file) { - crate::qoder::read_text(file) - } else { - fs::read_to_string(file) - } -} - -fn read_session_bytes(file: &Path) -> std::io::Result> { - if crate::qoder::looks_qoder_path(file) { - crate::qoder::read_bytes(file) - } else { - fs::read(file) - } -} - -/// Verbatim bytes for raw export. Qoder sessions may require the guarded Qoder CLI fallback on -/// macOS; all other sources retain the ordinary filesystem read used before Qoder support. -pub(crate) fn raw_session_bytes(file: &str) -> std::io::Result> { - read_session_bytes(Path::new(file)) -} - -pub(crate) fn session_read_error(file: &Path, error: &std::io::Error) -> Value { - let kind = match error.kind() { - std::io::ErrorKind::NotFound => "notFound", - std::io::ErrorKind::PermissionDenied => "permissionDenied", - _ => "readFailed", - }; - json!({ - "error": { - "kind": kind, - "file": file.to_string_lossy(), - "message": error.to_string(), - } - }) -} - -fn usage_of(u: &Value) -> Value { - let mut usage = json!({ - "inputTokens": u.get("input_tokens").and_then(|v| v.as_i64()).unwrap_or(0), - "outputTokens": u.get("output_tokens").and_then(|v| v.as_i64()).unwrap_or(0), - "cacheRead": u.get("cache_read_input_tokens").and_then(|v| v.as_i64()).unwrap_or(0), - "cacheCreation": u.get("cache_creation_input_tokens").and_then(|v| v.as_i64()).unwrap_or(0), - }); - let object = usage.as_object_mut().unwrap(); - // Qoder supplies billing/context facts alongside its zeroed token counters. Keep them on the - // per-turn usage object without adding empty fields to ordinary Claude Code messages. - for (source, target) in [ - ("credits", "credits"), - ("original_credits", "originalCredits"), - ("context_usage_ratio", "contextUsageRatio"), - ] { - if let Some(value) = u.get(source).filter(|value| value.is_number()) { - object.insert(target.to_string(), value.clone()); - } - } - usage -} - -fn content_text(content: &Value) -> String { - if let Some(s) = content.as_str() { - return s.to_string(); - } - if let Some(arr) = content.as_array() { - return arr - .iter() - .filter(|b| b.get("type").and_then(|t| t.as_str()) == Some("text")) - .filter_map(|b| b.get("text").and_then(|t| t.as_str())) - .collect::>() - .join(" "); - } - String::new() -} - -fn command_label(raw: &str) -> String { - let name = raw - .split_once("") - .and_then(|(_, r)| r.split_once("")) - .map(|(n, _)| n.trim().to_string()) - .unwrap_or_default(); - if name.is_empty() { - return String::new(); - } - let args = raw - .split_once("") - .and_then(|(_, r)| r.split_once("")) - .map(|(a, _)| a.trim().to_string()) - .unwrap_or_default(); - format!("{} {}", name, args).trim().to_string() -} - -/// First human prose turn (skips slash-command XML / meta / interrupt notices), capped at 90 chars. -pub(crate) fn first_user_text(messages: &[Value]) -> String { - let mut fallback_cmd = String::new(); - for m in messages { - if m.get("role").and_then(|r| r.as_str()) != Some("user") { - continue; - } - if m.get("_meta").and_then(|v| v.as_bool()).unwrap_or(false) { - continue; - } - let content = m.get("content").cloned().unwrap_or(Value::Null); - let raw = content_text(&content); - let raw = raw.trim(); - if raw.is_empty() { - continue; - } - if raw.starts_with('<') { - if fallback_cmd.is_empty() { - fallback_cmd = command_label(raw); - } - continue; - } - let t: String = raw.split_whitespace().collect::>().join(" "); - if t.starts_with("[Request interrupted") || t.starts_with("Caveat:") { - continue; - } - return t.chars().take(90).collect(); - } - fallback_cmd.chars().take(90).collect() -} - -/// __ccbud__ customization (custom title + tags + soft-delete flag) from any record carrying it. -pub(crate) fn read_ccbud(recs: &[Value]) -> (Option, Vec, bool) { - let c = recs.iter().find_map(|r| r.get("__ccbud__")); - let title = c - .and_then(|c| c.get("title")) - .and_then(|t| t.as_str()) - .map(|s| s.trim().to_string()) - .filter(|s| !s.is_empty()); - let tags = c - .and_then(|c| c.get("tagList")) - .and_then(|t| t.as_array()) - .map(|arr| { - arr.iter() - .filter_map(|t| t.as_str()) - .map(|s| s.trim().to_string()) - .filter(|s| !s.is_empty()) - .collect() - }) - .unwrap_or_default(); - let deleted = c.and_then(|c| c.get("delete")).and_then(|v| v.as_bool()).unwrap_or(false); - (title, tags, deleted) -} - -/// The foreign-CLI session sources routed by CONTAINER SHAPE (their path layouts are -/// distinctive per tool, and one of them isn't even jsonl) — content sniffing stays reserved -/// for the historical Claude-vs-Codex jsonl split. -#[derive(Clone, Copy, PartialEq)] -pub(crate) enum Foreign { - Grok, - Copilot, - Antigravity, -} - -pub(crate) fn foreign_kind(file: &Path) -> Option { - if crate::grok::looks_grok_path(file) { - return Some(Foreign::Grok); - } - if crate::copilot::looks_copilot_path(file) { - return Some(Foreign::Copilot); - } - if crate::antigravity::looks_agy_path(file) { - return Some(Foreign::Antigravity); - } - None -} - -/// Cached soft-delete verdict for one file: a Claude session's flag (rides its first line, so it's -/// final for a given mtime), or "this belongs to another CLI" (Codex rollout / Qoder session / -/// foreign source, whose flag lives in a sidecar and can flip WITHOUT touching the file — so only -/// the format verdict is cached, never the flag). -#[derive(Clone, Copy)] -enum DelKind { - Claude(bool), - Codex, - Qoder, - Foreign(Foreign), -} - -/// Process-lifetime memo of soft-delete status, keyed `path -> (mtime, kind)`. mtime is the -/// invalidation signal: set_ccbud rewrites a Claude file (bumping mtime) whenever the flag flips, -/// so a matching mtime means the cached answer is still valid. This lets dir_stats *stat* -/// unchanged sessions on each refresh instead of re-reading them. -fn deleted_cache() -> &'static std::sync::Mutex> { - static CACHE: std::sync::OnceLock>> = - std::sync::OnceLock::new(); - CACHE.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new())) -} - -/// Cheap soft-delete probe for counting, memoized by mtime. A Claude session's `__ccbud__.delete` -/// rides on the first parseable line, so a small head read suffices; a Codex rollout's flag is -/// re-read from the sidecar every time (itself mtime-cached and cheap). -fn is_session_deleted(file: &Path) -> bool { - let mt = mtime_ms(file); - let cached: Option = deleted_cache() - .lock() - .ok() - .and_then(|c| c.get(file).filter(|(cmt, _)| *cmt == mt).map(|(_, k)| *k)); - let kind = cached.unwrap_or_else(|| { - // Foreign sources are recognized by path shape alone — no read needed. Qoder is - // Claude-FORMAT but another tool's file, so its flag lives in the sidecar too. - let kind = if let Some(fk) = foreign_kind(file) { - DelKind::Foreign(fk) - } else if crate::qoder::looks_qoder_path(file) { - DelKind::Qoder - } else { - // Read the same window session_meta uses: a Codex rollout's first (session_meta) line - // embeds the full system prompt (~22 KB), so a smaller head truncates it, parse yields - // nothing, and the session mis-sniffs as Claude — desyncing dir vs trash counts. - let recs = parse_lines(&read_head(file, 131072)); - // Imported codex COPIES carry the flag in-file like Claude sessions (see set_ccbud) — - // only live rollouts (no .import.json) use the sidecar. - if crate::codex::looks_codex(&recs) && read_import_meta(&file.to_string_lossy()).is_none() { - DelKind::Codex - } else { - DelKind::Claude(read_ccbud(&recs).2) - } - }; - if let Ok(mut cache) = deleted_cache().lock() { - cache.insert(file.to_path_buf(), (mt, kind)); - } - kind - }); - match kind { - DelKind::Claude(del) => del, - DelKind::Codex => crate::codex::is_deleted(file), - DelKind::Qoder => crate::qoder::is_deleted(file), - DelKind::Foreign(Foreign::Grok) => crate::grok::is_deleted(file), - DelKind::Foreign(Foreign::Copilot) => crate::copilot::is_deleted(file), - DelKind::Foreign(Foreign::Antigravity) => crate::antigravity::is_deleted(file), - } -} - -fn line_to_message(rec: &Value) -> Option { - let t = rec.get("type").and_then(|v| v.as_str())?; - if t != "user" && t != "assistant" { - return None; - } - let m = rec.get("message")?; - let role = m.get("role").and_then(|v| v.as_str())?; - let mut out = json!({ - "role": role, - "content": m.get("content").cloned().unwrap_or(Value::Null), - "_ts": rec.get("timestamp").cloned().unwrap_or(Value::Null), - "_sidechain": rec.get("isSidechain").and_then(|v| v.as_bool()).unwrap_or(false), - "_meta": rec.get("isMeta").and_then(|v| v.as_bool()).unwrap_or(false), - }); - if t == "assistant" { - let o = out.as_object_mut().unwrap(); - o.insert("_model".into(), m.get("model").cloned().unwrap_or(Value::Null)); - o.insert("_usage".into(), m.get("usage").map(usage_of).unwrap_or(Value::Null)); - o.insert("_stopReason".into(), m.get("stop_reason").cloned().unwrap_or(Value::Null)); - } - Some(out) -} - -struct Shaped { - messages: Vec, - totals: Value, - model: Option, - first_ts: Option, - last_ts: Option, -} - -/// The renderer's normalized session shape shared by every non-Claude source (Codex, Grok, -/// Copilot, Antigravity): Anthropic-style messages (`role` + content blocks of -/// text/thinking/tool_use/tool_result) plus the session-level facts each format can recover. -pub struct Norm { - pub messages: Vec, - pub totals: Value, - pub model: Option, - pub first_ts: Option, - pub last_ts: Option, - pub cwd: Option, - pub session_id: Option, - pub thread_id: Option, - pub parent_thread_id: Option, - pub forked_from_id: Option, - pub is_subagent: bool, - pub agent_path: Option, - pub agent_nickname: Option, - pub agent_role: Option, - pub agent_depth: Option, - pub git_branch: Option, - pub version: Option, -} - -impl Default for Norm { - fn default() -> Self { - Norm { - messages: vec![], - totals: json!({ "in": 0, "out": 0, "cacheRead": 0, "cacheCreation": 0, "turns": 0 }), - model: None, - first_ts: None, - last_ts: None, - cwd: None, - session_id: None, - thread_id: None, - parent_thread_id: None, - forked_from_id: None, - is_subagent: false, - agent_path: None, - agent_nickname: None, - agent_role: None, - agent_depth: None, - git_branch: None, - version: None, - } - } -} - -/// data-URL image → Claude-style image source block, else None. -pub(crate) fn image_block(url: &str) -> Option { - let rest = url.strip_prefix("data:")?; - let (mime, b64) = rest.split_once(";base64,")?; - Some(json!({ "type": "image", "source": { "type": "base64", "media_type": mime, "data": b64 } })) -} - -fn shape_messages(recs: &[Value]) -> Shaped { - let mut messages = vec![]; - let (mut tin, mut tout, mut tcr, mut tcc, mut turns) = (0i64, 0i64, 0i64, 0i64, 0i64); - let mut credits = 0.0f64; - let mut has_credits = false; - let mut model: Option = None; - let mut first_ts: Option = None; - let mut last_ts: Option = None; - for r in recs { - let lm = match line_to_message(r) { - Some(m) => m, - None => continue, - }; - if lm.get("_meta").and_then(|v| v.as_bool()).unwrap_or(false) { - continue; - } - let ts = lm.get("_ts").and_then(|v| v.as_str()).map(|s| s.to_string()); - if let Some(t) = &ts { - if first_ts.is_none() { - first_ts = Some(t.clone()); - } - last_ts = Some(t.clone()); - } - let mut msg = json!({ "role": lm.get("role").cloned().unwrap_or(Value::Null), "content": lm.get("content").cloned().unwrap_or(Value::Null) }); - let mo = msg.as_object_mut().unwrap(); - if lm.get("_sidechain").and_then(|v| v.as_bool()).unwrap_or(false) { - mo.insert("isSidechain".into(), json!(true)); - } - if let Some(t) = &ts { - mo.insert("ts".into(), json!(t)); - } - if r.get("type").and_then(|v| v.as_str()) == Some("assistant") { - if let Some(md) = lm.get("_model").and_then(|v| v.as_str()) { - mo.insert("modelActual".into(), json!(md)); - model = Some(md.to_string()); - } - let u = lm.get("_usage").cloned().unwrap_or(Value::Null); - if u.is_object() { - mo.insert("usage".into(), u.clone()); - tin += u.get("inputTokens").and_then(|v| v.as_i64()).unwrap_or(0); - tout += u.get("outputTokens").and_then(|v| v.as_i64()).unwrap_or(0); - tcr += u.get("cacheRead").and_then(|v| v.as_i64()).unwrap_or(0); - tcc += u.get("cacheCreation").and_then(|v| v.as_i64()).unwrap_or(0); - if let Some(value) = u.get("credits").and_then(|v| v.as_f64()) { - credits += value; - has_credits = true; - } - turns += 1; - } - if let Some(sr) = lm.get("_stopReason").and_then(|v| v.as_str()) { - mo.insert("stopReason".into(), json!(sr)); - } - } - messages.push(msg); - } - let mut totals = json!({ "in": tin, "out": tout, "cacheRead": tcr, "cacheCreation": tcc, "turns": turns }); - if has_credits { - let totals = totals.as_object_mut().unwrap(); - totals.insert("credits".into(), json!(credits)); - // Qoder's source log may omit usable token accounting while still providing real credits. - // Flag that state so the UI does not misrepresent unavailable token counts as zero usage. - if tin == 0 && tout == 0 && tcr == 0 && tcc == 0 { - totals.insert("tokenUsageAvailable".into(), json!(false)); - } - } - Shaped { - messages, - totals, - model, - first_ts, - last_ts, - } -} - -fn mtime_ms(file: &Path) -> f64 { - fs::metadata(file) - .and_then(|m| m.modified()) - .ok() - .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) - .map(|d| d.as_millis() as f64) - .unwrap_or(0.0) -} - -/// File creation (birth) time in ms; mtime on filesystems that don't record one. NOT stable -/// across a title/tag edit — set_ccbud rewrites via tmp+rename, which gives the path the tmp -/// file's (fresh) birth time — so this is only the FALLBACK sort key when a session's records -/// carry no timestamp; record_created_ms is the real one. -pub(crate) fn created_ms(file: &Path) -> f64 { - fs::metadata(file) - .and_then(|m| m.created()) - .ok() - .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) - .map(|d| d.as_millis() as f64) - .filter(|v| *v > 0.0) - .unwrap_or_else(|| mtime_ms(file)) -} - -/// Session creation time for ORDERING: the first record's timestamp, i.e. content-derived and -/// therefore immune to file rewrites — renaming/tagging a conversation (tmp+rename resets the -/// fs birth time) must never reshuffle the list. Falls back to fs times when no record carries -/// a timestamp. Claude records and Codex rollout lines both put `timestamp` at the top level. -pub(crate) fn record_created_ms(recs: &[Value], file: &Path) -> f64 { - for r in recs { - if let Some(ts) = r.get("timestamp").and_then(|v| v.as_str()) { - if let Ok(d) = chrono::DateTime::parse_from_rfc3339(ts) { - return d.timestamp_millis() as f64; - } - } - } - created_ms(file) -} - -fn imports_root() -> PathBuf { - crate::store::ccbud_home().join("imports") -} -/// Configured dirs + the synthetic imported-transcripts store (id `__imported__`). -fn all_dirs(config: &Value) -> Vec<(String, String, PathBuf)> { - let mut dirs = config_dirs(config); - dirs.push(("__imported__".to_string(), "导入".to_string(), imports_root().join("projects"))); - dirs -} -/// A sibling data tree next to a dir entry's `projects/`. Every configured dir is probed for -/// EVERY layout (Claude Code AND Qoder write `/projects/…`, Codex and Grok -/// `/sessions/…`, Copilot `/session-state/…`, Antigravity `/conversations/*.db`), -/// so `~/.codex`, `~/.grok`, `~/.copilot`, `~/.gemini/antigravity-cli`, `~/.qoder` are just -/// configured dirs rather than special cases. -fn sibling_dir(projects_dir: &Path, name: &str) -> Option { - projects_dir.parent().map(|b| b.join(name)) -} - -fn sessions_dir(projects_dir: &Path) -> Option { - sibling_dir(projects_dir, "sessions") -} - -/// Dirs to watch for live history changes — each work dir's data trees (all four layouts). -pub fn watch_roots(config: &Value) -> Vec { - let mut roots: Vec = vec![]; - for (_, _, pd) in all_dirs(config) { - for name in ["sessions", "session-state", "conversations"] { - if let Some(sd) = sibling_dir(&pd, name) { - roots.push(sd); - } - } - roots.push(pd); - } - roots -} - -/// Walk every session .jsonl across the configured dirs (+ imports), invoking -/// `cb(file, dir_name, dir_id, dir_label)` — both the Claude projects/ tree and the -/// Codex sessions/ tree of each dir. -fn each_session_file(config: &Value, mut cb: F) { - for (id, label, root) in all_dirs(config) { - if let Ok(entries) = fs::read_dir(&root) { - for ent in entries.flatten() { - if !ent.path().is_dir() { - continue; - } - let dir_name = ent.file_name().to_string_lossy().into_owned(); - let pfiles = match fs::read_dir(ent.path()) { - Ok(f) => f, - Err(_) => continue, - }; - for f in pfiles.flatten() { - let p = f.path(); - if p.is_file() && p.extension().and_then(|e| e.to_str()) == Some("jsonl") { - cb(p, dir_name.clone(), &id, &label); - } - } - } - } - // Codex rollouts live in a date-sharded sessions/ tree; Grok shares the same sessions/ - // root but keys children by percent-encoded cwd (and stuffs sidecar jsonl — events/ - // updates/rewind — beside each chat_history.jsonl), so children are routed one by one - // rather than letting the codex walker sweep grok trees into garbage rows. - if let Some(sd) = sessions_dir(&root) { - if let Ok(children) = fs::read_dir(&sd) { - for ent in children.flatten() { - let p = ent.path(); - let name = ent.file_name().to_string_lossy().into_owned(); - if p.is_dir() && crate::grok::is_cwd_dir_name(&name) { - crate::grok::walk_cwd_dir(&p, &mut |f| cb(f, String::new(), &id, &label)); - } else if p.is_dir() { - crate::codex::walk_sessions(&p, |f| cb(f, String::new(), &id, &label)); - } else if p.is_file() && p.extension().and_then(|e| e.to_str()) == Some("jsonl") { - cb(p, String::new(), &id, &label); - } - } - } - } - // Copilot session logs (flat .jsonl + /events.jsonl). - if let Some(ss) = sibling_dir(&root, "session-state") { - crate::copilot::walk(&ss, &mut |f| cb(f, String::new(), &id, &label)); - } - // Antigravity conversations (one SQLite per session). - if let Some(cd) = sibling_dir(&root, "conversations") { - crate::antigravity::walk(&cd, &mut |f| cb(f, String::new(), &id, &label)); - } - } -} - -/// Mtime+size-keyed memo of session_meta list rows (mirrors the JS metaCache). List refreshes -/// fire on every watched write during a live session and previously re-read every candidate's -/// file head each time — with the memo, unchanged sessions cost a stat. Pruned in list_sessions -/// against the live file set; a live Codex rollout's sidecar edit (which does NOT touch the -/// file) is invalidated explicitly by set_ccbud. -fn meta_cache() -> &'static std::sync::Mutex> { - static CACHE: std::sync::OnceLock>> = - std::sync::OnceLock::new(); - CACHE.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new())) -} - -/// Freshness stamp for the list-meta / search caches: plain mtime, except Antigravity DBs -/// where a live agy writes into the WAL without touching the main file. -fn cache_stamp_ms(file: &Path) -> f64 { - match foreign_kind(file) { - Some(Foreign::Antigravity) => crate::antigravity::wal_mtime_ms(file), - _ => mtime_ms(file), - } -} - -fn session_meta(file: &Path, dir_name: &str, dir_id: &str, dir_label: &str) -> Option { - let (mt, size) = (cache_stamp_ms(file), fs::metadata(file).ok()?.len()); - if let Ok(cache) = meta_cache().lock() { - if let Some((cmt, csz, v)) = cache.get(file) { - if *cmt == mt && *csz == size { - return Some(v.clone()); - } - } - } - let built = build_session_meta(file, dir_name, dir_id, dir_label)?; - // A permission failure is recoverable without changing the transcript's mtime/size (for - // example after the user grants macOS App Data access) — never memoize that placeholder row, - // otherwise it would stay "(conversation)" until the process restarts. Every OTHER read - // error is memoized like a normal row: the mtime/size key already invalidates it when the - // file changes, and skipping the memo would re-read a broken transcript on every refresh. - let awaiting_grant = built - .get("readError") - .and_then(|e| e.get("kind")) - .and_then(|k| k.as_str()) - == Some("permissionDenied"); - if !awaiting_grant { - if let Ok(mut cache) = meta_cache().lock() { - cache.insert(file.to_path_buf(), (mt, size, built.clone())); - } - } - Some(built) -} - -fn build_session_meta(file: &Path, dir_name: &str, dir_id: &str, dir_label: &str) -> Option { - // Foreign sources first — routed by container shape BEFORE any content read (one of them - // isn't even text), each through its own shaper. - match foreign_kind(file) { - Some(Foreign::Grok) => return crate::grok::session_meta_from(file, dir_id, dir_label), - Some(Foreign::Copilot) => { - let recs = parse_lines(&read_head(file, 131072)); - return crate::copilot::session_meta_from(file, &recs, dir_id, dir_label); - } - Some(Foreign::Antigravity) => return crate::antigravity::session_meta_from(file, dir_id, dir_label), - None => {} - } - let meta = fs::metadata(file).ok()?; - let size = meta.len(); - let qoder = crate::qoder::looks_qoder_path(file); - // Qoder stores title/workspace/runtime records throughout the transcript, and its first JSON - // line can itself exceed the ordinary 128 KiB list window. Read the full file once (the row - // cache makes this a one-time cost per mtime/size); other formats retain the bounded head. - let raw = if qoder { - crate::qoder::read_text(file) - } else { - read_head_result(file, 131072) - }; - let (parsed_recs, read_error) = match raw { - Ok(raw) => (parse_lines(&raw), Value::Null), - Err(error) => ( - vec![], - session_read_error(file, &error) - .get("error") - .cloned() - .unwrap_or(Value::Null), - ), - }; - let qoder_title = if qoder { - crate::qoder::session_title_from(&parsed_recs) - } else { - None - }; - let qoder_cwd = if qoder { - crate::qoder::working_dir_from(&parsed_recs) - } else { - None - }; - let qoder_model = if qoder { - crate::qoder::model_from(&parsed_recs) - } else { - None - }; - let recs = if qoder { - crate::qoder::normalize_records(&parsed_recs) - } else { - parsed_recs - }; - // Codex rollouts (a dir's sessions/ tree, or snapshots imported into the app store) list - // through the codex shaper — the record format shares nothing with Claude's. - if crate::codex::looks_codex(&recs) { - return crate::codex::session_meta_from(file, &recs, dir_id, dir_label); - } - // Qoder sessions use Claude-like user/assistant envelopes plus inline title/workspace/runtime - // records and atomic assistant content wrappers. normalize_records makes the message stream - // Claude-shaped; Qoder-specific metadata remains app-sidecar + inline JSONL data. - let meta_rec = recs - .iter() - .find(|r| r.get("cwd").is_some()) - .or_else(|| recs.iter().find(|r| r.get("sessionId").is_some())); - let agent_rec = recs.iter().find(|r| r.get("agentId").is_some()); - let msgs: Vec = recs.iter().filter_map(line_to_message).collect(); - let (cc_title, cc_tags, cc_deleted) = - if qoder { crate::qoder::sidecar_meta(file) } else { read_ccbud(&recs) }; - let auto_title = qoder_title.unwrap_or_else(|| first_user_text(&msgs)); - let mut model: Option = None; - for r in &recs { - if r.get("type").and_then(|v| v.as_str()) == Some("assistant") { - if let Some(md) = r.get("message").and_then(|m| m.get("model")).and_then(|v| v.as_str()) { - model = Some(md.to_string()); - } - } - } - if qoder_model.is_some() { - model = qoder_model; - } - let subagent = agent_rec.is_some(); - let top_level_cwd = meta_rec - .and_then(|r| r.get("cwd")) - .and_then(|v| v.as_str()) - .map(|s| s.to_string()); - let cwd = (if qoder { - qoder_cwd.or(top_level_cwd) - } else { - top_level_cwd - }) - .or_else(|| decode_dir_name(dir_name)); - let stem = file.file_stem().and_then(|s| s.to_str()).unwrap_or("").to_string(); - let mt = mtime_ms(file); - Some(json!({ - "id": if qoder { format!("qoder:{}", stem) } else { format!("disk:{}{}", stem, if subagent { ":sub" } else { "" }) }, - "file": file.to_string_lossy(), - "source": if qoder { "qoder" } else { "disk" }, - "dirId": dir_id, - "dirLabel": dir_label, - "sessionId": meta_rec.and_then(|r| r.get("sessionId")).and_then(|v| v.as_str()).unwrap_or(&stem), - "cwd": cwd.clone(), - "project": cwd.as_deref().map(base_name).unwrap_or_default(), - "gitBranch": meta_rec.and_then(|r| r.get("gitBranch")).cloned().unwrap_or(Value::Null), - "title": cc_title.clone().unwrap_or_else(|| auto_title.clone()), - "autoTitle": auto_title, - "tags": cc_tags, - "model": model, - "isSubagent": subagent, - "imported": dir_id == "__imported__", - "deleted": cc_deleted, - "readError": read_error, - "createdAt": record_created_ms(&recs, file), - "lastActivity": mt, - "sizeKB": (size as f64 / 1024.0).round() as i64, - })) -} - -fn canonical_codex_key(session: &Value) -> Option { - if session.get("source").and_then(Value::as_str) != Some("codex") { - return None; - } - if session.get("canonicalThreadIdValid").and_then(Value::as_bool) != Some(true) { - return None; - } - let thread_id = session.get("threadId").and_then(Value::as_str)?; - let dir_id = session.get("dirId").and_then(Value::as_str).unwrap_or(""); - Some(format!("{dir_id}\0{thread_id}")) -} - -fn codex_canonical_filename(session: &Value) -> bool { - let Some(thread_id) = session.get("threadId").and_then(Value::as_str) else { - return false; - }; - let Some(file) = session.get("file").and_then(Value::as_str) else { - return false; - }; - let stem = Path::new(file).file_stem().and_then(|value| value.to_str()).unwrap_or(""); - stem == thread_id || stem.strip_suffix(thread_id).is_some_and(|prefix| prefix.ends_with('-')) -} - -fn codex_candidate_preferred(candidate: &Value, current: &Value) -> bool { - let candidate_file = candidate.get("file").and_then(Value::as_str).unwrap_or(""); - let current_file = current.get("file").and_then(Value::as_str).unwrap_or(""); - let thread_id = candidate - .get("threadId") - .and_then(Value::as_str) - .or_else(|| current.get("threadId").and_then(Value::as_str)) - .unwrap_or(""); - - // Codex's completed state DB is authoritative when its rollout_path still exists. Both - // candidates already passed ccbud's first-SessionMeta parse, so a matching path also verifies - // that the DB row belongs to this canonical id. - let preferred_path = [candidate_file, current_file] - .into_iter() - .filter(|file| !file.is_empty()) - .find_map(|file| crate::codex::preferred_rollout_path(Path::new(file), thread_id)); - if let Some(preferred) = preferred_path { - let candidate_matches = Path::new(candidate_file) == preferred.as_path(); - let current_matches = Path::new(current_file) == preferred.as_path(); - if candidate_matches != current_matches { - return candidate_matches; - } - } - - let imported = |value: &Value| value.get("imported").and_then(Value::as_bool).unwrap_or(false); - if imported(candidate) != imported(current) { - return !imported(candidate); - } - let archived = |file: &str| { - Path::new(file) - .components() - .any(|part| part.as_os_str().to_str() == Some("archived_sessions")) - }; - if archived(candidate_file) != archived(current_file) { - return !archived(candidate_file); - } - let number = |value: &Value, field: &str| value.get(field).and_then(Value::as_f64).unwrap_or(0.0); - for field in ["lastActivity", "createdAt"] { - let candidate_value = number(candidate, field); - let current_value = number(current, field); - if candidate_value != current_value { - return candidate_value > current_value; - } - } - if codex_canonical_filename(candidate) != codex_canonical_filename(current) { - return codex_canonical_filename(candidate); - } - let candidate_size = number(candidate, "sizeKB"); - let current_size = number(current, "sizeKB"); - if candidate_size != current_size { - return candidate_size > current_size; - } - candidate_file > current_file -} - -fn dedupe_canonical_codex_sessions(sessions: Vec) -> Vec { - let mut out = Vec::with_capacity(sessions.len()); - let mut positions = std::collections::HashMap::::new(); - for session in sessions { - let Some(key) = canonical_codex_key(&session) else { - out.push(session); - continue; - }; - if let Some(index) = positions.get(&key).copied() { - if codex_candidate_preferred(&session, &out[index]) { - out[index] = session; - } - } else { - positions.insert(key, out.len()); - out.push(session); - } - } - out -} - -fn limit_with_codex_ancestors(sessions: Vec, limit: usize) -> Vec { - if sessions.len() <= limit { - return sessions; - } - let mut positions = std::collections::HashMap::::new(); - for (index, session) in sessions.iter().enumerate() { - if let Some(key) = canonical_codex_key(session) { - positions.insert(key, index); - } - } - let mut included: std::collections::HashSet = (0..limit).collect(); - let mut queue: Vec = (0..limit).collect(); - let mut cursor = 0usize; - while cursor < queue.len() { - let index = queue[cursor]; - cursor += 1; - let session = &sessions[index]; - if canonical_codex_key(session).is_none() { - continue; - } - let dir_id = session.get("dirId").and_then(Value::as_str).unwrap_or(""); - let direct_parent = session.get("parentThreadId").and_then(Value::as_str); - let root_parent = session - .get("isSubagent") - .and_then(Value::as_bool) - .unwrap_or(false) - .then(|| session.get("rootSessionId").and_then(Value::as_str)) - .flatten(); - let parent_index = [direct_parent, root_parent] - .into_iter() - .flatten() - .find_map(|parent_id| positions.get(&format!("{dir_id}\0{parent_id}")).copied()); - let Some(parent_index) = parent_index else { - continue; - }; - if included.insert(parent_index) { - queue.push(parent_index); - } - } - sessions - .into_iter() - .enumerate() - .filter_map(|(index, session)| included.contains(&index).then_some(session)) - .collect() -} - -pub fn list_sessions(config: &Value, active: &str, limit: usize) -> Vec { - // The recycle bin spans every dir and shows only soft-deleted sessions; every other view - // is scoped to its dir and hides them. - let trash = active == TRASH_ID; - // Read (memoized) metas for EVERY candidate, then dedupe/order before the limit cut. Most - // formats use content-derived CreatedAt so title/tag rewrites cannot reshuffle rows; Codex - // uses rollout UpdatedAt because its custom metadata is sidecar-only and Codex defines latest - // that way. The meta cache turns the full walk into stats for unchanged files. - let mut live: std::collections::HashSet = std::collections::HashSet::new(); - let mut candidates: Vec<(PathBuf, String, String, String)> = Vec::new(); - each_session_file(config, |file, dir_name, id, label| { - live.insert(file.clone()); - if !trash && active != "all" && id != active { - return; - } - candidates.push((file, dir_name, id.to_string(), label.to_string())); - }); - // Warm the qoder helper cache in ONE batch before the per-row reads — on a macOS install with - // protected app data, every stale row would otherwise spawn its own helper process. - let qoder_files: Vec = candidates - .iter() - .map(|(file, _, _, _)| file.clone()) - .filter(|file| crate::qoder::looks_qoder_path(file)) - .collect(); - crate::qoder::prefetch(&qoder_files); - let mut out: Vec = Vec::new(); - for (file, dir_name, id, label) in &candidates { - if let Some(m) = session_meta(file, dir_name, id, label) { - out.push(m); - } - } - // Drop memo entries for files that no longer exist, so removed dirs don't pin stale rows. - if let Ok(mut cache) = meta_cache().lock() { - cache.retain(|k, _| live.contains(k)); - } - // Collapse only true physical duplicates (same dir + canonical thread id) BEFORE limit. - // Threads that merely share rootSessionId are distinct root/subagent nodes and remain intact. - let mut out = dedupe_canonical_codex_sessions(out); - // Apply recycle-bin visibility to the selected logical representative, not to each physical - // candidate. Otherwise deleting the authoritative copy could make a stale duplicate reappear - // in the normal list while the same logical thread also sits in the recycle bin. - out.retain(|session| { - session.get("deleted").and_then(Value::as_bool).unwrap_or(false) == trash - }); - let key = |v: &Value| { - // Codex defines "latest" as UpdatedAt (rollout mtime); its title/tags live in a sidecar, - // so this timestamp is not dirtied by ccbud edits. Keep the stable CreatedAt policy for - // formats whose transcript itself is rewritten when metadata changes. - let field = if v.get("source").and_then(Value::as_str) == Some("codex") { - "lastActivity" - } else { - "createdAt" - }; - v.get(field).and_then(Value::as_f64).unwrap_or(0.0) - }; - out.sort_by(|a, b| { - key(b) - .partial_cmp(&key(a)) - .unwrap_or(std::cmp::Ordering::Equal) - .then_with(|| { - let updated = |value: &Value| { - value.get("lastActivity").and_then(Value::as_f64).unwrap_or(0.0) - }; - updated(b) - .partial_cmp(&updated(a)) - .unwrap_or(std::cmp::Ordering::Equal) - }) - .then_with(|| { - let a_id = a - .get("threadId") - .or_else(|| a.get("id")) - .and_then(Value::as_str) - .unwrap_or(""); - let b_id = b - .get("threadId") - .or_else(|| b.get("id")) - .and_then(Value::as_str) - .unwrap_or(""); - b_id.cmp(a_id) - }) - }); - // Soft-cap Codex trees: include the parent/root chain of every selected child so a busy tree - // cannot show orphan subagents merely because its older root fell just below the limit. - limit_with_codex_ancestors(out, limit) -} - -pub fn list_projects(config: &Value, active: &str) -> Vec { - let sessions = list_sessions(config, active, 600); - let mut order: Vec = vec![]; - let mut groups: std::collections::HashMap = std::collections::HashMap::new(); - for s in sessions { - let cwd = s.get("cwd").and_then(|v| v.as_str()).unwrap_or("(unknown)").to_string(); - let la = s.get("lastActivity").and_then(|v| v.as_f64()).unwrap_or(0.0); - let ct = s.get("createdAt").and_then(|v| v.as_f64()).unwrap_or(la); - let sk = if s.get("source").and_then(Value::as_str) == Some("codex") { la } else { ct }; - let g = groups.entry(cwd.clone()).or_insert_with(|| { - order.push(cwd.clone()); - json!({ "cwd": s.get("cwd").cloned().unwrap_or(Value::Null), "name": s.get("project").cloned().unwrap_or(Value::Null), "sessions": [], "lastActivity": 0.0, "createdAt": 0.0, "sortActivity": 0.0 }) - }); - g["sessions"].as_array_mut().unwrap().push(s.clone()); - if la > g["lastActivity"].as_f64().unwrap_or(0.0) { - g["lastActivity"] = json!(la); - } - if ct > g["createdAt"].as_f64().unwrap_or(0.0) { - g["createdAt"] = json!(ct); - } - if sk > g["sortActivity"].as_f64().unwrap_or(0.0) { - g["sortActivity"] = json!(sk); - } - } - // Codex's latest semantic is rollout UpdatedAt; other formats retain CreatedAt so in-file - // title/tag edits cannot reorder them. Apply the same source-aware rule to rows and projects. - let sort_key = |v: &Value| { - let field = if v.get("source").and_then(Value::as_str) == Some("codex") { - "lastActivity" - } else { - "createdAt" - }; - v.get(field).and_then(Value::as_f64).unwrap_or(0.0) - }; - let mut arr: Vec = order.into_iter().filter_map(|k| groups.remove(&k)).collect(); - for g in &mut arr { - g["sessions"].as_array_mut().unwrap().sort_by(|a, b| { - sort_key(b).partial_cmp(&sort_key(a)).unwrap_or(std::cmp::Ordering::Equal) - }); - } - arr.sort_by(|a, b| { - let key = |value: &Value| { - value.get("sortActivity").and_then(Value::as_f64).unwrap_or(0.0) - }; - key(b).partial_cmp(&key(a)).unwrap_or(std::cmp::Ordering::Equal) - }); - for group in &mut arr { - if let Some(object) = group.as_object_mut() { - object.remove("sortActivity"); - } - } - arr -} - -pub fn dir_stats(config: &Value) -> Vec { - // Per-dir counts exclude soft-deleted sessions (they're hidden from those views); the deleted - // ones are tallied separately into the synthetic recycle-bin bucket. Reuse list_sessions so - // counts reflect canonical logical rows rather than duplicate physical rollout files. - let mut counts: std::collections::HashMap = std::collections::HashMap::new(); - for session in list_sessions(config, "all", usize::MAX) { - let id = session.get("dirId").and_then(Value::as_str).unwrap_or(""); - *counts.entry(id.to_string()).or_insert(0) += 1; - } - let trash = list_sessions(config, TRASH_ID, usize::MAX).len() as i64; - let mut out: Vec = all_dirs(config) - .into_iter() - .map(|(id, label, pd)| { - // A dir "exists" when ANY data tree is on disk — ~/.codex has only sessions/, - // ~/.copilot only session-state/, ~/.gemini/antigravity-cli only conversations/. - let exists = pd.is_dir() - || ["sessions", "session-state", "conversations"] - .iter() - .any(|n| sibling_dir(&pd, n).map(|s| s.is_dir()).unwrap_or(false)); - let imported = id == "__imported__"; - json!({ - "id": id.clone(), "label": label, "projectsDir": pd.to_string_lossy(), - "sessions": counts.get(&id).copied().unwrap_or(0), "exists": exists, "imported": imported, - }) - }) - .collect(); - out.push(json!({ - "id": TRASH_ID, "label": "回收站", "projectsDir": "", - "sessions": trash, "exists": true, "imported": false, "trash": true, - })); - out -} - -/// A skill-forked subagent transcript opens with a sentinel user line -/// "Base directory for this skill: /" — the last path segment names the skill. -/// Fallback attribution only: the spawning `Skill` tool_use in the parent thread -/// (apply_skill_names) is authoritative and overrides this when present. (history.js skillFromRecs) -const SKILL_BASE_DIR_PREFIX: &str = "Base directory for this skill: "; -pub(crate) fn skill_from_recs(recs: &[Value]) -> Option { - let first = recs.iter().find(|r| { - r.get("type").and_then(|v| v.as_str()) == Some("user") - && r.get("message").is_some() - && !r.get("isMeta").and_then(|v| v.as_bool()).unwrap_or(false) - })?; - let text = content_text(first.get("message")?.get("content").unwrap_or(&Value::Null)); - // Only the opening prompt carries the sentinel — don't scan further user turns. - let rest = text.trim().strip_prefix(SKILL_BASE_DIR_PREFIX)?; - let line = rest.lines().next().unwrap_or("").trim(); - line.split(['/', '\\']).filter(|s| !s.is_empty()).last().map(|s| s.to_string()) -} - -/// Primary skill attribution (history.js applySkillNames): a subagent spawned by the `Skill` tool -/// is named by the spawning tool_use's input.skill (matched by tool_use id — the subagents map -/// key), in whichever thread the call lives (main or a nested subagent). Overrides the sentinel -/// fallback from skill_from_recs. -pub(crate) fn apply_skill_names(main_messages: &[Value], subs: &mut serde_json::Map) { - if subs.is_empty() { - return; - } - fn scan(msgs: &[Value], subs: &serde_json::Map, out: &mut Vec<(String, String)>) { - for m in msgs { - let blocks = match m.get("content").and_then(|c| c.as_array()) { - Some(b) => b, - None => continue, - }; - for b in blocks { - if b.get("type").and_then(|v| v.as_str()) != Some("tool_use") - || b.get("name").and_then(|v| v.as_str()) != Some("Skill") - { - continue; - } - let id = match b.get("id").and_then(|v| v.as_str()) { - Some(i) if subs.contains_key(i) => i, - _ => continue, - }; - if let Some(s) = b.get("input").and_then(|i| i.get("skill")).and_then(|v| v.as_str()) { - let s = s.trim(); - if !s.is_empty() { - out.push((id.to_string(), s.to_string())); - } - } - } - } - } - let mut named: Vec<(String, String)> = vec![]; - scan(main_messages, subs, &mut named); - for (_, v) in subs.iter() { - if let Some(msgs) = v.get("messages").and_then(|m| m.as_array()) { - scan(msgs, subs, &mut named); - } - } - for (id, name) in named { - if let Some(o) = subs.get_mut(&id).and_then(|s| s.as_object_mut()) { - o.insert("skill".into(), json!(name)); - } - } -} - -/// Read a session's child subagent dialogues from `/subagents/agent-*.jsonl` (+ .meta.json), -/// keyed by the spawning tool_use id so the renderer can nest them. {} when none. (history.js readSubagents) -fn read_subagents(file: &str) -> serde_json::Map { - let p = Path::new(file); - let qoder = crate::qoder::looks_qoder_path(p); - let stem = p.file_stem().and_then(|s| s.to_str()).unwrap_or(""); - let dir = match p.parent() { - Some(d) => d.join(stem).join("subagents"), - None => return serde_json::Map::new(), - }; - let mut by_tool = serde_json::Map::new(); - let entries = match fs::read_dir(&dir) { - Ok(e) => e, - Err(_) => return by_tool, - }; - let mut agent_files: Vec<(String, PathBuf)> = vec![]; - for ent in entries.flatten() { - let name = ent.file_name().to_string_lossy().to_string(); - if name.starts_with("agent-") && name.ends_with(".jsonl") { - agent_files.push((name, ent.path())); - } - } - // A protected qoder session's subagent transcripts + meta sidecars warm in one helper batch - // instead of two spawns per agent. - if qoder { - let mut warm: Vec = vec![]; - for (name, path) in &agent_files { - warm.push(path.clone()); - let agent_id = name.trim_start_matches("agent-").trim_end_matches(".jsonl"); - warm.push(dir.join(format!("agent-{}.meta.json", agent_id))); - } - crate::qoder::prefetch(&warm); - } - for (name, transcript_path) in agent_files { - let agent_id = name - .trim_start_matches("agent-") - .trim_end_matches(".jsonl") - .to_string(); - let meta_path = dir.join(format!("agent-{}.meta.json", agent_id)); - let meta_raw = if qoder { - crate::qoder::read_text(&meta_path) - } else { - fs::read_to_string(&meta_path) - }; - let meta: Value = meta_raw - .ok() - .and_then(|s| serde_json::from_str(&s).ok()) - .unwrap_or_else(|| json!({})); - let raw = match read_session_text(&transcript_path) { - Ok(s) => s, - Err(_) => continue, - }; - let parsed = parse_lines(&raw); - let recs = if qoder { - crate::qoder::normalize_records(&parsed) - } else { - parsed - }; - let shaped = shape_messages(&recs); - let key = meta - .get("toolUseId") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()) - .unwrap_or_else(|| format!("agent:{}", agent_id)); - let agent_type = meta - .get("agentType") - .and_then(|v| v.as_str()) - .or_else(|| meta.get("subagent_type").and_then(|v| v.as_str())) - .unwrap_or("agent"); - by_tool.insert( - key, - json!({ - "agentId": agent_id, - "file": transcript_path.to_string_lossy(), - "type": agent_type, - "description": meta.get("description").and_then(|v| v.as_str()).unwrap_or(""), - "skill": skill_from_recs(&recs), - "count": shaped.messages.len(), - "totals": shaped.totals, - "messages": shaped.messages, - }), - ); - } - by_tool -} - -/// A session's subagents directory: `//subagents`. None when the path has no stem. -fn subagent_dir(file: &Path) -> Option { - let stem = file.file_stem().and_then(|s| s.to_str())?; - file.parent().map(|d| d.join(stem).join("subagents")) -} - -/// The raw subagent sidecar files for a session — `(agent-*.jsonl | agent-*.meta.json, bytes)`. -/// Empty when the session spawned no subagents. Shared by bundle export, import, and replay-merge. -fn read_subagent_files(file: &Path) -> Vec<(String, Vec)> { - let dir = match subagent_dir(file) { - Some(d) => d, - None => return vec![], - }; - let qoder = crate::qoder::looks_qoder_path(file); - let mut out = vec![]; - if let Ok(entries) = fs::read_dir(&dir) { - for ent in entries.flatten() { - let p = ent.path(); - if !p.is_file() { - continue; - } - let name = ent.file_name().to_string_lossy().into_owned(); - let lower = name.to_lowercase(); - if lower.starts_with("agent-") && (lower.ends_with(".jsonl") || lower.ends_with(".meta.json")) { - let bytes = if qoder { - crate::qoder::read_bytes(&p) - } else { - fs::read(&p) - }; - if let Ok(bytes) = bytes { - out.push((name, bytes)); - } - } - } - } - out.sort_by(|a, b| a.0.cmp(&b.0)); // deterministic bundle order - out -} - -/// Whether a session has any subagent transcripts (drives export → .zip vs plain .jsonl). -pub fn session_has_subagents(file: &str) -> bool { - !read_subagent_files(Path::new(file)).is_empty() -} - -/// Build a conversation-bundle ZIP: the main session `.jsonl` at the top level and each -/// subagent file under `subagents/`. Caller uses this only when the session actually has subagents -/// (a plain .jsonl export otherwise). Round-trips through import_zip / splitBundle. -pub fn export_bundle(file: &str) -> std::io::Result> { - let path = Path::new(file); - let main = read_session_bytes(path)?; - let main_name = path - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or("conversation.jsonl") - .to_string(); - let mut entries = vec![crate::ziputil::Entry { name: main_name, data: main }]; - for (name, bytes) in read_subagent_files(path) { - entries.push(crate::ziputil::Entry { name: format!("subagents/{}", name), data: bytes }); - } - Ok(crate::ziputil::build(&entries)) -} - -/// Absolute paths of a session's subagent transcripts (`/subagents/agent-*.jsonl`), sorted. -/// Empty when the session has no subagents. Powers "Claude 分析": every subagent transcript is -/// attached alongside the main session in the Cowork deep link (which takes a repeated `file=` param), -/// so the analysis covers subagent runs — not just the main thread. -pub fn subagent_transcript_paths(file: &str) -> Vec { - let dir = match subagent_dir(Path::new(file)) { - Some(d) => d, - None => return vec![], - }; - let mut out = vec![]; - if let Ok(entries) = fs::read_dir(&dir) { - for ent in entries.flatten() { - let p = ent.path(); - if !p.is_file() { - continue; - } - let name = ent.file_name().to_string_lossy().to_lowercase(); - if name.starts_with("agent-") && name.ends_with(".jsonl") { - out.push(p.to_string_lossy().into_owned()); - } - } - } - out.sort(); - out -} - -/// Read the import provenance sidecar (`.import.json`) for an imported transcript. -pub(crate) fn read_import_meta(file: &str) -> Option { - let p = Path::new(file); - let stem = p.file_stem().and_then(|s| s.to_str())?; - let dir = p.parent()?; - let raw = fs::read_to_string(dir.join(format!("{}.import.json", stem))).ok()?; - serde_json::from_str(&raw).ok() -} - -pub fn get_session(file: &str) -> Value { - let path = Path::new(file); - let qoder = crate::qoder::looks_qoder_path(path); - // Foreign sources route by container shape BEFORE the text read — Antigravity sessions are - // SQLite, and grok/copilot jsonl would otherwise fall through to the Claude shaper. - match foreign_kind(path) { - Some(Foreign::Antigravity) => return crate::antigravity::session_from(file), - Some(fk) => { - let raw = match read_session_text(path) { - Ok(s) => s, - Err(error) => return session_read_error(path, &error), - }; - let recs = parse_lines(&raw); - return match fk { - Foreign::Grok => crate::grok::session_from_recs(file, &recs), - _ => crate::copilot::session_from_recs(file, &recs), - }; - } - None => {} - } - let raw = match read_session_text(path) { - Ok(s) => s, - Err(error) => return session_read_error(path, &error), - }; - let parsed_recs = parse_lines(&raw); - if crate::codex::looks_codex(&parsed_recs) { - return crate::codex::session_from_recs(file, &parsed_recs); - } - let qoder_title = if qoder { - crate::qoder::session_title_from(&parsed_recs) - } else { - None - }; - let qoder_cwd = if qoder { - crate::qoder::working_dir_from(&parsed_recs) - } else { - None - }; - let qoder_model = if qoder { - crate::qoder::model_from(&parsed_recs) - } else { - None - }; - let recs = if qoder { - crate::qoder::normalize_records(&parsed_recs) - } else { - parsed_recs - }; - let meta_rec = recs - .iter() - .find(|r| r.get("cwd").is_some()) - .or_else(|| recs.iter().find(|r| r.get("sessionId").is_some())); - let agent_rec = recs.iter().find(|r| r.get("agentId").is_some()); - let agent_id = agent_rec - .and_then(|r| r.get("agentId")) - .and_then(|v| v.as_str()) - .map(|s| s.to_string()); - let summary = recs - .iter() - .find(|r| r.get("type").and_then(|v| v.as_str()) == Some("summary") && r.get("summary").is_some()) - .and_then(|r| r.get("summary").cloned()); - // Qoder detail mirrors build_session_meta: normalized atomic wrappers, inline metadata, and - // app-owned title/tags/delete overrides without rewriting another CLI's transcript. - let (cc_title, cc_tags, cc_deleted) = - if qoder { crate::qoder::sidecar_meta(path) } else { read_ccbud(&recs) }; - let shaped = shape_messages(&recs); - let auto_title = qoder_title.unwrap_or_else(|| first_user_text(&shaped.messages)); - let subagent = agent_rec.is_some(); - let top_level_cwd = meta_rec - .and_then(|r| r.get("cwd")) - .and_then(|v| v.as_str()) - .map(|s| s.to_string()); - let cwd = if qoder { - qoder_cwd.or(top_level_cwd) - } else { - top_level_cwd - }; - let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("").to_string(); - let base_id = meta_rec - .and_then(|r| r.get("sessionId")) - .and_then(|v| v.as_str()) - .unwrap_or(&stem) - .to_string(); - // A subagent session's id carries the agent suffix; only a top-level session embeds subagents. - let sess_id = match (subagent, &agent_id) { - (true, Some(aid)) => format!("{}-{}", base_id, aid), - _ => base_id.clone(), - }; - let mut subs = if subagent { serde_json::Map::new() } else { read_subagents(file) }; - apply_skill_names(&shaped.messages, &mut subs); - // Live Qoder files are never imported snapshots; avoid probing a protected sibling sidecar. - let import_meta = if qoder { None } else { read_import_meta(file) }; - - json!({ - "meta": { - "id": if qoder { format!("qoder:{}", stem) } else { format!("disk:{}{}", stem, if subagent { ":sub" } else { "" }) }, - "file": file, - "source": if qoder { "qoder" } else { "disk" }, - // Renderer falls back to Claude when null (the app's home turf carries no label). - "assistant": if qoder { json!("Qoder") } else { Value::Null }, - "title": cc_title.clone().unwrap_or_else(|| auto_title.clone()), - "autoTitle": auto_title, - "tags": cc_tags, - "summary": summary, - "sessionId": sess_id, - "cwd": cwd.clone(), - "project": cwd.as_deref().map(base_name).unwrap_or_default(), - "gitBranch": meta_rec.and_then(|r| r.get("gitBranch")).cloned().unwrap_or(Value::Null), - "version": meta_rec.and_then(|r| r.get("version")).cloned().unwrap_or(Value::Null), - "isSubagent": subagent, - // A standalone subagent transcript self-reports its invoking skill via the sentinel. - "skill": if subagent { skill_from_recs(&recs) } else { None:: }, - "deleted": cc_deleted, - "imported": import_meta.is_some(), - "importedFrom": import_meta.as_ref().and_then(|m| m.get("originalPath")).cloned().unwrap_or(Value::Null), - "importedAt": import_meta.as_ref().and_then(|m| m.get("importedAt")).cloned().unwrap_or(Value::Null), - "model": qoder_model.or(shaped.model), - "totals": shaped.totals, - "messages": shaped.messages.len(), - "subagentCount": subs.len(), - "firstTs": shaped.first_ts, - "lastTs": shaped.last_ts, - }, - "messages": shaped.messages, - "subagents": subs, - }) -} - -// ---- content search (the session list's "big search") ---- -// -// Scans session CONTENT (message text / thinking / tool calls + results) across every listed -// session — main threads, their subagent transcripts, and Codex rollouts — and reports, per -// matching session, WHERE the first match lives ("main" or a subagent's tool_use key) plus a -// display snippet. The renderer opens the session, switches the panel to that agent, and -// re-finds the query locally, so list hits and in-conversation positioning stay aligned. -// -// Performance model (this runs per keystroke, debounced): -// - extraction cache: path -> (mtime, size, extracted text), so repeated queries pay the JSON -// parse + shaping once per file version; -// - raw prefilter: on a cache miss the raw JSONL bytes are substring-scanned first, and only -// files that could match are parsed at all (JSON escapes quotes/backslashes/control chars, -// so the prefilter is skipped for queries containing those); -// - parallel scan: per-file work fans out over a small thread pool. - -/// ASCII-case-insensitive substring search (byte-wise; non-ASCII must match exactly — CJK has no -/// case). A valid-UTF-8 needle can only match at char boundaries of valid-UTF-8 text (ASCII bytes -/// never equal continuation bytes), so the returned byte offset is safe to slice on. -fn ifind(hay: &str, needle: &str, from: usize) -> Option { - let h = hay.as_bytes(); - let n = needle.as_bytes(); - if n.is_empty() || h.len() < n.len() { - return None; - } - let last = h.len() - n.len(); - let n0 = n[0].to_ascii_lowercase(); - let mut i = from; - while i <= last { - if h[i].to_ascii_lowercase() == n0 { - let mut k = 1; - while k < n.len() && h[i + k].to_ascii_lowercase() == n[k].to_ascii_lowercase() { - k += 1; - } - if k == n.len() { - return Some(i); - } - } - i += 1; - } - None -} - -/// Non-overlapping case-insensitive occurrence count (same fold as ifind). -fn icount(hay: &str, needle: &str) -> usize { - let (mut i, mut c) = (0usize, 0usize); - while let Some(p) = ifind(hay, needle, i) { - c += 1; - i = p + needle.len().max(1); - } - c -} - -/// Mirror of the renderer's formatCodexBootstrap (conversations.js / runtime.js): Codex records -/// its initial AGENTS.md instructions + environment snapshot as one XML-ish user text block, and -/// the panel renders it as compact Markdown — search must index that same Markdown, not the raw -/// transport shape. None = not a bootstrap message (ordinary prose passes through untouched). -fn format_codex_bootstrap(source: &str) -> Option { - static AGENTS_RE: std::sync::OnceLock = std::sync::OnceLock::new(); - static ENV_RE: std::sync::OnceLock = std::sync::OnceLock::new(); - static ROOT_RE: std::sync::OnceLock = std::sync::OnceLock::new(); - let agents_re = AGENTS_RE.get_or_init(|| { - regex::Regex::new( - r"(?is)^\s*#\s+AGENTS\.md instructions for ([^\r\n]+).*?]*>(.*?)", - ) - .unwrap() - }); - let env_re = ENV_RE.get_or_init(|| { - regex::Regex::new(r"(?is)]*>(.*?)").unwrap() - }); - let root_re = - ROOT_RE.get_or_init(|| regex::Regex::new(r"(?is)]*>(.*?)").unwrap()); - let agents = agents_re.captures(source)?; - - // Dynamic per-name regexes are fine here: at most one bootstrap message exists per session. - let tag = |block: &str, name: &str| -> String { - regex::Regex::new(&format!(r"(?is)<{name}\b[^>]*>(.*?)")) - .ok() - .and_then(|re| re.captures(block).and_then(|c| c.get(1).map(|m| m.as_str().trim().to_string()))) - .unwrap_or_default() - }; - let attr = |block: &str, name: &str, attribute: &str| -> String { - regex::Regex::new(&format!(r#"(?i)<{name}\b[^>]*\b{attribute}=["']([^"']+)["']"#)) - .ok() - .and_then(|re| re.captures(block).and_then(|c| c.get(1).map(|m| m.as_str().trim().to_string()))) - .unwrap_or_default() - }; - let code = |value: &str| -> String { - if value.is_empty() { String::new() } else { format!("`{}`", value) } - }; - - let mut parts: Vec = - vec![format!("# AGENTS.md instructions for {}", agents.get(1).map(|m| m.as_str().trim()).unwrap_or(""))]; - let instructions = agents.get(2).map(|m| m.as_str().trim()).unwrap_or(""); - if !instructions.is_empty() { - let lines: Vec<&str> = instructions.lines().filter(|line| !line.trim().is_empty()).collect(); - parts.push(if lines.len() == 1 { - format!("**INSTRUCTIONS:** {}", lines[0].trim()) - } else { - format!("**INSTRUCTIONS:**\n\n{}", instructions) - }); - } - - let env = env_re.captures(source); - if let Some(env) = &env { - let block = env.get(1).map(|m| m.as_str()).unwrap_or(""); - let roots: Vec = root_re - .captures_iter(block) - .filter_map(|c| c.get(1).map(|m| m.as_str().trim().to_string())) - .filter(|r| !r.is_empty()) - .map(|r| code(&r)) - .collect(); - let fields: Vec<(&str, String)> = vec![ - ("environment_context", code(&tag(block, "cwd"))), - ("shell", tag(block, "shell")), - ("current_date", tag(block, "current_date")), - ("timezone", tag(block, "timezone")), - ("workspace_roots", roots.join(", ")), - ("permission_profile", attr(block, "permission_profile", "type")), - ("file_system", attr(block, "file_system", "type")), - ] - .into_iter() - .filter(|(_, value)| !value.is_empty()) - .collect(); - if !fields.is_empty() { - parts.push( - fields - .iter() - .map(|(name, value)| format!("**{}:** {}", name, value)) - .collect::>() - .join(" \n"), - ); - } - } - - let mut rest = source.replacen(agents.get(0).map(|m| m.as_str()).unwrap_or(""), "", 1); - if let Some(env) = &env { - rest = rest.replacen(env.get(0).map(|m| m.as_str()).unwrap_or(""), "", 1); - } - let rest = rest.trim(); - if !rest.is_empty() { - parts.push(rest.to_string()); - } - Some(parts.join("\n\n").trim().to_string()) -} - -/// Strip harness-injected blocks from user prose — MUST stay rule-for-rule in sync with the -/// renderer's stripInjected (conversations.js) and the export viewer's copy (runtime.js), so what -/// the big search matches is exactly what the in-conversation search (and the panel) will show. -/// The Codex AGENTS bootstrap reformats to the same Markdown the panel renders; task-notification -/// envelopes keep their human-facing body; the transport metadata (ids, status, summary) -/// is dropped and must therefore never be searchable. -fn strip_injected(s: &str) -> String { - static SKILL_RE: std::sync::OnceLock = std::sync::OnceLock::new(); - static TASK_RE: std::sync::OnceLock = std::sync::OnceLock::new(); - static RESULT_RE: std::sync::OnceLock = std::sync::OnceLock::new(); - static RE: std::sync::OnceLock = std::sync::OnceLock::new(); - // Same rule order as the JS: the Codex AGENTS bootstrap is reformatted FIRST, then the - // envelope rules run over the (possibly rewritten) text. - let bootstrap = format_codex_bootstrap(s); - let s: &str = bootstrap.as_deref().unwrap_or(s); - // A turn that is nothing but a envelope is Codex's recorded skill-instruction - // injection — runtime context the panel suppresses wholesale, so search must too. Prose that - // merely quotes markup alongside other text stays searchable. - let skill_re = SKILL_RE - .get_or_init(|| regex::Regex::new(r"(?is)^\s*]*>.*\s*$").unwrap()); - if skill_re.is_match(s) { - return String::new(); - } - let task_re = TASK_RE.get_or_init(|| { - regex::Regex::new(r"(?is)]*>.*?").unwrap() - }); - let result_re = - RESULT_RE.get_or_init(|| regex::Regex::new(r"(?is)]*>(.*?)").unwrap()); - let re = RE.get_or_init(|| { - regex::Regex::new( - r"(?s).*?|.*?|.*?", - ) - .unwrap() - }); - let unwrapped = task_re.replace_all(s, |caps: ®ex::Captures| { - result_re - .captures(caps.get(0).map(|m| m.as_str()).unwrap_or("")) - .and_then(|c| c.get(1)) - .map(|m| format!("\n{}\n", m.as_str().trim())) - .unwrap_or_default() - }); - re.replace_all(&unwrapped, "").trim().to_string() -} - -fn tool_result_search_text(c: &Value) -> String { - if let Some(s) = c.as_str() { - return s.to_string(); - } - if let Some(arr) = c.as_array() { - return arr - .iter() - .filter_map(|x| x.get("text").and_then(|t| t.as_str())) - .collect::>() - .join("\n"); - } - String::new() -} - -/// One searchable text blob for a shaped message list — the renderer's messagePlainText, flattened: -/// user prose (injected blocks stripped), assistant text, thinking, tool name + input JSON, and -/// tool results. Images and raw structure are skipped so a hit here is findable in the panel. -fn extract_search_text(messages: &[Value]) -> String { - let mut out = String::new(); - let mut push = |t: &str| { - if !t.is_empty() { - out.push_str(t); - out.push('\n'); - } - }; - for m in messages { - let role = m.get("role").and_then(|v| v.as_str()).unwrap_or(""); - let content = match m.get("content") { - Some(c) => c, - None => continue, - }; - if let Some(s) = content.as_str() { - if role == "user" { - push(&strip_injected(s)); - } else { - push(s); - } - continue; - } - let arr = match content.as_array() { - Some(a) => a, - None => continue, - }; - for b in arr { - match b.get("type").and_then(|v| v.as_str()).unwrap_or("") { - "text" => { - let t = b.get("text").and_then(|v| v.as_str()).unwrap_or(""); - if role == "user" { - push(&strip_injected(t)); - } else { - push(t); - } - } - "thinking" => push(b.get("thinking").and_then(|v| v.as_str()).unwrap_or("")), - "skill_load" => { - push(b.get("name").and_then(Value::as_str).unwrap_or("")); - push(b.get("path").and_then(Value::as_str).unwrap_or("")); - push(b.get("snapshot").and_then(Value::as_str).unwrap_or("")); - } - "tool_use" => { - let name = b.get("name").and_then(|v| v.as_str()).unwrap_or(""); - let input = b.get("input").map(|i| i.to_string()).unwrap_or_default(); - push(&format!("{} {}", name, input)); - } - "tool_result" => { - push(&tool_result_search_text(b.get("content").unwrap_or(&Value::Null))) - } - _ => {} - } - } - } - out -} - -struct SearchCache { - map: std::collections::HashMap)>, - bytes: usize, -} -/// Extracted-text memo, keyed path -> (mtime, size, text). Cleared wholesale past the byte budget -/// (crude but safe — the next search simply re-extracts what it touches). -fn search_cache() -> &'static std::sync::Mutex { - static CACHE: std::sync::OnceLock> = std::sync::OnceLock::new(); - CACHE.get_or_init(|| std::sync::Mutex::new(SearchCache { map: std::collections::HashMap::new(), bytes: 0 })) -} -const SEARCH_CACHE_BUDGET: usize = 128 * 1024 * 1024; - -/// Search one transcript file for `q`: (extracted text, first-match byte offset), or None. -/// Serves from the extraction cache when fresh; otherwise prefilters the raw bytes and only -/// parses candidates — files that can't match are neither parsed nor cached. -fn thread_scan(path: &Path, q: &str, raw_safe: bool) -> Option<(std::sync::Arc, usize)> { - let meta = fs::metadata(path).ok()?; - let (mt, sz) = (cache_stamp_ms(path), meta.len()); - if let Ok(cache) = search_cache().lock() { - if let Some((cmt, csz, text)) = cache.map.get(path) { - if *cmt == mt && *csz == sz { - let t = text.clone(); - drop(cache); - return ifind(&t, q, 0).map(|p| (t, p)); - } - } - } - let fk = foreign_kind(path); - let messages: Vec = if fk == Some(Foreign::Antigravity) { - // SQLite source: no raw-bytes prefilter (the payloads are binary) — extraction is - // cached, so the decode is paid once per file version. - crate::antigravity::normalize_db(path).messages - } else { - let raw = read_session_text(path).ok()?; - if raw_safe && ifind(&raw, q, 0).is_none() { - return None; - } - let parsed = parse_lines(&raw); - let recs = if crate::qoder::looks_qoder_path(path) { - crate::qoder::normalize_records(&parsed) - } else { - parsed - }; - match fk { - Some(Foreign::Grok) => crate::grok::normalize(&recs, None).messages, - Some(Foreign::Copilot) => crate::copilot::normalize(&recs).messages, - _ => { - if crate::codex::looks_codex(&recs) { - crate::codex::session_from_recs(&path.to_string_lossy(), &recs) - .get("messages") - .and_then(|v| v.as_array()) - .cloned() - .unwrap_or_default() - } else { - shape_messages(&recs).messages - } - } - } - }; - let text = std::sync::Arc::new(extract_search_text(&messages)); - if let Ok(mut cache) = search_cache().lock() { - if cache.bytes + text.len() > SEARCH_CACHE_BUDGET { - cache.map.clear(); - cache.bytes = 0; - } - if let Some((_, _, old)) = cache.map.insert(path.to_path_buf(), (mt, sz, text.clone())) { - cache.bytes = cache.bytes.saturating_sub(old.len()); // replaced a stale entry - } - cache.bytes += text.len(); - } - ifind(&text, q, 0).map(|p| (text, p)) -} - -/// Display snippet around the first match: ~56 chars of context either side, whitespace collapsed, -/// ellipsized at cut edges. Slice bounds snap outward/inward to char boundaries. -fn snippet_around(text: &str, pos: usize, match_len: usize) -> String { - const CTX: usize = 56; - let mut start = pos.saturating_sub(CTX); - while start > 0 && !text.is_char_boundary(start) { - start -= 1; - } - let mut end = (pos + match_len + CTX).min(text.len()); - while end < text.len() && !text.is_char_boundary(end) { - end += 1; - } - let body = text[start..end].split_whitespace().collect::>().join(" "); - format!("{}{}{}", if start > 0 { "…" } else { "" }, body, if end < text.len() { "…" } else { "" }) -} - -/// Scan one session — main thread first, then each subagent transcript — and shape the hit the -/// renderer needs to auto-locate: which agent matched, a snippet, and the occurrence count. -fn scan_session(file: &Path, q: &str, raw_safe: bool) -> Option { - if let Some((text, pos)) = thread_scan(file, q, raw_safe) { - return Some(json!({ - "file": file.to_string_lossy(), - "agent": "main", - "snippet": snippet_around(&text, pos, q.len()), - "count": icount(&text, q), - })); - } - let dir = subagent_dir(file)?; - let mut names: Vec = vec![]; - if let Ok(entries) = fs::read_dir(&dir) { - for ent in entries.flatten() { - let name = ent.file_name().to_string_lossy().into_owned(); - if name.starts_with("agent-") && name.ends_with(".jsonl") { - names.push(name); - } - } - } - names.sort(); - for name in names { - if let Some((text, pos)) = thread_scan(&dir.join(&name), q, raw_safe) { - let agent_id = name.trim_start_matches("agent-").trim_end_matches(".jsonl").to_string(); - let meta_path = dir.join(format!("agent-{}.meta.json", agent_id)); - let meta_raw = if crate::qoder::looks_qoder_path(file) { - crate::qoder::read_text(&meta_path) - } else { - fs::read_to_string(&meta_path) - }; - let meta: Value = meta_raw - .ok() - .and_then(|s| serde_json::from_str(&s).ok()) - .unwrap_or_else(|| json!({})); - // Key by the spawning tool_use id — the same key read_subagents uses, so the renderer - // can switch its panel straight to this agent. - let key = meta - .get("toolUseId") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()) - .unwrap_or_else(|| format!("agent:{}", agent_id)); - let agent_type = meta - .get("agentType") - .and_then(|v| v.as_str()) - .or_else(|| meta.get("subagent_type").and_then(|v| v.as_str())) - .unwrap_or("agent"); - return Some(json!({ - "file": file.to_string_lossy(), - "agent": key, - "agentType": agent_type, - "snippet": snippet_around(&text, pos, q.len()), - "count": icount(&text, q), - })); - } - } - None -} - -/// Content search over the same candidate set (and dir/trash scoping) as the list view, newest -/// first. Returns [{ file, agent, agentType?, snippet, count }] for up to `limit` sessions. -pub fn search_sessions(config: &Value, active: &str, query: &str, limit: usize) -> Vec { - let q = query.trim(); - if q.is_empty() { - return vec![]; - } - let trash = active == TRASH_ID; - // The raw-bytes prefilter only applies to queries whose every byte is guaranteed to appear - // verbatim in the file's JSON encoding: printable ASCII minus the chars JSON escapes - // (quote/backslash/control). Non-ASCII stays OFF the prefilter — some producers (e.g. - // Python's json.dumps default) escape it as \uXXXX, which a byte scan would miss; those - // queries always take the parse+extract path (cached, so paid once per file version). - let raw_safe = q.bytes().all(|b| b.is_ascii() && b != b'"' && b != b'\\' && b >= 0x20); - // Reuse the list's pre-limit canonical-thread dedupe, directory/trash scope, and ordering. - // Otherwise duplicate physical rollouts could consume the 600-file search window even though - // the sidebar shows only their selected representative. - let files: Vec<(PathBuf, f64)> = list_sessions(config, active, 600) - .into_iter() - .filter_map(|session| { - let file = PathBuf::from(session.get("file")?.as_str()?); - let created = session - .get("createdAt") - .and_then(Value::as_f64) - .unwrap_or_else(|| created_ms(&file)); - Some((file, created)) - }) - .collect(); - // One batch helper call instead of a spawn per protected qoder file inside the worker loop - // (repeat scans of unchanged files are then served by the extraction + helper caches). - let qoder_files: Vec = files - .iter() - .map(|(file, _)| file.clone()) - .filter(|file| crate::qoder::looks_qoder_path(file)) - .collect(); - crate::qoder::prefetch(&qoder_files); - let hits = std::sync::Mutex::new(Vec::<(f64, Value)>::new()); - let next = std::sync::atomic::AtomicUsize::new(0); - let workers = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(4).clamp(1, 8); - std::thread::scope(|s| { - for _ in 0..workers { - s.spawn(|| loop { - let i = next.fetch_add(1, std::sync::atomic::Ordering::SeqCst); - if i >= files.len() { - break; - } - let (file, ct) = &files[i]; - if is_session_deleted(file) != trash { - continue; - } - if let Some(hit) = scan_session(file, q, raw_safe) { - if let Ok(mut h) = hits.lock() { - h.push((*ct, hit)); - } - } - }); - } - }); - let mut hits = hits.into_inner().unwrap_or_else(|e| e.into_inner()); - hits.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal)); - hits.truncate(limit); - hits.into_iter().map(|(_, v)| v).collect() -} - -/// Write per-conversation customization (custom title + tags) onto the FIRST parseable line as a -/// `__ccbud__` field. Atomic (tmp + rename). Guarded to the configured dirs + the imports store -/// (renderer can't drive an arbitrary-path write, but imported sessions must be titleable/taggable -/// too — mirrors history.js setCcbud, whose getDirs() includes the imported dir). -pub fn set_ccbud(file: &str, patch: &Value, config: &Value) -> Value { - let target = Path::new(file); - if !within_scope(target, config) { - return json!({ "ok": false, "reason": "out-of-scope" }); - } - // Foreign-CLI sessions are other tools' files (one is SQLite): their title/tags/delete - // flag always live in the app-owned sidecar. Same cache-drop contract as the codex branch. - if let Some(fk) = foreign_kind(target) { - let r = match fk { - Foreign::Grok => crate::grok::set_meta(file, patch), - Foreign::Copilot => crate::copilot::set_meta(file, patch), - Foreign::Antigravity => crate::antigravity::set_meta(file, patch), - }; - if r.get("ok").and_then(|v| v.as_bool()).unwrap_or(false) { - if let Ok(mut cache) = meta_cache().lock() { - cache.remove(target); - } - } - return r; - } - // Qoder sessions are Claude-format but another tool's live files: title/tags/delete go to - // the shared sidecar (keyed qoder:) instead of an in-file rewrite. The sidecar edit - // doesn't touch the file (no mtime bump), so the list-meta memo is dropped by hand. - if crate::qoder::looks_qoder_path(target) { - let r = crate::qoder::set_meta(file, patch); - if r.get("ok").and_then(|v| v.as_bool()).unwrap_or(false) { - if let Ok(mut cache) = meta_cache().lock() { - cache.remove(target); - } - } - return r; - } - let raw = match fs::read_to_string(file) { - Ok(s) => s, - Err(_) => return json!({ "ok": false, "reason": "read" }), - }; - // Live Codex rollouts are another tool's files — their title/tags/delete flag live in the - // app-owned sidecar instead of being written into the rollout. Imported codex COPIES sit - // inside our store (marked by .import.json) and take the normal in-file path below. - let head: Vec = raw.lines().take(8).filter_map(|l| serde_json::from_str(l.trim()).ok()).collect(); - if crate::codex::looks_codex(&head) && read_import_meta(file).is_none() { - let r = crate::codex::set_meta(file, patch); - // A sidecar edit changes the row without touching the rollout file (no mtime bump), so - // the list-meta memo must be dropped by hand. In-file writes below invalidate via mtime. - if r.get("ok").and_then(|v| v.as_bool()).unwrap_or(false) { - if let Ok(mut cache) = meta_cache().lock() { - cache.remove(target); - } - } - return r; - } - let mut lines: Vec = raw.split('\n').map(|s| s.to_string()).collect(); - let mut found: Option<(usize, Value)> = None; - for (i, l) in lines.iter().enumerate() { - let s = l.trim(); - if s.is_empty() { - continue; - } - if let Ok(v) = serde_json::from_str::(s) { - if v.is_object() { - found = Some((i, v)); - break; - } - } - } - let (idx, mut obj) = match found { - Some(x) => x, - None => return json!({ "ok": false, "reason": "empty" }), - }; - let mut next = obj.get("__ccbud__").and_then(|v| v.as_object()).cloned().unwrap_or_default(); - if let Some(t) = patch.get("title") { - let t = t.as_str().unwrap_or("").trim().to_string(); - if !t.is_empty() { - next.insert("title".into(), json!(t)); - } else { - next.remove("title"); - } - } - if let Some(tags) = patch.get("tags") { - let mut arr: Vec = vec![]; - if let Some(ta) = tags.as_array() { - for x in ta { - if let Some(s) = x.as_str() { - let s = s.trim(); - if !s.is_empty() && !arr.iter().any(|y| y == s) { - arr.push(s.to_string()); - } - } - } - } - if !arr.is_empty() { - next.insert("tagList".into(), json!(arr)); - } else { - next.remove("tagList"); - } - } - // Soft delete / restore: `delete: true` marks the session deleted; `delete: false` (restore) - // drops the flag. Restore that empties __ccbud__ removes the field wholesale below. - if let Some(d) = patch.get("delete") { - if d.as_bool().unwrap_or(false) { - next.insert("delete".into(), json!(true)); - } else { - next.remove("delete"); - } - } - let o = obj.as_object_mut().unwrap(); - if !next.is_empty() { - o.insert("__ccbud__".into(), Value::Object(next)); - } else { - o.remove("__ccbud__"); - } - lines[idx] = serde_json::to_string(&obj).unwrap_or_default(); - let out = lines.join("\n"); - let tmp = format!("{}.ccbud.tmp", file); - if fs::write(&tmp, &out).is_err() { - return json!({ "ok": false, "reason": "write" }); - } - if fs::rename(&tmp, file).is_err() { - let _ = fs::remove_file(&tmp); - return json!({ "ok": false, "reason": "write" }); - } - // The rewrite bumps mtime/size, which already invalidates the list-meta memo — dropping the - // entry outright also covers a same-millisecond, same-length rewrite. - if let Ok(mut cache) = meta_cache().lock() { - cache.remove(target); - } - json!({ "ok": true }) -} - -/// Permanently remove a session's .jsonl from disk (recycle-bin "delete forever"). Guarded to the -/// configured dirs + the imports store exactly like set_ccbud, and also drops the session's -/// `/` subagents tree and any import sidecar (mirrors remove_import's cleanup). -/// Renderer-driven writes/deletes are confined to the configured work dirs' data trees -/// (projects/ AND sessions/) plus the imports store. -fn within_scope(target: &Path, config: &Value) -> bool { - all_dirs(config).iter().any(|(_, _, pd)| { - target.starts_with(pd) - || ["sessions", "session-state", "conversations"] - .iter() - .any(|n| sibling_dir(pd, n).map(|sd| target.starts_with(sd)).unwrap_or(false)) - }) -} - -pub fn delete_session_file(file: &str, config: &Value) -> Value { - let target = Path::new(file); - if !within_scope(target, config) { - return json!({ "ok": false, "reason": "out-of-scope" }); - } - if !target.is_file() { - return json!({ "ok": false, "reason": "missing" }); - } - // A LIVE Codex rollout, Qoder session, or foreign-CLI session is another tool's file — the - // app only ever soft-deletes those via the sidecar and never rewrites them (see set_ccbud), - // so "delete forever" must not rm the source either. Imported codex COPIES (marked by an - // .import.json) are our own snapshots and stay hard-deletable, like Claude sessions the app - // manages in the configured dirs. - if foreign_kind(target).is_some() || crate::qoder::looks_qoder_path(target) { - return json!({ "ok": false, "reason": "foreign" }); - } - let head = parse_lines(&read_head(target, 131072)); - if crate::codex::looks_codex(&head) && read_import_meta(file).is_none() { - return json!({ "ok": false, "reason": "foreign" }); - } - if fs::remove_file(target).is_err() { - return json!({ "ok": false, "reason": "remove" }); - } - crate::codex::remove_meta(file); // drop any codex sidecar entry (no-op for Claude sessions) - let dir = target.parent().unwrap_or(Path::new(".")); - let stem = target.file_stem().and_then(|s| s.to_str()).unwrap_or(""); - if !stem.is_empty() { - let _ = fs::remove_dir_all(dir.join(stem)); // /subagents/... - let _ = fs::remove_file(dir.join(format!("{}.import.json", stem))); - } - json!({ "ok": true }) -} - -/// Self-contained round-trip test of set_ccbud + get_session in a throwaway projects tree. -pub fn history_selftest(base_dir: &Path) -> Value { - let proj = base_dir.join("test-claude").join("projects").join("-test-cwd"); - let _ = fs::create_dir_all(&proj); - let file = proj.join("sess1.jsonl"); - let content = "{\"type\":\"user\",\"message\":{\"role\":\"user\",\"content\":\"hello world from selfcheck\"},\"cwd\":\"/test/cwd\",\"sessionId\":\"sess1\",\"timestamp\":\"2025-01-01T10:00:00.000Z\"}\n"; - let _ = fs::write(&file, content); - let config = json!({ "historyDirs": [ base_dir.join("test-claude").to_string_lossy() ] }); - let fpath = file.to_string_lossy().to_string(); - let set = set_ccbud(&fpath, &json!({ "title": "My Title", "tags": ["a", "b", "b"] }), &config); - let sess = get_session(&fpath); - let title = sess.get("meta").and_then(|m| m.get("title")).and_then(|v| v.as_str()).unwrap_or("").to_string(); - let tags = sess.get("meta").and_then(|m| m.get("tags")).and_then(|v| v.as_array()).map(|a| a.len()).unwrap_or(0); - let auto = sess.get("meta").and_then(|m| m.get("autoTitle")).and_then(|v| v.as_str()).unwrap_or("").to_string(); - // Soft-delete round-trip: marked → hidden from "all" but present in trash → restored → back in "all". - let _ = set_ccbud(&fpath, &json!({ "delete": true }), &config); - let after_del = get_session(&fpath).get("meta").and_then(|m| m.get("deleted")).and_then(|v| v.as_bool()).unwrap_or(false); - let hidden_in_all = !list_sessions(&config, "all", 50).iter().any(|s| s.get("file").and_then(|v| v.as_str()) == Some(fpath.as_str())); - let shown_in_trash = list_sessions(&config, TRASH_ID, 50).iter().any(|s| s.get("file").and_then(|v| v.as_str()) == Some(fpath.as_str())); - let _ = set_ccbud(&fpath, &json!({ "delete": false }), &config); - let restored = !get_session(&fpath).get("meta").and_then(|m| m.get("deleted")).and_then(|v| v.as_bool()).unwrap_or(false); - json!({ - "setOk": set.get("ok").and_then(|v| v.as_bool()).unwrap_or(false), - "title": title, - "tagCount": tags, - "autoTitle": auto, - "deletedAfterMark": after_del, - "hiddenInAll": hidden_in_all, - "shownInTrash": shown_in_trash, - "restored": restored, - }) -} - -#[cfg(test)] -mod foreign_probe { - use super::*; - - // Diagnostic harness (not an assertion): list + open REAL foreign-CLI sessions so the - // shapers can be eyeballed against live ~/.grok, ~/.copilot, ~/.gemini/antigravity-cli. - // Run: CCBUD_PROBE_FOREIGN="~/.grok,~/.copilot,~/.gemini/antigravity-cli" \ - // cargo test --lib probe_foreign_dirs -- --ignored --nocapture - #[test] - #[ignore] - fn probe_foreign_dirs() { - let Ok(dirs) = std::env::var("CCBUD_PROBE_FOREIGN") else { - eprintln!("set CCBUD_PROBE_FOREIGN=dir1,dir2,…"); - return; - }; - let list: Vec<&str> = dirs.split(',').map(|s| s.trim()).filter(|s| !s.is_empty()).collect(); - let config = json!({ "historyDirs": list }); - let sessions = list_sessions(&config, "all", 500); - eprintln!("== {} sessions across {:?}", sessions.len(), list); - let mut by_source: std::collections::HashMap = std::collections::HashMap::new(); - for s in &sessions { - *by_source - .entry(s.get("source").and_then(|v| v.as_str()).unwrap_or("?").to_string()) - .or_insert(0) += 1; - } - eprintln!("== by source: {:?}", by_source); - let mut seen: std::collections::HashSet = std::collections::HashSet::new(); - for s in &sessions { - let src = s.get("source").and_then(|v| v.as_str()).unwrap_or("?").to_string(); - if !seen.insert(src.clone()) { - continue; - } - let file = s.get("file").and_then(|v| v.as_str()).unwrap_or(""); - eprintln!( - "-- [{}] {} | cwd={} | title={:?}", - src, - file, - s.get("cwd").and_then(|v| v.as_str()).unwrap_or("-"), - s.get("title").and_then(|v| v.as_str()).unwrap_or("-") - ); - let detail = get_session(file); - let meta = detail.get("meta").cloned().unwrap_or(Value::Null); - let msgs = detail.get("messages").and_then(|v| v.as_array()).map(|a| a.len()).unwrap_or(0); - eprintln!( - " detail: assistant={:?} messages={} totals={} firstTs={:?}", - meta.get("assistant").and_then(|v| v.as_str()), - msgs, - meta.get("totals").map(|t| t.to_string()).unwrap_or_default(), - meta.get("firstTs").and_then(|v| v.as_str()) - ); - if let Some(arr) = detail.get("messages").and_then(|v| v.as_array()) { - for m in arr.iter().take(4) { - let role = m.get("role").and_then(|v| v.as_str()).unwrap_or("?"); - let kinds: Vec = m - .get("content") - .and_then(|c| c.as_array()) - .map(|a| { - a.iter() - .map(|b| b.get("type").and_then(|t| t.as_str()).unwrap_or("?").to_string()) - .collect() - }) - .unwrap_or_default(); - eprintln!(" msg {} {:?}", role, kinds); - } - } - } - } -} - -// ---- import (copy someone else's .jsonl into the app-managed store) ---- - -fn encode_cwd(cwd: Option<&str>) -> String { - match cwd { - Some(c) if !c.is_empty() => c.replace(['/', '\\'], "-"), - _ => "-imported".to_string(), - } -} - -fn copy_dir(src: &Path, dst: &Path) -> std::io::Result<()> { - fs::create_dir_all(dst)?; - for e in fs::read_dir(src)? { - let e = e?; - let s = e.path(); - let d = dst.join(e.file_name()); - if s.is_dir() { - copy_dir(&s, &d)?; - } else { - fs::copy(&s, &d)?; - } - } - Ok(()) -} - -fn now_ms() -> i64 { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_millis() as i64) - .unwrap_or(0) -} - -fn records_to_jsonl(records: &[Value]) -> String { - let mut text = records - .iter() - .map(|r| serde_json::to_string(r).unwrap_or_default()) - .collect::>() - .join("\n"); - text.push('\n'); - text -} - -/// A qoder transcript imported into the app store loses its container path and would otherwise -/// re-parse as raw Claude records: queued commands vanish, redacted duplicates render, atomic -/// wrappers stay fragmented, and qoder's own title is lost. Sniffed by CONTENT (import copies and -/// bundle zips carry no .qoder path), the copy is rewritten up front — normalized records, with -/// the qoder title carried onto the first line's __ccbud__ so the import keeps its name. -fn qoder_import_raw(recs: &[Value]) -> Option<(String, Vec)> { - if !crate::qoder::looks_qoder_records(recs) { - return None; - } - let mut normalized = crate::qoder::normalize_records(recs); - if let Some(title) = crate::qoder::session_title_from(recs) { - if let Some(first) = normalized.iter_mut().find(|r| r.is_object()) { - let obj = first.as_object_mut().unwrap(); - let mut cc = obj.get("__ccbud__").and_then(|v| v.as_object()).cloned().unwrap_or_default(); - cc.entry("title".to_string()).or_insert_with(|| json!(title)); - obj.insert("__ccbud__".into(), Value::Object(cc)); - } - } - Some((records_to_jsonl(&normalized), normalized)) -} - -/// Snapshot a transcript (already read into `raw`) plus its subagent sidecars into the import store, -/// laid out like a native projects/ tree + a provenance sidecar. `subagents`: (filename, bytes) to -/// drop under `/subagents/` — names are basename-reduced and pattern-checked so a crafted -/// entry can't escape the directory. Returns 1 = imported, 2 = skipped (already present), -/// 0 = failed/not-a-transcript. Shared by the plain-.jsonl and .zip-bundle import paths. -fn write_imported(raw: &str, original_path: &str, original_name: &str, subagents: &[(String, Vec)]) -> i32 { - let recs = parse_lines(raw); - let is_codex = crate::codex::looks_codex(&recs); - // Qoder content is rewritten to Claude shape before storing — the has_msg gate below then - // sees the materialized queued-command user turns too. - let (qoder_text, recs) = match qoder_import_raw(&recs) { - Some((text, normalized)) => (Some(text), normalized), - None => (None, recs), - }; - let raw = qoder_text.as_deref().unwrap_or(raw); - let has_msg = recs.iter().any(|r| { - let t = r.get("type").and_then(|v| v.as_str()); - (t == Some("user") || t == Some("assistant")) && r.get("message").is_some() - }); - if !has_msg && !is_codex { - return 0; - } - let name_stem = || Path::new(original_name).file_stem().and_then(|s| s.to_str()).unwrap_or("import").to_string(); - // Codex rollouts keep cwd/session id inside the session_meta payload, not on the records. - let (cwd_owned, base_id) = if is_codex { - let (c, s) = crate::codex::head_ids(&recs); - (c, s.unwrap_or_else(name_stem)) - } else { - let meta_rec = recs.iter().find(|r| r.get("cwd").is_some()).or_else(|| recs.iter().find(|r| r.get("sessionId").is_some())); - ( - meta_rec.and_then(|r| r.get("cwd")).and_then(|v| v.as_str()).map(|s| s.to_string()), - meta_rec - .and_then(|r| r.get("sessionId")) - .and_then(|v| v.as_str()) - .map(|s| s.to_string()) - .unwrap_or_else(name_stem), - ) - }; - let cwd = cwd_owned.as_deref(); - let dest_dir = imports_root().join("projects").join(encode_cwd(cwd)); - let dest_file = dest_dir.join(format!("{}.jsonl", base_id)); - if dest_file.exists() { - return 2; - } - if fs::create_dir_all(&dest_dir).is_err() || fs::write(&dest_file, raw).is_err() { - return 0; - } - if !subagents.is_empty() { - let sub_dir = dest_dir.join(&base_id).join("subagents"); - if fs::create_dir_all(&sub_dir).is_ok() { - for (name, bytes) in subagents { - // file_name() strips any directory component, so the write can't escape sub_dir. - let safe = Path::new(name).file_name().and_then(|n| n.to_str()).unwrap_or(""); - let lower = safe.to_lowercase(); - if lower.starts_with("agent-") && (lower.ends_with(".jsonl") || lower.ends_with(".meta.json")) { - // A qoder session's subagent transcripts carry the same atomic wrappers — - // the parent's sniff decides, so the whole stored copy is Claude-shaped. - if qoder_text.is_some() && lower.ends_with(".jsonl") { - if let Ok(text) = std::str::from_utf8(bytes) { - let normalized = crate::qoder::normalize_records(&parse_lines(text)); - let _ = fs::write(sub_dir.join(safe), records_to_jsonl(&normalized)); - continue; - } - } - let _ = fs::write(sub_dir.join(safe), bytes); - } - } - } - } - let sidecar = dest_dir.join(format!("{}.import.json", base_id)); - let _ = fs::write( - &sidecar, - serde_json::to_vec_pretty(&json!({ - "originalPath": original_path, - "originalName": original_name, - "sessionId": base_id, - "importedAt": now_ms(), - })) - .unwrap_or_default(), - ); - 1 -} - -/// Import a plain .jsonl transcript, bringing along its on-disk subagents dir if present. -/// Foreign-CLI sources (Grok / Copilot / Antigravity) are intentionally not importable — -/// their layouts/formats aren't Claude/Codex, and a Grok chat_history head would otherwise -/// trip looks_codex (its `reasoning` lines look like old envelope-less Codex items). -fn import_one(src: &str) -> i32 { - let src_path = Path::new(src); - if foreign_kind(src_path).is_some() { - return 0; - } - let raw = match read_session_text(src_path) { - Ok(s) => s, - Err(_) => return 0, - }; - // Path-less copies of foreign transcripts: refuse anything whose head sniffs as Grok - // chat_history (type:system + later reasoning/tool_result) or Copilot events - // (type:session.start with producer copilot-agent). - let head: Vec = raw.lines().take(8).filter_map(|l| serde_json::from_str(l.trim()).ok()).collect(); - if looks_foreign_jsonl(&head) { - return 0; - } - let subs = read_subagent_files(src_path); - let original_name = src_path.file_name().and_then(|n| n.to_str()).unwrap_or(""); - write_imported(&raw, src, original_name, &subs) -} - -/// Content sniff for foreign CLI jsonl (used by import when the path no longer carries the -/// original container shape — e.g. a bare chat_history.jsonl dropped into the import dialog). -fn looks_foreign_jsonl(recs: &[Value]) -> bool { - recs.iter().take(8).any(|r| match r.get("type").and_then(|v| v.as_str()) { - // Copilot event stream - Some("session.start") | Some("user.message") | Some("assistant.message") - | Some("tool.execution_complete") | Some("tool.execution_start") => true, - // Grok chat_history: top-level system/reasoning/tool_result (Claude wraps these) - Some("reasoning") | Some("tool_result") if r.get("message").is_none() => true, - Some("system") if r.get("content").is_some() && r.get("message").is_none() => true, - _ => false, - }) -} - -/// Import a conversation-bundle .zip (main session + `subagents/`), restoring the subagent layout so -/// the pipeline nests them exactly as if they'd been captured live. Round-trips export_bundle. -fn import_zip(src: &str) -> i32 { - let bytes = match fs::read(src) { - Ok(b) => b, - Err(_) => return 0, - }; - let (main, subs) = crate::ziputil::split_bundle(crate::ziputil::read(&bytes)); - let main_data = match main { - Some((_, data)) => data, - None => return 0, - }; - let raw = match String::from_utf8(main_data) { - Ok(s) => s, - Err(_) => return 0, - }; - let original_name = Path::new(src).file_name().and_then(|n| n.to_str()).unwrap_or(""); - write_imported(&raw, src, original_name, &subs) -} - -pub fn import_paths(paths: &[String]) -> Value { - let (mut imported, mut skipped, mut failed) = (0, 0, 0); - for src in paths { - let lower = src.to_lowercase(); - let r = if lower.ends_with(".zip") { - import_zip(src) - } else if lower.ends_with(".jsonl") { - import_one(src) - } else { - 0 - }; - match r { - 1 => imported += 1, - 2 => skipped += 1, - _ => failed += 1, - } - } - json!({ "imported": imported, "skipped": skipped, "failed": failed }) -} - -pub fn remove_import(file: &str) -> Value { - let root = imports_root(); - let f = Path::new(file); - // Hard safety: only ever delete inside our own import store. - if !f.starts_with(&root) { - return json!({ "ok": false, "error": "outside import store" }); - } - let dir = f.parent().unwrap_or(Path::new(".")); - let base = f.file_stem().and_then(|s| s.to_str()).unwrap_or(""); - let _ = fs::remove_file(f); - let _ = fs::remove_file(dir.join(format!("{}.import.json", base))); - let _ = fs::remove_dir_all(dir.join(base)); // subagents/ - json!({ "ok": true }) -} - -/// Self-contained test of import → list-as-imported → re-import-skip → remove. -pub fn import_selftest(base_dir: &Path) -> Value { - std::env::set_var("CCBUD_HOME", base_dir); // imports_root() honors CCBUD_HOME - let src_dir = base_dir.join("import-src"); - let _ = fs::create_dir_all(&src_dir); - let src = src_dir.join("foreign.jsonl"); - let _ = fs::write(&src, "{\"type\":\"user\",\"message\":{\"role\":\"user\",\"content\":\"imported hello\"},\"cwd\":\"/imp/cwd\",\"sessionId\":\"impsess\",\"timestamp\":\"2025-01-01T10:00:00.000Z\"}\n"); - let srcs = vec![src.to_string_lossy().to_string()]; - let r = import_paths(&srcs); - let r2 = import_paths(&srcs); - let config = json!({ "historyDirs": ["~/.claude"] }); - let sessions = list_sessions(&config, "__imported__", 50); - let found = sessions.iter().any(|s| { - s.get("imported").and_then(|v| v.as_bool()).unwrap_or(false) - && s.get("title").and_then(|v| v.as_str()) == Some("imported hello") - }); - let dest = imports_root().join("projects").join("-imp-cwd").join("impsess.jsonl"); - let rm = remove_import(&dest.to_string_lossy()); - - // ---- bundle round-trip: a session WITH subagents exports as a .zip and re-imports with its - // subagent transcripts restored (the export → import path the 对话 view drives). ---- - let bproj = base_dir.join("bundle-src").join("projects").join("-bnd-cwd"); - let _ = fs::create_dir_all(&bproj); - let bmain = bproj.join("bundsess.jsonl"); - let _ = fs::write(&bmain, "{\"type\":\"assistant\",\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"tool_use\",\"id\":\"tu9\",\"name\":\"Task\",\"input\":{}}]},\"cwd\":\"/bnd/cwd\",\"sessionId\":\"bundsess\",\"timestamp\":\"2025-01-01T10:00:00.000Z\"}\n"); - let bsub = bproj.join("bundsess").join("subagents"); - let _ = fs::create_dir_all(&bsub); - let _ = fs::write(bsub.join("agent-b1.jsonl"), "{\"type\":\"assistant\",\"isSidechain\":true,\"agentId\":\"b1\",\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"sub done\"}]},\"sessionId\":\"bundsess\",\"timestamp\":\"2025-01-01T10:00:01.000Z\"}\n"); - let _ = fs::write(bsub.join("agent-b1.meta.json"), "{\"agentType\":\"general-purpose\",\"description\":\"d\",\"toolUseId\":\"tu9\"}"); - let zip = export_bundle(&bmain.to_string_lossy()).unwrap_or_default(); - let zip_is_zip = zip.starts_with(&[0x50, 0x4b, 0x03, 0x04]); - let zip_path = base_dir.join("bundle-src").join("bundsess.zip"); - let _ = fs::write(&zip_path, &zip); - let rb = import_paths(&[zip_path.to_string_lossy().to_string()]); - let imp_dir = imports_root().join("projects").join("-bnd-cwd"); - let sub_restored = imp_dir.join("bundsess").join("subagents").join("agent-b1.jsonl").exists() - && imp_dir.join("bundsess").join("subagents").join("agent-b1.meta.json").exists(); - let bundle_sess = get_session(&imp_dir.join("bundsess.jsonl").to_string_lossy()); - let bundle_sub_count = bundle_sess.get("meta").and_then(|m| m.get("subagentCount")).and_then(|v| v.as_i64()).unwrap_or(0); - - json!({ - "imported": r.get("imported"), - "reskipped": r2.get("skipped"), - "appearsImported": found, - "removed": rm.get("ok"), - "gone": !dest.exists(), - "bundleZip": zip_is_zip, - "bundleImported": rb.get("imported"), - "bundleSubRestored": sub_restored, - "bundleSubagentCount": bundle_sub_count, - }) -} - -#[cfg(test)] -mod tests { - use super::*; - - // One work dir carrying ALL foreign layouts (grok sessions/%2F…, copilot session-state/, - // antigravity conversations/*.db): each session must list under its own source with cwd, - // title and detail routed through its shaper, hard-delete must refuse, and content search - // must reach every format. - #[test] - fn foreign_sources_route_end_to_end() { - let base = std::env::temp_dir().join("ccbud-foreign-route-test"); - let _ = fs::remove_dir_all(&base); - - // grok: sessions///chat_history.jsonl + summary.json - let gdir = base.join("sessions").join("%2Ftmp%2Fgproj").join("0199-grok-uuid"); - fs::create_dir_all(&gdir).unwrap(); - fs::write( - gdir.join("chat_history.jsonl"), - "{\"type\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"grok needle walrus\"}]}\n\ - {\"type\":\"assistant\",\"content\":\"done\",\"tool_calls\":[{\"id\":\"c1\",\"name\":\"run_terminal_command\",\"arguments\":\"{\\\"command\\\":\\\"ls\\\"}\"}]}\n", - ) - .unwrap(); - fs::write( - gdir.join("summary.json"), - "{\"info\":{\"id\":\"0199-grok-uuid\",\"cwd\":\"/tmp/gproj\"},\"generated_title\":\"Grok 会话\",\"created_at\":\"2026-06-18T06:27:07.777Z\",\"current_model_id\":\"grok-build\"}", - ) - .unwrap(); - // …and a stray sidecar jsonl the codex walker must NOT sweep into a session row - fs::write(gdir.join("events.jsonl"), "{\"ts\":\"x\",\"type\":\"mcp_config_resolved\"}\n").unwrap(); - - // copilot: session-state//events.jsonl + workspace.yaml - let cdir = base.join("session-state").join("cp-uuid-1"); - fs::create_dir_all(&cdir).unwrap(); - fs::write( - cdir.join("events.jsonl"), - "{\"type\":\"session.start\",\"data\":{\"sessionId\":\"cp-uuid-1\",\"context\":{\"cwd\":\"/tmp/cproj\"}},\"timestamp\":\"2026-07-12T07:26:54.363Z\"}\n\ - {\"type\":\"user.message\",\"data\":{\"content\":\"copilot needle pelican\"},\"timestamp\":\"2026-07-12T07:27:14.463Z\"}\n", - ) - .unwrap(); - fs::write( - cdir.join("workspace.yaml"), - "id: cp-uuid-1\ncwd: /tmp/cproj\nname: Copilot 会话\ncreated_at: 2026-07-12T07:26:54.368Z\n", - ) - .unwrap(); - - // antigravity: conversations/.db with one user step (hand-encoded wire format) - let adir = base.join("conversations"); - fs::create_dir_all(&adir).unwrap(); - let adb = adir.join("agy-uuid-1.db"); - { - fn enc_varint(mut v: u64, out: &mut Vec) { - loop { - let b = (v & 0x7f) as u8; - v >>= 7; - if v == 0 { - out.push(b); - break; - } - out.push(b | 0x80); - } - } - fn put_varint(field: u32, v: u64, out: &mut Vec) { - enc_varint(((field as u64) << 3) | 0, out); - enc_varint(v, out); - } - fn put_bytes(field: u32, data: &[u8], out: &mut Vec) { - enc_varint(((field as u64) << 3) | 2, out); - enc_varint(data.len() as u64, out); - out.extend_from_slice(data); - } - let mut ts = vec![]; - put_varint(1, 1_783_811_237, &mut ts); - let mut meta5 = vec![]; - put_bytes(1, &ts, &mut meta5); - let mut u19 = vec![]; - put_bytes(2, "agy needle capybara".as_bytes(), &mut u19); - let mut step = vec![]; - put_varint(1, 14, &mut step); - put_varint(4, 3, &mut step); - put_bytes(5, &meta5, &mut step); - put_bytes(19, &u19, &mut step); - let conn = rusqlite::Connection::open(&adb).unwrap(); - conn.execute_batch( - "CREATE TABLE steps (idx INTEGER PRIMARY KEY, step_type INTEGER NOT NULL DEFAULT 0, status INTEGER NOT NULL DEFAULT 0, step_payload BLOB);", - ) - .unwrap(); - conn.execute("INSERT INTO steps (idx, step_type, status, step_payload) VALUES (0, 14, 3, ?1)", [&step]) - .unwrap(); - } - { - let conn = rusqlite::Connection::open(base.join("conversation_summaries.db")).unwrap(); - conn.execute_batch( - "CREATE TABLE conversation_summaries (conversation_id TEXT PRIMARY KEY, title TEXT NOT NULL DEFAULT '', preview TEXT NOT NULL DEFAULT '', step_count INTEGER NOT NULL DEFAULT 0, last_modified_time DATETIME, workspace_uris TEXT NOT NULL DEFAULT '[]');", - ) - .unwrap(); - conn.execute( - "INSERT INTO conversation_summaries (conversation_id, title, preview, step_count, workspace_uris) VALUES ('agy-uuid-1', 'Agy 会话', 'p', 1, '[\"file:///tmp/aproj\"]')", - [], - ) - .unwrap(); - } - - let config = json!({ "historyDirs": [ base.to_string_lossy() ] }); - let rows = list_sessions(&config, "all", 50); - let by = |src: &str| { - rows.iter() - .find(|r| r.get("source").and_then(|v| v.as_str()) == Some(src)) - .unwrap_or_else(|| panic!("no {} row in {:?}", src, rows)) - .clone() - }; - // exactly one row per source — the grok dir's stray events.jsonl must not add a fourth - assert_eq!(rows.len(), 3, "rows: {:?}", rows); - let (g, c, a) = (by("grok"), by("copilot"), by("antigravity")); - assert_eq!(g["cwd"], "/tmp/gproj"); - assert_eq!(g["title"], "Grok 会话"); - assert_eq!(g["model"], "grok-build"); - assert_eq!(c["cwd"], "/tmp/cproj"); - assert_eq!(c["title"], "Copilot 会话"); - assert_eq!(a["cwd"], "/tmp/aproj"); - assert_eq!(a["title"], "Agy 会话"); - - // detail routes through each shaper (assistant name is the renderer's header/stat hook) - for (row, assistant, first_text) in [ - (&g, "Grok", "grok needle walrus"), - (&c, "Copilot", "copilot needle pelican"), - (&a, "Antigravity", "agy needle capybara"), - ] { - let file = row["file"].as_str().unwrap(); - let d = get_session(file); - assert_eq!(d["meta"]["assistant"], assistant); - assert_eq!(d["messages"][0]["content"][0]["text"], first_text); - // another tool's live file: delete-forever must refuse and leave it on disk - let del = delete_session_file(file, &config); - assert_eq!(del["reason"], "foreign"); - assert!(Path::new(file).is_file()); - } - - // content search reaches every format (agy has no raw-text prefilter path) - for needle in ["walrus", "pelican", "capybara"] { - let hits = search_sessions(&config, "all", needle, 10); - assert_eq!(hits.len(), 1, "search {}: {:?}", needle, hits); - } - - let _ = fs::remove_dir_all(&base); - } - - // Qoder writes Claude-like atomic event wrappers plus inline metadata into its own tree. - // Rows and detail must use that metadata, merge one assistant response's content blocks, - // retain queued commands as user turns, nest subagents, and remain searchable/exportable. - #[test] - fn qoder_sessions_route_end_to_end() { - let base = std::env::temp_dir().join("ccbud-qoder-route-test"); - let _ = fs::remove_dir_all(&base); - let root = base.join(".qoder"); - let proj = root.join("projects").join("-tmp-qproj"); - fs::create_dir_all(&proj).unwrap(); - let uuid = "11111111-1111-4111-8111-111111111111"; - let sess = proj.join(format!("{}.jsonl", uuid)); - let records = vec![ - json!({ "type": "agent-setting", "agentSetting": "triage", "entrypoint": "sdk-cli", "sessionId": uuid }), - json!({ "type": "last-prompt", "sessionId": uuid, "lastPrompt": "last prompt fallback" }), - json!({ "type": "ai-title", "sessionId": uuid, "aiTitle": "Qoder 会话" }), - json!({ "type": "workspace-directories", "sessionId": uuid, "directories": ["/tmp/qproj"] }), - json!({ "type": "runtime-config", "sessionId": uuid, "model": "ultimate", "reasoningEffort": "high" }), - json!({ - "type": "user", "uuid": "u1", "timestamp": "2026-06-04T09:47:27.966Z", - "message": { "role": "user", "content": "qoder needle axolotl" }, - "sessionId": uuid, "version": "1.1.13" - }), - json!({ - "type": "assistant", "uuid": "a1", "parentUuid": "u1", "timestamp": "2026-06-04T09:47:32.116Z", - "message": { "id": "msg_1", "type": "message", "role": "assistant", "model": "wire-model", "content": [ - { "type": "redacted_thinking", "data": "must not render" } - ]}, "sessionId": uuid - }), - json!({ - "type": "assistant", "uuid": "a2", "parentUuid": "a1", "timestamp": "2026-06-04T09:47:32.216Z", - "message": { "id": "msg_1", "type": "message", "role": "assistant", "content": [ - { "type": "thinking", "thinking": "considering" } - ]}, "sessionId": uuid - }), - json!({ - "type": "assistant", "uuid": "a3", "parentUuid": "a2", "timestamp": "2026-06-04T09:47:32.316Z", - "message": { "id": "msg_1", "type": "message", "role": "assistant", "content": [ - { "type": "text", "text": "done" } - ]}, "sessionId": uuid - }), - json!({ - "type": "assistant", "uuid": "a4", "parentUuid": "a3", "timestamp": "2026-06-04T09:47:32.416Z", - "message": { - "id": "msg_1", "type": "message", "role": "assistant", "stop_reason": "end_turn", - "usage": { "input_tokens": 100, "cache_creation_input_tokens": 7, "cache_read_input_tokens": 50, "output_tokens": 30 }, - "content": [{ "type": "tool_use", "id": "tu1", "name": "Task", "input": {} }] - }, "sessionId": uuid - }), - json!({ - "type": "attachment", "attachment": { "type": "queued_command", "prompt": "queued narwhal follow-up", "commandMode": false }, - "uuid": "u2", "parentUuid": "a4", "timestamp": "2026-06-04T09:47:35.000Z", "sessionId": uuid - }), - ]; - let raw = records - .iter() - .map(|record| serde_json::to_string(record).unwrap()) - .collect::>() - .join("\n") - + "\n"; - fs::write(&sess, raw).unwrap(); - let sub = proj.join(uuid).join("subagents"); - fs::create_dir_all(&sub).unwrap(); - fs::write( - sub.join("agent-q1.jsonl"), - format!("{{\"type\":\"assistant\",\"isSidechain\":true,\"agentId\":\"q1\",\"message\":{{\"role\":\"assistant\",\"content\":[{{\"type\":\"text\",\"text\":\"sub quetzal done\"}}]}},\"sessionId\":\"{}\",\"timestamp\":\"2026-06-04T09:47:40.000Z\"}}\n", uuid), - ) - .unwrap(); - fs::write( - sub.join("agent-q1.meta.json"), - "{\"agentType\":\"general-purpose\",\"description\":\"d\",\"toolUseId\":\"tu1\"}", - ) - .unwrap(); - - let config = json!({ "historyDirs": [ root.to_string_lossy() ] }); - let rows = list_sessions(&config, "all", 50); - assert_eq!(rows.len(), 1, "rows: {:?}", rows); - let r = &rows[0]; - assert_eq!(r["source"], "qoder"); - assert_eq!(r["id"], format!("qoder:{}", uuid)); - assert_eq!(r["title"], "Qoder 会话"); - assert_eq!(r["autoTitle"], "Qoder 会话"); - assert_eq!(r["cwd"], "/tmp/qproj"); - assert_eq!(r["model"], "ultimate"); - assert_eq!(r["deleted"], false); - - let file = r["file"].as_str().unwrap(); - let d = get_session(file); - assert_eq!(d["meta"]["assistant"], "Qoder"); - assert_eq!(d["meta"]["source"], "qoder"); - assert_eq!(d["meta"]["id"], format!("qoder:{}", uuid)); - assert_eq!(d["meta"]["title"], "Qoder 会话"); - assert_eq!(d["meta"]["model"], "ultimate"); - assert_eq!(d["meta"]["subagentCount"], 1); - assert_eq!(d["messages"].as_array().unwrap().len(), 3); - assert_eq!(d["messages"][0]["content"], "qoder needle axolotl"); // string-content user turn - let assistant_blocks = d["messages"][1]["content"].as_array().unwrap(); - assert_eq!( - assistant_blocks - .iter() - .filter_map(|block| block.get("type").and_then(Value::as_str)) - .collect::>(), - vec!["thinking", "text", "tool_use"] - ); - assert_eq!(d["messages"][1]["usage"]["inputTokens"], 100); - assert_eq!(d["messages"][1]["stopReason"], "end_turn"); - assert_eq!(d["messages"][2]["role"], "user"); - assert_eq!(d["messages"][2]["content"], "queued narwhal follow-up"); - assert_eq!(d["subagents"]["tu1"]["messages"][0]["content"][0]["text"], "sub quetzal done"); - - // another tool's live file: delete-forever must refuse and leave it on disk - let del = delete_session_file(file, &config); - assert_eq!(del["reason"], "foreign"); - assert!(Path::new(file).is_file()); - - // content search reaches the main thread and the subagent transcript - let hits = search_sessions(&config, "all", "axolotl", 10); - assert_eq!(hits.len(), 1, "{:?}", hits); - assert_eq!(hits[0]["agent"], "main"); - let hits = search_sessions(&config, "all", "narwhal", 10); - assert_eq!(hits.len(), 1, "{:?}", hits); - assert_eq!(hits[0]["agent"], "main"); - let hits = search_sessions(&config, "all", "quetzal", 10); - assert_eq!(hits.len(), 1, "{:?}", hits); - assert_eq!(hits[0]["agent"], "tu1"); - - let _ = fs::remove_dir_all(&base); - } - - #[test] - fn session_read_errors_are_structured() { - let base = std::env::temp_dir().join(format!( - "ccbud-session-read-error-test-{}", - std::process::id() - )); - let _ = fs::remove_dir_all(&base); - let project = base.join(".qoder").join("projects").join("-tmp-error"); - fs::create_dir_all(&project).unwrap(); - - let missing = project.join("missing.jsonl"); - let detail = get_session(&missing.to_string_lossy()); - assert_eq!(detail["error"]["kind"], "notFound"); - - let invalid = project.join("invalid.jsonl"); - fs::write(&invalid, [0xff, 0xfe]).unwrap(); - let detail = get_session(&invalid.to_string_lossy()); - assert_eq!(detail["error"]["kind"], "readFailed"); - - let denied = session_read_error( - &invalid, - &std::io::Error::new(std::io::ErrorKind::PermissionDenied, "denied"), - ); - assert_eq!(denied["error"]["kind"], "permissionDenied"); - - let _ = fs::remove_dir_all(&base); - } - - // The Rust search extractor and the renderer's stripInjected must agree: a task-notification - // envelope surfaces only its body — transport metadata must never be searchable. - #[test] - fn strip_injected_unwraps_task_notifications() { - let s = strip_injected( - "before \ncompleted\ntransport-noise\n\nDone **ok**\n\n after", - ); - assert!(s.contains("before") && s.contains("after")); - assert!(s.contains("Done **ok**")); - assert!(!s.contains("transport-noise")); - assert!(!s.contains("task-notification")); - // an envelope without a vanishes wholesale, like a system-reminder - let gone = strip_injected("x running y"); - assert!(gone.contains('x') && gone.contains('y') && !gone.contains("running")); - // the pre-existing rules still apply after the unwrap - assert_eq!(strip_injected("himeta"), "hi"); - // a standalone Codex injection vanishes; quoting one alongside prose does not - assert_eq!(strip_injected(" skill-body\n"), ""); - assert!(strip_injected("see quoted here").contains("quoted")); - } - - // The Codex AGENTS bootstrap must index as the SAME compact Markdown the panel renders - // (formatCodexBootstrap parity) — not as the raw XML-ish transport shape. - #[test] - fn strip_injected_formats_codex_bootstrap_like_the_panel() { - let raw = "# AGENTS.md instructions for /work/proj\n\n\nAlways run tests.\n\n\n\n /work/proj\n zsh\n /work/proj\n \n"; - let s = strip_injected(raw); - assert!(s.starts_with("# AGENTS.md instructions for /work/proj"), "{s}"); - assert!(s.contains("**INSTRUCTIONS:** Always run tests."), "{s}"); - assert!(s.contains("**environment_context:** `/work/proj`"), "{s}"); - assert!(s.contains("**shell:** zsh"), "{s}"); - assert!(s.contains("**workspace_roots:** `/work/proj`"), "{s}"); - assert!(s.contains("**permission_profile:** workspace-write"), "{s}"); - assert!(!s.contains("\na\nb\n"); - assert!(multi.contains("**INSTRUCTIONS:**\n\na\nb"), "{multi}"); - // ordinary prose is untouched - assert_eq!(strip_injected("ordinary prose"), "ordinary prose"); - } - - // Imported qoder content is rewritten to Claude shape (wrappers merged, queued commands - // materialized) with qoder's own title carried onto __ccbud__; Claude content passes through. - #[test] - fn qoder_imports_are_normalized_with_title() { - let recs = vec![ - json!({ "type": "ai-title", "aiTitle": "Qoder 导入标题" }), - json!({ "type": "assistant", "uuid": "w1", "message": { "id": "m1", "role": "assistant", "content": [{ "type": "thinking", "thinking": "t" }] } }), - json!({ "type": "assistant", "uuid": "w2", "message": { "id": "m1", "role": "assistant", "content": [{ "type": "text", "text": "done" }] } }), - json!({ "type": "attachment", "attachment": { "type": "queued_command", "prompt": "queued prompt" } }), - ]; - let (text, normalized) = qoder_import_raw(&recs).expect("sniffs as qoder"); - let assistants: Vec<&Value> = normalized.iter().filter(|r| r["type"] == "assistant").collect(); - assert_eq!(assistants.len(), 1, "wrappers merged: {:?}", normalized); - assert_eq!(assistants[0]["message"]["content"].as_array().unwrap().len(), 2); - assert!(normalized - .iter() - .any(|r| r["type"] == "user" && r["message"]["content"] == "queued prompt")); - let first: Value = serde_json::from_str(text.lines().next().unwrap()).unwrap(); - assert_eq!(first["__ccbud__"]["title"], "Qoder 导入标题"); - assert!(qoder_import_raw(&[json!({ "type": "user", "message": { "content": "hi" } })]).is_none()); - } - - // A live Codex rollout (a work dir's sessions/ tree, no .import.json) must NEVER be hard-deleted - // by "delete forever" — it's another tool's file. delete_session_file must refuse and leave it on - // disk. A Claude session in the same dir's projects/ tree is still deletable. - #[test] - fn delete_forever_refuses_live_codex_rollout() { - let base = std::env::temp_dir().join("ccbud-codex-del-test"); - let _ = fs::remove_dir_all(&base); - // codex rollout under /sessions/… - let sdir = base.join("sessions").join("2026").join("07").join("04"); - fs::create_dir_all(&sdir).unwrap(); - let codex_file = sdir.join("rollout-x.jsonl"); - fs::write( - &codex_file, - "{\"timestamp\":\"2026-07-04T00:00:00Z\",\"type\":\"session_meta\",\"payload\":{\"session_id\":\"x\",\"cwd\":\"/x\"}}\n\ - {\"timestamp\":\"2026-07-04T00:00:01Z\",\"type\":\"response_item\",\"payload\":{\"type\":\"message\",\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"hi\"}]}}\n", - ) - .unwrap(); - // claude session under /projects/… - let pdir = base.join("projects").join("-x"); - fs::create_dir_all(&pdir).unwrap(); - let claude_file = pdir.join("s1.jsonl"); - fs::write(&claude_file, "{\"type\":\"user\",\"message\":{\"role\":\"user\",\"content\":\"hi\"},\"cwd\":\"/x\",\"sessionId\":\"s1\"}\n").unwrap(); - - let config = json!({ "historyDirs": [ base.to_string_lossy() ] }); - - // live codex → refused, file survives - let r = delete_session_file(&codex_file.to_string_lossy(), &config); - assert_eq!(r.get("reason").and_then(|v| v.as_str()), Some("foreign"), "live codex must be refused"); - assert!(codex_file.is_file(), "codex rollout must NOT be deleted"); - - // claude session → deleted - let r2 = delete_session_file(&claude_file.to_string_lossy(), &config); - assert_eq!(r2.get("ok").and_then(|v| v.as_bool()), Some(true)); - assert!(!claude_file.is_file(), "claude session should be gone"); - - let _ = fs::remove_dir_all(&base); - } - - // Export a session-with-subagents and prove the .zip splits back into the main session + both - // subagent sidecars (the shape import_zip then writes into the store). Avoids mutating CCBUD_HOME - // so it can't race other threads under `cargo test`; the store round-trip is covered by the - // in-app import_selftest and confirms in review via write_imported (shared with import_one). - #[test] - fn export_bundle_round_trips_through_split() { - let base = std::env::temp_dir().join("ccbud-bundle-test"); - let _ = fs::remove_dir_all(&base); - let proj = base.join("projects").join("-bnd-cwd"); - fs::create_dir_all(&proj).unwrap(); - let main = proj.join("bundsess.jsonl"); - fs::write(&main, "{\"type\":\"assistant\",\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"tool_use\",\"id\":\"tu9\",\"name\":\"Task\",\"input\":{}}]},\"cwd\":\"/bnd/cwd\",\"sessionId\":\"bundsess\",\"timestamp\":\"2025-01-01T10:00:00.000Z\"}\n").unwrap(); - let sub = proj.join("bundsess").join("subagents"); - fs::create_dir_all(&sub).unwrap(); - fs::write(sub.join("agent-b1.jsonl"), "{\"type\":\"assistant\",\"isSidechain\":true,\"agentId\":\"b1\",\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"sub done\"}]},\"sessionId\":\"bundsess\",\"timestamp\":\"2025-01-01T10:00:01.000Z\"}\n").unwrap(); - fs::write(sub.join("agent-b1.meta.json"), "{\"agentType\":\"general-purpose\",\"description\":\"d\",\"toolUseId\":\"tu9\"}").unwrap(); - - assert!(session_has_subagents(&main.to_string_lossy())); - - let zip = export_bundle(&main.to_string_lossy()).unwrap(); - assert!(zip.starts_with(&[0x50, 0x4b, 0x03, 0x04]), "starts with PK local header"); - - let (m, subs) = crate::ziputil::split_bundle(crate::ziputil::read(&zip)); - assert_eq!(m.as_ref().map(|(n, _)| n.as_str()), Some("bundsess.jsonl")); - assert_eq!(subs.len(), 2); - assert!(subs.iter().any(|(n, d)| n == "agent-b1.jsonl" && String::from_utf8_lossy(d).contains("sub done"))); - assert!(subs.iter().any(|(n, _)| n == "agent-b1.meta.json")); - - let _ = fs::remove_dir_all(&base); - } - - // The list is ordered by the session's FIRST RECORD TIMESTAMP, not fs times — a title/tag - // edit rewrites the file via tmp+rename (which resets its fs birth time to "now") and must - // NOT reshuffle the list. - #[test] - fn list_order_survives_title_and_tag_edits() { - let base = std::env::temp_dir().join("ccbud-order-test"); - let _ = fs::remove_dir_all(&base); - let proj = base.join("projects").join("-ord-cwd"); - fs::create_dir_all(&proj).unwrap(); - let older = proj.join("older.jsonl"); - let newer = proj.join("newer.jsonl"); - fs::write(&older, "{\"type\":\"user\",\"message\":{\"role\":\"user\",\"content\":\"old one\"},\"cwd\":\"/ord/cwd\",\"sessionId\":\"older\",\"timestamp\":\"2025-01-01T10:00:00.000Z\"}\n").unwrap(); - fs::write(&newer, "{\"type\":\"user\",\"message\":{\"role\":\"user\",\"content\":\"new one\"},\"cwd\":\"/ord/cwd\",\"sessionId\":\"newer\",\"timestamp\":\"2025-06-01T10:00:00.000Z\"}\n").unwrap(); - let config = json!({ "historyDirs": [ base.to_string_lossy() ] }); - let order = |cfg: &Value| -> Vec { - list_sessions(cfg, "all", 50) - .iter() - .filter(|s| s.get("cwd").and_then(|v| v.as_str()) == Some("/ord/cwd")) - .map(|s| s.get("sessionId").and_then(|v| v.as_str()).unwrap_or("").to_string()) - .collect() - }; - assert_eq!(order(&config), vec!["newer", "older"], "newest record time first"); - - // Rename + tag the OLDER session: the file is rewritten through a fresh tmp inode, yet - // the list order must not change. - let r = set_ccbud(&older.to_string_lossy(), &json!({ "title": "Renamed", "tags": ["pinned"] }), &config); - assert_eq!(r.get("ok").and_then(|v| v.as_bool()), Some(true)); - assert_eq!(order(&config), vec!["newer", "older"], "tag/title edit must not reshuffle"); - - // And the row's createdAt still reflects the record timestamp, not the rewrite moment, - // while the edited title shows up immediately (list-meta memo invalidated by the write). - let rows = list_sessions(&config, "all", 50); - let row = rows.iter().find(|s| s.get("sessionId").and_then(|v| v.as_str()) == Some("older")).unwrap(); - let want = chrono::DateTime::parse_from_rfc3339("2025-01-01T10:00:00.000Z").unwrap().timestamp_millis() as f64; - assert_eq!(row.get("createdAt").and_then(|v| v.as_f64()), Some(want)); - assert_eq!(row.get("title").and_then(|v| v.as_str()), Some("Renamed")); - - let _ = fs::remove_dir_all(&base); - } - - // Content search: a main-thread hit reports agent "main"; a subagent-only hit reports the - // spawning tool_use key (+ agent type); injected text never matches; and - // ASCII case folds. Runs twice so the second pass exercises the extraction cache. - #[test] - fn search_sessions_finds_main_and_subagent_content() { - let base = std::env::temp_dir().join("ccbud-search-test"); - let _ = fs::remove_dir_all(&base); - let proj = base.join("projects").join("-srch-cwd"); - fs::create_dir_all(&proj).unwrap(); - let main = proj.join("srchsess.jsonl"); - fs::write( - &main, - "{\"type\":\"user\",\"message\":{\"role\":\"user\",\"content\":\"find the zebra crossingreminder-secret\"},\"cwd\":\"/srch/cwd\",\"sessionId\":\"srchsess\",\"timestamp\":\"2025-01-01T10:00:00.000Z\"}\n\ - {\"type\":\"assistant\",\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"tool_use\",\"id\":\"tu1\",\"name\":\"Task\",\"input\":{}}]},\"sessionId\":\"srchsess\",\"timestamp\":\"2025-01-01T10:00:01.000Z\"}\n", - ) - .unwrap(); - let sub = proj.join("srchsess").join("subagents"); - fs::create_dir_all(&sub).unwrap(); - fs::write( - sub.join("agent-s1.jsonl"), - "{\"type\":\"assistant\",\"isSidechain\":true,\"agentId\":\"s1\",\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"the quokka was found here\"}]},\"sessionId\":\"srchsess\",\"timestamp\":\"2025-01-01T10:00:02.000Z\"}\n", - ) - .unwrap(); - fs::write(sub.join("agent-s1.meta.json"), "{\"agentType\":\"explore\",\"description\":\"d\",\"toolUseId\":\"tu1\"}").unwrap(); - // Content stored as \uXXXX escapes (e.g. python json.dumps output) — a byte scan can't - // see the decoded text, so non-ASCII queries must bypass the raw prefilter. - let esc = proj.join("escsess.jsonl"); - fs::write( - &esc, - "{\"type\":\"user\",\"message\":{\"role\":\"user\",\"content\":\"\\u4e2d\\u6587\\u5185\\u5bb9 escaped\"},\"cwd\":\"/srch/cwd\",\"sessionId\":\"escsess\",\"timestamp\":\"2025-01-02T10:00:00.000Z\"}\n", - ) - .unwrap(); - // A codex rollout in the same work dir's sessions/ tree — its own record format, scanned - // through the codex shaper. - let cdir = base.join("sessions").join("2026").join("07").join("04"); - fs::create_dir_all(&cdir).unwrap(); - fs::write( - cdir.join("rollout-c.jsonl"), - "{\"timestamp\":\"2026-07-04T00:00:00Z\",\"type\":\"session_meta\",\"payload\":{\"session_id\":\"c1\",\"cwd\":\"/cx\"}}\n\ - {\"timestamp\":\"2026-07-04T00:00:01Z\",\"type\":\"response_item\",\"payload\":{\"type\":\"message\",\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"codex kangaroo request\"}]}}\n", - ) - .unwrap(); - let config = json!({ "historyDirs": [ base.to_string_lossy() ] }); - - for pass in 0..2 { - // main-thread hit - let hits = search_sessions(&config, "all", "zebra crossing", 50); - assert_eq!(hits.len(), 1, "pass {}: one session matches", pass); - assert_eq!(hits[0].get("agent").and_then(|v| v.as_str()), Some("main")); - assert!(hits[0].get("snippet").and_then(|v| v.as_str()).unwrap_or("").contains("zebra")); - - // subagent-only hit → keyed by the spawning tool_use id, labeled with the agent type - let hits = search_sessions(&config, "all", "QUOKKA", 50); // also proves case folding - assert_eq!(hits.len(), 1); - assert_eq!(hits[0].get("agent").and_then(|v| v.as_str()), Some("tu1")); - assert_eq!(hits[0].get("agentType").and_then(|v| v.as_str()), Some("explore")); - - // codex rollout content is searchable too - let hits = search_sessions(&config, "all", "kangaroo", 50); - assert_eq!(hits.len(), 1, "pass {}: codex rollout matches", pass); - assert_eq!(hits[0].get("agent").and_then(|v| v.as_str()), Some("main")); - - // \uXXXX-escaped content still matches a non-ASCII query (no raw prefilter for those) - let hits = search_sessions(&config, "all", "中文", 50); - assert_eq!(hits.len(), 1, "pass {}: escaped unicode content matches", pass); - assert!(hits[0].get("snippet").and_then(|v| v.as_str()).unwrap_or("").contains("中文内容")); - - // injected system-reminder content is NOT searchable (matches the renderer) - assert!(search_sessions(&config, "all", "reminder-secret", 50).is_empty()); - // no match at all - assert!(search_sessions(&config, "all", "wombat", 50).is_empty()); - } - - let _ = fs::remove_dir_all(&base); - } - - #[test] - fn subagent_transcript_paths_lists_only_agent_jsonl() { - let base = std::env::temp_dir().join("ccbud-subpaths-test"); - let _ = fs::remove_dir_all(&base); - let proj = base.join("projects").join("-m-cwd"); - fs::create_dir_all(&proj).unwrap(); - let main = proj.join("m.jsonl"); - fs::write(&main, "{\"type\":\"user\",\"message\":{\"role\":\"user\",\"content\":\"hi\"},\"sessionId\":\"m\"}\n").unwrap(); - // no subagents → empty (caller attaches only the main file) - assert!(subagent_transcript_paths(&main.to_string_lossy()).is_empty()); - - let sub = proj.join("m").join("subagents"); - fs::create_dir_all(&sub).unwrap(); - fs::write(sub.join("agent-a.jsonl"), "{}\n").unwrap(); - fs::write(sub.join("agent-b.jsonl"), "{}\n").unwrap(); - fs::write(sub.join("agent-a.meta.json"), "{}").unwrap(); // sidecar must be excluded - - let paths = subagent_transcript_paths(&main.to_string_lossy()); - assert_eq!(paths.len(), 2, "only the two agent-*.jsonl, not the .meta.json"); - assert!(paths.iter().all(|p| p.ends_with(".jsonl"))); - assert!(paths.iter().any(|p| p.ends_with("agent-a.jsonl"))); - assert!(paths.iter().any(|p| p.ends_with("agent-b.jsonl"))); - - let _ = fs::remove_dir_all(&base); - } -} diff --git a/src-tauri/src/history/codexdedupe.rs b/src-tauri/src/history/codexdedupe.rs new file mode 100644 index 0000000..484419b --- /dev/null +++ b/src-tauri/src/history/codexdedupe.rs @@ -0,0 +1,146 @@ +use serde_json::Value; +use std::path::Path; + +fn canonical_codex_key(session: &Value) -> Option { + if session.get("source").and_then(Value::as_str) != Some("codex") { + return None; + } + if session.get("canonicalThreadIdValid").and_then(Value::as_bool) != Some(true) { + return None; + } + let thread_id = session.get("threadId").and_then(Value::as_str)?; + let dir_id = session.get("dirId").and_then(Value::as_str).unwrap_or(""); + Some(format!("{dir_id}\0{thread_id}")) +} + +fn codex_canonical_filename(session: &Value) -> bool { + let Some(thread_id) = session.get("threadId").and_then(Value::as_str) else { + return false; + }; + let Some(file) = session.get("file").and_then(Value::as_str) else { + return false; + }; + let stem = Path::new(file).file_stem().and_then(|value| value.to_str()).unwrap_or(""); + stem == thread_id || stem.strip_suffix(thread_id).is_some_and(|prefix| prefix.ends_with('-')) +} + +fn codex_candidate_preferred(candidate: &Value, current: &Value) -> bool { + let candidate_file = candidate.get("file").and_then(Value::as_str).unwrap_or(""); + let current_file = current.get("file").and_then(Value::as_str).unwrap_or(""); + let thread_id = candidate + .get("threadId") + .and_then(Value::as_str) + .or_else(|| current.get("threadId").and_then(Value::as_str)) + .unwrap_or(""); + + // Codex's completed state DB is authoritative when its rollout_path still exists. Both + // candidates already passed ccbud's first-SessionMeta parse, so a matching path also verifies + // that the DB row belongs to this canonical id. + let preferred_path = [candidate_file, current_file] + .into_iter() + .filter(|file| !file.is_empty()) + .find_map(|file| crate::codex::preferred_rollout_path(Path::new(file), thread_id)); + if let Some(preferred) = preferred_path { + let candidate_matches = Path::new(candidate_file) == preferred.as_path(); + let current_matches = Path::new(current_file) == preferred.as_path(); + if candidate_matches != current_matches { + return candidate_matches; + } + } + + let imported = |value: &Value| value.get("imported").and_then(Value::as_bool).unwrap_or(false); + if imported(candidate) != imported(current) { + return !imported(candidate); + } + let archived = |file: &str| { + Path::new(file) + .components() + .any(|part| part.as_os_str().to_str() == Some("archived_sessions")) + }; + if archived(candidate_file) != archived(current_file) { + return !archived(candidate_file); + } + let number = |value: &Value, field: &str| value.get(field).and_then(Value::as_f64).unwrap_or(0.0); + for field in ["lastActivity", "createdAt"] { + let candidate_value = number(candidate, field); + let current_value = number(current, field); + if candidate_value != current_value { + return candidate_value > current_value; + } + } + if codex_canonical_filename(candidate) != codex_canonical_filename(current) { + return codex_canonical_filename(candidate); + } + let candidate_size = number(candidate, "sizeKB"); + let current_size = number(current, "sizeKB"); + if candidate_size != current_size { + return candidate_size > current_size; + } + candidate_file > current_file +} + +pub(super) fn dedupe_canonical_codex_sessions(sessions: Vec) -> Vec { + let mut out = Vec::with_capacity(sessions.len()); + let mut positions = std::collections::HashMap::::new(); + for session in sessions { + let Some(key) = canonical_codex_key(&session) else { + out.push(session); + continue; + }; + if let Some(index) = positions.get(&key).copied() { + if codex_candidate_preferred(&session, &out[index]) { + out[index] = session; + } + } else { + positions.insert(key, out.len()); + out.push(session); + } + } + out +} + +pub(super) fn limit_with_codex_ancestors(sessions: Vec, limit: usize) -> Vec { + if sessions.len() <= limit { + return sessions; + } + let mut positions = std::collections::HashMap::::new(); + for (index, session) in sessions.iter().enumerate() { + if let Some(key) = canonical_codex_key(session) { + positions.insert(key, index); + } + } + let mut included: std::collections::HashSet = (0..limit).collect(); + let mut queue: Vec = (0..limit).collect(); + let mut cursor = 0usize; + while cursor < queue.len() { + let index = queue[cursor]; + cursor += 1; + let session = &sessions[index]; + if canonical_codex_key(session).is_none() { + continue; + } + let dir_id = session.get("dirId").and_then(Value::as_str).unwrap_or(""); + let direct_parent = session.get("parentThreadId").and_then(Value::as_str); + let root_parent = session + .get("isSubagent") + .and_then(Value::as_bool) + .unwrap_or(false) + .then(|| session.get("rootSessionId").and_then(Value::as_str)) + .flatten(); + let parent_index = [direct_parent, root_parent] + .into_iter() + .flatten() + .find_map(|parent_id| positions.get(&format!("{dir_id}\0{parent_id}")).copied()); + let Some(parent_index) = parent_index else { + continue; + }; + if included.insert(parent_index) { + queue.push(parent_index); + } + } + sessions + .into_iter() + .enumerate() + .filter_map(|(index, session)| included.contains(&index).then_some(session)) + .collect() +} diff --git a/src-tauri/src/history/edit.rs b/src-tauri/src/history/edit.rs new file mode 100644 index 0000000..0aa8652 --- /dev/null +++ b/src-tauri/src/history/edit.rs @@ -0,0 +1,189 @@ +use serde_json::{json, Value}; +use std::fs; +use std::path::Path; + +use super::foreign::{foreign_kind, Foreign}; +use super::jsonl::{parse_lines, read_head}; +use super::meta::meta_cache; +use super::paths::{all_dirs, sibling_dir}; +use super::session::read_import_meta; + +/// Write per-conversation customization (custom title + tags) onto the FIRST parseable line as a +/// `__ccbud__` field. Atomic (tmp + rename). Guarded to the configured dirs + the imports store +/// (renderer can't drive an arbitrary-path write, but imported sessions must be titleable/taggable +/// too — mirrors history.js setCcbud, whose getDirs() includes the imported dir). +pub fn set_ccbud(file: &str, patch: &Value, config: &Value) -> Value { + let target = Path::new(file); + if !within_scope(target, config) { + return json!({ "ok": false, "reason": "out-of-scope" }); + } + // Foreign-CLI sessions are other tools' files (one is SQLite): their title/tags/delete + // flag always live in the app-owned sidecar. Same cache-drop contract as the codex branch. + if let Some(fk) = foreign_kind(target) { + let r = match fk { + Foreign::Grok => crate::grok::set_meta(file, patch), + Foreign::Copilot => crate::copilot::set_meta(file, patch), + Foreign::Antigravity => crate::antigravity::set_meta(file, patch), + }; + if r.get("ok").and_then(|v| v.as_bool()).unwrap_or(false) { + if let Ok(mut cache) = meta_cache().lock() { + cache.remove(target); + } + } + return r; + } + // Qoder sessions are Claude-format but another tool's live files: title/tags/delete go to + // the shared sidecar (keyed qoder:) instead of an in-file rewrite. The sidecar edit + // doesn't touch the file (no mtime bump), so the list-meta memo is dropped by hand. + if crate::qoder::looks_qoder_path(target) { + let r = crate::qoder::set_meta(file, patch); + if r.get("ok").and_then(|v| v.as_bool()).unwrap_or(false) { + if let Ok(mut cache) = meta_cache().lock() { + cache.remove(target); + } + } + return r; + } + let raw = match fs::read_to_string(file) { + Ok(s) => s, + Err(_) => return json!({ "ok": false, "reason": "read" }), + }; + // Live Codex rollouts are another tool's files — their title/tags/delete flag live in the + // app-owned sidecar instead of being written into the rollout. Imported codex COPIES sit + // inside our store (marked by .import.json) and take the normal in-file path below. + let head: Vec = raw.lines().take(8).filter_map(|l| serde_json::from_str(l.trim()).ok()).collect(); + if crate::codex::looks_codex(&head) && read_import_meta(file).is_none() { + let r = crate::codex::set_meta(file, patch); + // A sidecar edit changes the row without touching the rollout file (no mtime bump), so + // the list-meta memo must be dropped by hand. In-file writes below invalidate via mtime. + if r.get("ok").and_then(|v| v.as_bool()).unwrap_or(false) { + if let Ok(mut cache) = meta_cache().lock() { + cache.remove(target); + } + } + return r; + } + let mut lines: Vec = raw.split('\n').map(|s| s.to_string()).collect(); + let mut found: Option<(usize, Value)> = None; + for (i, l) in lines.iter().enumerate() { + let s = l.trim(); + if s.is_empty() { + continue; + } + if let Ok(v) = serde_json::from_str::(s) { + if v.is_object() { + found = Some((i, v)); + break; + } + } + } + let (idx, mut obj) = match found { + Some(x) => x, + None => return json!({ "ok": false, "reason": "empty" }), + }; + let mut next = obj.get("__ccbud__").and_then(|v| v.as_object()).cloned().unwrap_or_default(); + if let Some(t) = patch.get("title") { + let t = t.as_str().unwrap_or("").trim().to_string(); + if !t.is_empty() { + next.insert("title".into(), json!(t)); + } else { + next.remove("title"); + } + } + if let Some(tags) = patch.get("tags") { + let mut arr: Vec = vec![]; + if let Some(ta) = tags.as_array() { + for x in ta { + if let Some(s) = x.as_str() { + let s = s.trim(); + if !s.is_empty() && !arr.iter().any(|y| y == s) { + arr.push(s.to_string()); + } + } + } + } + if !arr.is_empty() { + next.insert("tagList".into(), json!(arr)); + } else { + next.remove("tagList"); + } + } + // Soft delete / restore: `delete: true` marks the session deleted; `delete: false` (restore) + // drops the flag. Restore that empties __ccbud__ removes the field wholesale below. + if let Some(d) = patch.get("delete") { + if d.as_bool().unwrap_or(false) { + next.insert("delete".into(), json!(true)); + } else { + next.remove("delete"); + } + } + let o = obj.as_object_mut().unwrap(); + if !next.is_empty() { + o.insert("__ccbud__".into(), Value::Object(next)); + } else { + o.remove("__ccbud__"); + } + lines[idx] = serde_json::to_string(&obj).unwrap_or_default(); + let out = lines.join("\n"); + let tmp = format!("{}.ccbud.tmp", file); + if fs::write(&tmp, &out).is_err() { + return json!({ "ok": false, "reason": "write" }); + } + if fs::rename(&tmp, file).is_err() { + let _ = fs::remove_file(&tmp); + return json!({ "ok": false, "reason": "write" }); + } + // The rewrite bumps mtime/size, which already invalidates the list-meta memo — dropping the + // entry outright also covers a same-millisecond, same-length rewrite. + if let Ok(mut cache) = meta_cache().lock() { + cache.remove(target); + } + json!({ "ok": true }) +} + +/// Permanently remove a session's .jsonl from disk (recycle-bin "delete forever"). Guarded to the +/// configured dirs + the imports store exactly like set_ccbud, and also drops the session's +/// `/` subagents tree and any import sidecar (mirrors remove_import's cleanup). +/// Renderer-driven writes/deletes are confined to the configured work dirs' data trees +/// (projects/ AND sessions/) plus the imports store. +fn within_scope(target: &Path, config: &Value) -> bool { + all_dirs(config).iter().any(|(_, _, pd)| { + target.starts_with(pd) + || ["sessions", "session-state", "conversations"] + .iter() + .any(|n| sibling_dir(pd, n).map(|sd| target.starts_with(sd)).unwrap_or(false)) + }) +} + +pub fn delete_session_file(file: &str, config: &Value) -> Value { + let target = Path::new(file); + if !within_scope(target, config) { + return json!({ "ok": false, "reason": "out-of-scope" }); + } + if !target.is_file() { + return json!({ "ok": false, "reason": "missing" }); + } + // A LIVE Codex rollout, Qoder session, or foreign-CLI session is another tool's file — the + // app only ever soft-deletes those via the sidecar and never rewrites them (see set_ccbud), + // so "delete forever" must not rm the source either. Imported codex COPIES (marked by an + // .import.json) are our own snapshots and stay hard-deletable, like Claude sessions the app + // manages in the configured dirs. + if foreign_kind(target).is_some() || crate::qoder::looks_qoder_path(target) { + return json!({ "ok": false, "reason": "foreign" }); + } + let head = parse_lines(&read_head(target, 131072)); + if crate::codex::looks_codex(&head) && read_import_meta(file).is_none() { + return json!({ "ok": false, "reason": "foreign" }); + } + if fs::remove_file(target).is_err() { + return json!({ "ok": false, "reason": "remove" }); + } + crate::codex::remove_meta(file); // drop any codex sidecar entry (no-op for Claude sessions) + let dir = target.parent().unwrap_or(Path::new(".")); + let stem = target.file_stem().and_then(|s| s.to_str()).unwrap_or(""); + if !stem.is_empty() { + let _ = fs::remove_dir_all(dir.join(stem)); // /subagents/... + let _ = fs::remove_file(dir.join(format!("{}.import.json", stem))); + } + json!({ "ok": true }) +} diff --git a/src-tauri/src/history/foreign.rs b/src-tauri/src/history/foreign.rs new file mode 100644 index 0000000..fe24d35 --- /dev/null +++ b/src-tauri/src/history/foreign.rs @@ -0,0 +1,103 @@ +use std::path::{Path, PathBuf}; + +use super::jsonl::{mtime_ms, parse_lines, read_head}; +use super::session::read_import_meta; +use super::text::read_ccbud; + +/// The foreign-CLI session sources routed by CONTAINER SHAPE (their path layouts are +/// distinctive per tool, and one of them isn't even jsonl) — content sniffing stays reserved +/// for the historical Claude-vs-Codex jsonl split. +#[derive(Clone, Copy, PartialEq)] +pub(crate) enum Foreign { + Grok, + Copilot, + Antigravity, +} + +pub(crate) fn foreign_kind(file: &Path) -> Option { + if crate::grok::looks_grok_path(file) { + return Some(Foreign::Grok); + } + if crate::copilot::looks_copilot_path(file) { + return Some(Foreign::Copilot); + } + if crate::antigravity::looks_agy_path(file) { + return Some(Foreign::Antigravity); + } + None +} + +/// Cached soft-delete verdict for one file: a Claude session's flag (rides its first line, so it's +/// final for a given mtime), or "this belongs to another CLI" (Codex rollout / Qoder session / +/// foreign source, whose flag lives in a sidecar and can flip WITHOUT touching the file — so only +/// the format verdict is cached, never the flag). +#[derive(Clone, Copy)] +enum DelKind { + Claude(bool), + Codex, + Qoder, + Foreign(Foreign), +} + +/// Process-lifetime memo of soft-delete status, keyed `path -> (mtime, kind)`. mtime is the +/// invalidation signal: set_ccbud rewrites a Claude file (bumping mtime) whenever the flag flips, +/// so a matching mtime means the cached answer is still valid. This lets dir_stats *stat* +/// unchanged sessions on each refresh instead of re-reading them. +fn deleted_cache() -> &'static std::sync::Mutex> { + static CACHE: std::sync::OnceLock>> = + std::sync::OnceLock::new(); + CACHE.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new())) +} + +/// Cheap soft-delete probe for counting, memoized by mtime. A Claude session's `__ccbud__.delete` +/// rides on the first parseable line, so a small head read suffices; a Codex rollout's flag is +/// re-read from the sidecar every time (itself mtime-cached and cheap). +pub(super) fn is_session_deleted(file: &Path) -> bool { + let mt = mtime_ms(file); + let cached: Option = deleted_cache() + .lock() + .ok() + .and_then(|c| c.get(file).filter(|(cmt, _)| *cmt == mt).map(|(_, k)| *k)); + let kind = cached.unwrap_or_else(|| { + // Foreign sources are recognized by path shape alone — no read needed. Qoder is + // Claude-FORMAT but another tool's file, so its flag lives in the sidecar too. + let kind = if let Some(fk) = foreign_kind(file) { + DelKind::Foreign(fk) + } else if crate::qoder::looks_qoder_path(file) { + DelKind::Qoder + } else { + // Read the same window session_meta uses: a Codex rollout's first (session_meta) line + // embeds the full system prompt (~22 KB), so a smaller head truncates it, parse yields + // nothing, and the session mis-sniffs as Claude — desyncing dir vs trash counts. + let recs = parse_lines(&read_head(file, 131072)); + // Imported codex COPIES carry the flag in-file like Claude sessions (see set_ccbud) — + // only live rollouts (no .import.json) use the sidecar. + if crate::codex::looks_codex(&recs) && read_import_meta(&file.to_string_lossy()).is_none() { + DelKind::Codex + } else { + DelKind::Claude(read_ccbud(&recs).2) + } + }; + if let Ok(mut cache) = deleted_cache().lock() { + cache.insert(file.to_path_buf(), (mt, kind)); + } + kind + }); + match kind { + DelKind::Claude(del) => del, + DelKind::Codex => crate::codex::is_deleted(file), + DelKind::Qoder => crate::qoder::is_deleted(file), + DelKind::Foreign(Foreign::Grok) => crate::grok::is_deleted(file), + DelKind::Foreign(Foreign::Copilot) => crate::copilot::is_deleted(file), + DelKind::Foreign(Foreign::Antigravity) => crate::antigravity::is_deleted(file), + } +} + +/// Freshness stamp for the list-meta / search caches: plain mtime, except Antigravity DBs +/// where a live agy writes into the WAL without touching the main file. +pub(super) fn cache_stamp_ms(file: &Path) -> f64 { + match foreign_kind(file) { + Some(Foreign::Antigravity) => crate::antigravity::wal_mtime_ms(file), + _ => mtime_ms(file), + } +} diff --git a/src-tauri/src/history/foreign_probe.rs b/src-tauri/src/history/foreign_probe.rs new file mode 100644 index 0000000..1efdeb5 --- /dev/null +++ b/src-tauri/src/history/foreign_probe.rs @@ -0,0 +1,68 @@ +use serde_json::{json, Value}; + +use super::list::list_sessions; +use super::session::get_session; + +// Diagnostic harness (not an assertion): list + open REAL foreign-CLI sessions so the +// shapers can be eyeballed against live ~/.grok, ~/.copilot, ~/.gemini/antigravity-cli. +// Run: CCBUD_PROBE_FOREIGN="~/.grok,~/.copilot,~/.gemini/antigravity-cli" \ +// cargo test --lib probe_foreign_dirs -- --ignored --nocapture +#[test] +#[ignore] +fn probe_foreign_dirs() { + let Ok(dirs) = std::env::var("CCBUD_PROBE_FOREIGN") else { + eprintln!("set CCBUD_PROBE_FOREIGN=dir1,dir2,…"); + return; + }; + let list: Vec<&str> = dirs.split(',').map(|s| s.trim()).filter(|s| !s.is_empty()).collect(); + let config = json!({ "historyDirs": list }); + let sessions = list_sessions(&config, "all", 500); + eprintln!("== {} sessions across {:?}", sessions.len(), list); + let mut by_source: std::collections::HashMap = std::collections::HashMap::new(); + for s in &sessions { + *by_source + .entry(s.get("source").and_then(|v| v.as_str()).unwrap_or("?").to_string()) + .or_insert(0) += 1; + } + eprintln!("== by source: {:?}", by_source); + let mut seen: std::collections::HashSet = std::collections::HashSet::new(); + for s in &sessions { + let src = s.get("source").and_then(|v| v.as_str()).unwrap_or("?").to_string(); + if !seen.insert(src.clone()) { + continue; + } + let file = s.get("file").and_then(|v| v.as_str()).unwrap_or(""); + eprintln!( + "-- [{}] {} | cwd={} | title={:?}", + src, + file, + s.get("cwd").and_then(|v| v.as_str()).unwrap_or("-"), + s.get("title").and_then(|v| v.as_str()).unwrap_or("-") + ); + let detail = get_session(file); + let meta = detail.get("meta").cloned().unwrap_or(Value::Null); + let msgs = detail.get("messages").and_then(|v| v.as_array()).map(|a| a.len()).unwrap_or(0); + eprintln!( + " detail: assistant={:?} messages={} totals={} firstTs={:?}", + meta.get("assistant").and_then(|v| v.as_str()), + msgs, + meta.get("totals").map(|t| t.to_string()).unwrap_or_default(), + meta.get("firstTs").and_then(|v| v.as_str()) + ); + if let Some(arr) = detail.get("messages").and_then(|v| v.as_array()) { + for m in arr.iter().take(4) { + let role = m.get("role").and_then(|v| v.as_str()).unwrap_or("?"); + let kinds: Vec = m + .get("content") + .and_then(|c| c.as_array()) + .map(|a| { + a.iter() + .map(|b| b.get("type").and_then(|t| t.as_str()).unwrap_or("?").to_string()) + .collect() + }) + .unwrap_or_default(); + eprintln!(" msg {} {:?}", role, kinds); + } + } + } +} diff --git a/src-tauri/src/history/import.rs b/src-tauri/src/history/import.rs new file mode 100644 index 0000000..8956a63 --- /dev/null +++ b/src-tauri/src/history/import.rs @@ -0,0 +1,151 @@ +// ---- import (copy someone else's .jsonl into the app-managed store) ---- + +use serde_json::{json, Value}; +use std::fs; +use std::path::Path; + +use super::jsonl::parse_lines; +use super::paths::imports_root; + +fn encode_cwd(cwd: Option<&str>) -> String { + match cwd { + Some(c) if !c.is_empty() => c.replace(['/', '\\'], "-"), + _ => "-imported".to_string(), + } +} + +fn copy_dir(src: &Path, dst: &Path) -> std::io::Result<()> { + fs::create_dir_all(dst)?; + for e in fs::read_dir(src)? { + let e = e?; + let s = e.path(); + let d = dst.join(e.file_name()); + if s.is_dir() { + copy_dir(&s, &d)?; + } else { + fs::copy(&s, &d)?; + } + } + Ok(()) +} + +fn now_ms() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as i64) + .unwrap_or(0) +} + +fn records_to_jsonl(records: &[Value]) -> String { + let mut text = records + .iter() + .map(|r| serde_json::to_string(r).unwrap_or_default()) + .collect::>() + .join("\n"); + text.push('\n'); + text +} + +/// A qoder transcript imported into the app store loses its container path and would otherwise +/// re-parse as raw Claude records: queued commands vanish, redacted duplicates render, atomic +/// wrappers stay fragmented, and qoder's own title is lost. Sniffed by CONTENT (import copies and +/// bundle zips carry no .qoder path), the copy is rewritten up front — normalized records, with +/// the qoder title carried onto the first line's __ccbud__ so the import keeps its name. +pub(super) fn qoder_import_raw(recs: &[Value]) -> Option<(String, Vec)> { + if !crate::qoder::looks_qoder_records(recs) { + return None; + } + let mut normalized = crate::qoder::normalize_records(recs); + if let Some(title) = crate::qoder::session_title_from(recs) { + if let Some(first) = normalized.iter_mut().find(|r| r.is_object()) { + let obj = first.as_object_mut().unwrap(); + let mut cc = obj.get("__ccbud__").and_then(|v| v.as_object()).cloned().unwrap_or_default(); + cc.entry("title".to_string()).or_insert_with(|| json!(title)); + obj.insert("__ccbud__".into(), Value::Object(cc)); + } + } + Some((records_to_jsonl(&normalized), normalized)) +} + +/// Snapshot a transcript (already read into `raw`) plus its subagent sidecars into the import store, +/// laid out like a native projects/ tree + a provenance sidecar. `subagents`: (filename, bytes) to +/// drop under `/subagents/` — names are basename-reduced and pattern-checked so a crafted +/// entry can't escape the directory. Returns 1 = imported, 2 = skipped (already present), +/// 0 = failed/not-a-transcript. Shared by the plain-.jsonl and .zip-bundle import paths. +pub(super) fn write_imported(raw: &str, original_path: &str, original_name: &str, subagents: &[(String, Vec)]) -> i32 { + let recs = parse_lines(raw); + let is_codex = crate::codex::looks_codex(&recs); + // Qoder content is rewritten to Claude shape before storing — the has_msg gate below then + // sees the materialized queued-command user turns too. + let (qoder_text, recs) = match qoder_import_raw(&recs) { + Some((text, normalized)) => (Some(text), normalized), + None => (None, recs), + }; + let raw = qoder_text.as_deref().unwrap_or(raw); + let has_msg = recs.iter().any(|r| { + let t = r.get("type").and_then(|v| v.as_str()); + (t == Some("user") || t == Some("assistant")) && r.get("message").is_some() + }); + if !has_msg && !is_codex { + return 0; + } + let name_stem = || Path::new(original_name).file_stem().and_then(|s| s.to_str()).unwrap_or("import").to_string(); + // Codex rollouts keep cwd/session id inside the session_meta payload, not on the records. + let (cwd_owned, base_id) = if is_codex { + let (c, s) = crate::codex::head_ids(&recs); + (c, s.unwrap_or_else(name_stem)) + } else { + let meta_rec = recs.iter().find(|r| r.get("cwd").is_some()).or_else(|| recs.iter().find(|r| r.get("sessionId").is_some())); + ( + meta_rec.and_then(|r| r.get("cwd")).and_then(|v| v.as_str()).map(|s| s.to_string()), + meta_rec + .and_then(|r| r.get("sessionId")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + .unwrap_or_else(name_stem), + ) + }; + let cwd = cwd_owned.as_deref(); + let dest_dir = imports_root().join("projects").join(encode_cwd(cwd)); + let dest_file = dest_dir.join(format!("{}.jsonl", base_id)); + if dest_file.exists() { + return 2; + } + if fs::create_dir_all(&dest_dir).is_err() || fs::write(&dest_file, raw).is_err() { + return 0; + } + if !subagents.is_empty() { + let sub_dir = dest_dir.join(&base_id).join("subagents"); + if fs::create_dir_all(&sub_dir).is_ok() { + for (name, bytes) in subagents { + // file_name() strips any directory component, so the write can't escape sub_dir. + let safe = Path::new(name).file_name().and_then(|n| n.to_str()).unwrap_or(""); + let lower = safe.to_lowercase(); + if lower.starts_with("agent-") && (lower.ends_with(".jsonl") || lower.ends_with(".meta.json")) { + // A qoder session's subagent transcripts carry the same atomic wrappers — + // the parent's sniff decides, so the whole stored copy is Claude-shaped. + if qoder_text.is_some() && lower.ends_with(".jsonl") { + if let Ok(text) = std::str::from_utf8(bytes) { + let normalized = crate::qoder::normalize_records(&parse_lines(text)); + let _ = fs::write(sub_dir.join(safe), records_to_jsonl(&normalized)); + continue; + } + } + let _ = fs::write(sub_dir.join(safe), bytes); + } + } + } + } + let sidecar = dest_dir.join(format!("{}.import.json", base_id)); + let _ = fs::write( + &sidecar, + serde_json::to_vec_pretty(&json!({ + "originalPath": original_path, + "originalName": original_name, + "sessionId": base_id, + "importedAt": now_ms(), + })) + .unwrap_or_default(), + ); + 1 +} diff --git a/src-tauri/src/history/importpaths.rs b/src-tauri/src/history/importpaths.rs new file mode 100644 index 0000000..c096c63 --- /dev/null +++ b/src-tauri/src/history/importpaths.rs @@ -0,0 +1,103 @@ +use serde_json::{json, Value}; +use std::fs; +use std::path::Path; + +use super::foreign::foreign_kind; +use super::import::write_imported; +use super::jsonl::read_session_text; +use super::paths::imports_root; +use super::subagents::read_subagent_files; + +/// Import a plain .jsonl transcript, bringing along its on-disk subagents dir if present. +/// Foreign-CLI sources (Grok / Copilot / Antigravity) are intentionally not importable — +/// their layouts/formats aren't Claude/Codex, and a Grok chat_history head would otherwise +/// trip looks_codex (its `reasoning` lines look like old envelope-less Codex items). +fn import_one(src: &str) -> i32 { + let src_path = Path::new(src); + if foreign_kind(src_path).is_some() { + return 0; + } + let raw = match read_session_text(src_path) { + Ok(s) => s, + Err(_) => return 0, + }; + // Path-less copies of foreign transcripts: refuse anything whose head sniffs as Grok + // chat_history (type:system + later reasoning/tool_result) or Copilot events + // (type:session.start with producer copilot-agent). + let head: Vec = raw.lines().take(8).filter_map(|l| serde_json::from_str(l.trim()).ok()).collect(); + if looks_foreign_jsonl(&head) { + return 0; + } + let subs = read_subagent_files(src_path); + let original_name = src_path.file_name().and_then(|n| n.to_str()).unwrap_or(""); + write_imported(&raw, src, original_name, &subs) +} + +/// Content sniff for foreign CLI jsonl (used by import when the path no longer carries the +/// original container shape — e.g. a bare chat_history.jsonl dropped into the import dialog). +fn looks_foreign_jsonl(recs: &[Value]) -> bool { + recs.iter().take(8).any(|r| match r.get("type").and_then(|v| v.as_str()) { + // Copilot event stream + Some("session.start") | Some("user.message") | Some("assistant.message") + | Some("tool.execution_complete") | Some("tool.execution_start") => true, + // Grok chat_history: top-level system/reasoning/tool_result (Claude wraps these) + Some("reasoning") | Some("tool_result") if r.get("message").is_none() => true, + Some("system") if r.get("content").is_some() && r.get("message").is_none() => true, + _ => false, + }) +} + +/// Import a conversation-bundle .zip (main session + `subagents/`), restoring the subagent layout so +/// the pipeline nests them exactly as if they'd been captured live. Round-trips export_bundle. +fn import_zip(src: &str) -> i32 { + let bytes = match fs::read(src) { + Ok(b) => b, + Err(_) => return 0, + }; + let (main, subs) = crate::ziputil::split_bundle(crate::ziputil::read(&bytes)); + let main_data = match main { + Some((_, data)) => data, + None => return 0, + }; + let raw = match String::from_utf8(main_data) { + Ok(s) => s, + Err(_) => return 0, + }; + let original_name = Path::new(src).file_name().and_then(|n| n.to_str()).unwrap_or(""); + write_imported(&raw, src, original_name, &subs) +} + +pub fn import_paths(paths: &[String]) -> Value { + let (mut imported, mut skipped, mut failed) = (0, 0, 0); + for src in paths { + let lower = src.to_lowercase(); + let r = if lower.ends_with(".zip") { + import_zip(src) + } else if lower.ends_with(".jsonl") { + import_one(src) + } else { + 0 + }; + match r { + 1 => imported += 1, + 2 => skipped += 1, + _ => failed += 1, + } + } + json!({ "imported": imported, "skipped": skipped, "failed": failed }) +} + +pub fn remove_import(file: &str) -> Value { + let root = imports_root(); + let f = Path::new(file); + // Hard safety: only ever delete inside our own import store. + if !f.starts_with(&root) { + return json!({ "ok": false, "error": "outside import store" }); + } + let dir = f.parent().unwrap_or(Path::new(".")); + let base = f.file_stem().and_then(|s| s.to_str()).unwrap_or(""); + let _ = fs::remove_file(f); + let _ = fs::remove_file(dir.join(format!("{}.import.json", base))); + let _ = fs::remove_dir_all(dir.join(base)); // subagents/ + json!({ "ok": true }) +} diff --git a/src-tauri/src/history/jsonl.rs b/src-tauri/src/history/jsonl.rs new file mode 100644 index 0000000..7693def --- /dev/null +++ b/src-tauri/src/history/jsonl.rs @@ -0,0 +1,128 @@ +use serde_json::{json, Value}; +use std::fs; +use std::path::Path; + +pub(crate) fn parse_lines(text: &str) -> Vec { + let mut out = vec![]; + for line in text.split('\n') { + let s = line.trim(); + if s.is_empty() { + continue; + } + if let Ok(v) = serde_json::from_str::(s) { + out.push(v); + } + } + out +} + +pub(super) fn read_head_result(file: &Path, max: usize) -> std::io::Result { + use std::io::{BufRead, BufReader, Read}; + // Qoder data can be protected as "Other Application Data" on macOS. Its reader first + // attempts the normal filesystem path and uses the installed Qoder CLI only for EPERM; + // keep the same bounded-head contract used by list metadata after that read succeeds. + if crate::qoder::looks_qoder_path(file) { + let mut bytes = crate::qoder::read_bytes(file)?; + bytes.truncate(max); + return Ok(String::from_utf8_lossy(&bytes).into_owned()); + } + let mut file = fs::File::open(file)?; + let mut buf = vec![0u8; max]; + let read = file.read(&mut buf)?; + buf.truncate(read); + // SessionMeta may exceed the ordinary list window because it can embed base instructions and + // dynamic tools. Extend ONLY when the first record itself has no newline yet; a later partial + // record can be ignored, avoiding an accidental multi-megabyte image/tool-result read. + let prefix_len = buf.len().min(4096); + let compact_prefix: String = String::from_utf8_lossy(&buf[..prefix_len]) + .chars() + .filter(|value| !value.is_ascii_whitespace()) + .collect(); + let codex_session_meta = compact_prefix + .find("\"type\":\"session_meta\"") + .is_some_and(|position| position < 512); + if codex_session_meta && read == max && !buf.contains(&b'\n') { + let mut reader = BufReader::new(file); + let _ = reader.read_until(b'\n', &mut buf)?; + } + Ok(String::from_utf8_lossy(&buf).into_owned()) +} + +pub(crate) fn read_head(file: &Path, max: usize) -> String { + read_head_result(file, max).unwrap_or_default() +} + +pub(super) fn read_session_text(file: &Path) -> std::io::Result { + if crate::qoder::looks_qoder_path(file) { + crate::qoder::read_text(file) + } else { + fs::read_to_string(file) + } +} + +pub(super) fn read_session_bytes(file: &Path) -> std::io::Result> { + if crate::qoder::looks_qoder_path(file) { + crate::qoder::read_bytes(file) + } else { + fs::read(file) + } +} + +/// Verbatim bytes for raw export. Qoder sessions may require the guarded Qoder CLI fallback on +/// macOS; all other sources retain the ordinary filesystem read used before Qoder support. +pub(crate) fn raw_session_bytes(file: &str) -> std::io::Result> { + read_session_bytes(Path::new(file)) +} + +pub(crate) fn session_read_error(file: &Path, error: &std::io::Error) -> Value { + let kind = match error.kind() { + std::io::ErrorKind::NotFound => "notFound", + std::io::ErrorKind::PermissionDenied => "permissionDenied", + _ => "readFailed", + }; + json!({ + "error": { + "kind": kind, + "file": file.to_string_lossy(), + "message": error.to_string(), + } + }) +} + +pub(super) fn mtime_ms(file: &Path) -> f64 { + fs::metadata(file) + .and_then(|m| m.modified()) + .ok() + .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|d| d.as_millis() as f64) + .unwrap_or(0.0) +} + +/// File creation (birth) time in ms; mtime on filesystems that don't record one. NOT stable +/// across a title/tag edit — set_ccbud rewrites via tmp+rename, which gives the path the tmp +/// file's (fresh) birth time — so this is only the FALLBACK sort key when a session's records +/// carry no timestamp; record_created_ms is the real one. +pub(crate) fn created_ms(file: &Path) -> f64 { + fs::metadata(file) + .and_then(|m| m.created()) + .ok() + .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|d| d.as_millis() as f64) + .filter(|v| *v > 0.0) + .unwrap_or_else(|| mtime_ms(file)) +} + +/// Session creation time for ORDERING: the first record's timestamp, i.e. content-derived and +/// therefore immune to file rewrites — renaming/tagging a conversation (tmp+rename resets the +/// fs birth time) must never reshuffle the list. Falls back to fs times when no record carries +/// a timestamp. Claude records and Codex rollout lines both put `timestamp` at the top level. +pub(crate) fn record_created_ms(recs: &[Value], file: &Path) -> f64 { + for r in recs { + if let Some(ts) = r.get("timestamp").and_then(|v| v.as_str()) { + if let Ok(d) = chrono::DateTime::parse_from_rfc3339(ts) { + return d.timestamp_millis() as f64; + } + } + } + created_ms(file) +} diff --git a/src-tauri/src/history/list.rs b/src-tauri/src/history/list.rs new file mode 100644 index 0000000..7a1a292 --- /dev/null +++ b/src-tauri/src/history/list.rs @@ -0,0 +1,180 @@ +use serde_json::{json, Value}; +use std::path::PathBuf; + +use super::codexdedupe::{dedupe_canonical_codex_sessions, limit_with_codex_ancestors}; +use super::meta::{meta_cache, session_meta}; +use super::paths::{all_dirs, each_session_file, sibling_dir}; +use super::TRASH_ID; + +pub fn list_sessions(config: &Value, active: &str, limit: usize) -> Vec { + // The recycle bin spans every dir and shows only soft-deleted sessions; every other view + // is scoped to its dir and hides them. + let trash = active == TRASH_ID; + // Read (memoized) metas for EVERY candidate, then dedupe/order before the limit cut. Most + // formats use content-derived CreatedAt so title/tag rewrites cannot reshuffle rows; Codex + // uses rollout UpdatedAt because its custom metadata is sidecar-only and Codex defines latest + // that way. The meta cache turns the full walk into stats for unchanged files. + let mut live: std::collections::HashSet = std::collections::HashSet::new(); + let mut candidates: Vec<(PathBuf, String, String, String)> = Vec::new(); + each_session_file(config, |file, dir_name, id, label| { + live.insert(file.clone()); + if !trash && active != "all" && id != active { + return; + } + candidates.push((file, dir_name, id.to_string(), label.to_string())); + }); + // Warm the qoder helper cache in ONE batch before the per-row reads — on a macOS install with + // protected app data, every stale row would otherwise spawn its own helper process. + let qoder_files: Vec = candidates + .iter() + .map(|(file, _, _, _)| file.clone()) + .filter(|file| crate::qoder::looks_qoder_path(file)) + .collect(); + crate::qoder::prefetch(&qoder_files); + let mut out: Vec = Vec::new(); + for (file, dir_name, id, label) in &candidates { + if let Some(m) = session_meta(file, dir_name, id, label) { + out.push(m); + } + } + // Drop memo entries for files that no longer exist, so removed dirs don't pin stale rows. + if let Ok(mut cache) = meta_cache().lock() { + cache.retain(|k, _| live.contains(k)); + } + // Collapse only true physical duplicates (same dir + canonical thread id) BEFORE limit. + // Threads that merely share rootSessionId are distinct root/subagent nodes and remain intact. + let mut out = dedupe_canonical_codex_sessions(out); + // Apply recycle-bin visibility to the selected logical representative, not to each physical + // candidate. Otherwise deleting the authoritative copy could make a stale duplicate reappear + // in the normal list while the same logical thread also sits in the recycle bin. + out.retain(|session| { + session.get("deleted").and_then(Value::as_bool).unwrap_or(false) == trash + }); + let key = |v: &Value| { + // Codex defines "latest" as UpdatedAt (rollout mtime); its title/tags live in a sidecar, + // so this timestamp is not dirtied by ccbud edits. Keep the stable CreatedAt policy for + // formats whose transcript itself is rewritten when metadata changes. + let field = if v.get("source").and_then(Value::as_str) == Some("codex") { + "lastActivity" + } else { + "createdAt" + }; + v.get(field).and_then(Value::as_f64).unwrap_or(0.0) + }; + out.sort_by(|a, b| { + key(b) + .partial_cmp(&key(a)) + .unwrap_or(std::cmp::Ordering::Equal) + .then_with(|| { + let updated = |value: &Value| { + value.get("lastActivity").and_then(Value::as_f64).unwrap_or(0.0) + }; + updated(b) + .partial_cmp(&updated(a)) + .unwrap_or(std::cmp::Ordering::Equal) + }) + .then_with(|| { + let a_id = a + .get("threadId") + .or_else(|| a.get("id")) + .and_then(Value::as_str) + .unwrap_or(""); + let b_id = b + .get("threadId") + .or_else(|| b.get("id")) + .and_then(Value::as_str) + .unwrap_or(""); + b_id.cmp(a_id) + }) + }); + // Soft-cap Codex trees: include the parent/root chain of every selected child so a busy tree + // cannot show orphan subagents merely because its older root fell just below the limit. + limit_with_codex_ancestors(out, limit) +} + +pub fn list_projects(config: &Value, active: &str) -> Vec { + let sessions = list_sessions(config, active, 600); + let mut order: Vec = vec![]; + let mut groups: std::collections::HashMap = std::collections::HashMap::new(); + for s in sessions { + let cwd = s.get("cwd").and_then(|v| v.as_str()).unwrap_or("(unknown)").to_string(); + let la = s.get("lastActivity").and_then(|v| v.as_f64()).unwrap_or(0.0); + let ct = s.get("createdAt").and_then(|v| v.as_f64()).unwrap_or(la); + let sk = if s.get("source").and_then(Value::as_str) == Some("codex") { la } else { ct }; + let g = groups.entry(cwd.clone()).or_insert_with(|| { + order.push(cwd.clone()); + json!({ "cwd": s.get("cwd").cloned().unwrap_or(Value::Null), "name": s.get("project").cloned().unwrap_or(Value::Null), "sessions": [], "lastActivity": 0.0, "createdAt": 0.0, "sortActivity": 0.0 }) + }); + g["sessions"].as_array_mut().unwrap().push(s.clone()); + if la > g["lastActivity"].as_f64().unwrap_or(0.0) { + g["lastActivity"] = json!(la); + } + if ct > g["createdAt"].as_f64().unwrap_or(0.0) { + g["createdAt"] = json!(ct); + } + if sk > g["sortActivity"].as_f64().unwrap_or(0.0) { + g["sortActivity"] = json!(sk); + } + } + // Codex's latest semantic is rollout UpdatedAt; other formats retain CreatedAt so in-file + // title/tag edits cannot reorder them. Apply the same source-aware rule to rows and projects. + let sort_key = |v: &Value| { + let field = if v.get("source").and_then(Value::as_str) == Some("codex") { + "lastActivity" + } else { + "createdAt" + }; + v.get(field).and_then(Value::as_f64).unwrap_or(0.0) + }; + let mut arr: Vec = order.into_iter().filter_map(|k| groups.remove(&k)).collect(); + for g in &mut arr { + g["sessions"].as_array_mut().unwrap().sort_by(|a, b| { + sort_key(b).partial_cmp(&sort_key(a)).unwrap_or(std::cmp::Ordering::Equal) + }); + } + arr.sort_by(|a, b| { + let key = |value: &Value| { + value.get("sortActivity").and_then(Value::as_f64).unwrap_or(0.0) + }; + key(b).partial_cmp(&key(a)).unwrap_or(std::cmp::Ordering::Equal) + }); + for group in &mut arr { + if let Some(object) = group.as_object_mut() { + object.remove("sortActivity"); + } + } + arr +} + +pub fn dir_stats(config: &Value) -> Vec { + // Per-dir counts exclude soft-deleted sessions (they're hidden from those views); the deleted + // ones are tallied separately into the synthetic recycle-bin bucket. Reuse list_sessions so + // counts reflect canonical logical rows rather than duplicate physical rollout files. + let mut counts: std::collections::HashMap = std::collections::HashMap::new(); + for session in list_sessions(config, "all", usize::MAX) { + let id = session.get("dirId").and_then(Value::as_str).unwrap_or(""); + *counts.entry(id.to_string()).or_insert(0) += 1; + } + let trash = list_sessions(config, TRASH_ID, usize::MAX).len() as i64; + let mut out: Vec = all_dirs(config) + .into_iter() + .map(|(id, label, pd)| { + // A dir "exists" when ANY data tree is on disk — ~/.codex has only sessions/, + // ~/.copilot only session-state/, ~/.gemini/antigravity-cli only conversations/. + let exists = pd.is_dir() + || ["sessions", "session-state", "conversations"] + .iter() + .any(|n| sibling_dir(&pd, n).map(|s| s.is_dir()).unwrap_or(false)); + let imported = id == "__imported__"; + json!({ + "id": id.clone(), "label": label, "projectsDir": pd.to_string_lossy(), + "sessions": counts.get(&id).copied().unwrap_or(0), "exists": exists, "imported": imported, + }) + }) + .collect(); + out.push(json!({ + "id": TRASH_ID, "label": "回收站", "projectsDir": "", + "sessions": trash, "exists": true, "imported": false, "trash": true, + })); + out +} diff --git a/src-tauri/src/history/meta.rs b/src-tauri/src/history/meta.rs new file mode 100644 index 0000000..7874eaf --- /dev/null +++ b/src-tauri/src/history/meta.rs @@ -0,0 +1,168 @@ +use serde_json::{json, Value}; +use std::fs; +use std::path::{Path, PathBuf}; + +use super::foreign::{cache_stamp_ms, foreign_kind, Foreign}; +use super::jsonl::{ + mtime_ms, parse_lines, read_head, read_head_result, record_created_ms, session_read_error, +}; +use super::norm::line_to_message; +use super::paths::{base_name, decode_dir_name}; +use super::text::{first_user_text, read_ccbud}; + +/// Mtime+size-keyed memo of session_meta list rows (mirrors the JS metaCache). List refreshes +/// fire on every watched write during a live session and previously re-read every candidate's +/// file head each time — with the memo, unchanged sessions cost a stat. Pruned in list_sessions +/// against the live file set; a live Codex rollout's sidecar edit (which does NOT touch the +/// file) is invalidated explicitly by set_ccbud. +pub(super) fn meta_cache() -> &'static std::sync::Mutex> { + static CACHE: std::sync::OnceLock>> = + std::sync::OnceLock::new(); + CACHE.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new())) +} + +pub(super) fn session_meta(file: &Path, dir_name: &str, dir_id: &str, dir_label: &str) -> Option { + let (mt, size) = (cache_stamp_ms(file), fs::metadata(file).ok()?.len()); + if let Ok(cache) = meta_cache().lock() { + if let Some((cmt, csz, v)) = cache.get(file) { + if *cmt == mt && *csz == size { + return Some(v.clone()); + } + } + } + let built = build_session_meta(file, dir_name, dir_id, dir_label)?; + // A permission failure is recoverable without changing the transcript's mtime/size (for + // example after the user grants macOS App Data access) — never memoize that placeholder row, + // otherwise it would stay "(conversation)" until the process restarts. Every OTHER read + // error is memoized like a normal row: the mtime/size key already invalidates it when the + // file changes, and skipping the memo would re-read a broken transcript on every refresh. + let awaiting_grant = built + .get("readError") + .and_then(|e| e.get("kind")) + .and_then(|k| k.as_str()) + == Some("permissionDenied"); + if !awaiting_grant { + if let Ok(mut cache) = meta_cache().lock() { + cache.insert(file.to_path_buf(), (mt, size, built.clone())); + } + } + Some(built) +} + +fn build_session_meta(file: &Path, dir_name: &str, dir_id: &str, dir_label: &str) -> Option { + // Foreign sources first — routed by container shape BEFORE any content read (one of them + // isn't even text), each through its own shaper. + match foreign_kind(file) { + Some(Foreign::Grok) => return crate::grok::session_meta_from(file, dir_id, dir_label), + Some(Foreign::Copilot) => { + let recs = parse_lines(&read_head(file, 131072)); + return crate::copilot::session_meta_from(file, &recs, dir_id, dir_label); + } + Some(Foreign::Antigravity) => return crate::antigravity::session_meta_from(file, dir_id, dir_label), + None => {} + } + let meta = fs::metadata(file).ok()?; + let size = meta.len(); + let qoder = crate::qoder::looks_qoder_path(file); + // Qoder stores title/workspace/runtime records throughout the transcript, and its first JSON + // line can itself exceed the ordinary 128 KiB list window. Read the full file once (the row + // cache makes this a one-time cost per mtime/size); other formats retain the bounded head. + let raw = if qoder { + crate::qoder::read_text(file) + } else { + read_head_result(file, 131072) + }; + let (parsed_recs, read_error) = match raw { + Ok(raw) => (parse_lines(&raw), Value::Null), + Err(error) => ( + vec![], + session_read_error(file, &error) + .get("error") + .cloned() + .unwrap_or(Value::Null), + ), + }; + let qoder_title = if qoder { + crate::qoder::session_title_from(&parsed_recs) + } else { + None + }; + let qoder_cwd = if qoder { + crate::qoder::working_dir_from(&parsed_recs) + } else { + None + }; + let qoder_model = if qoder { + crate::qoder::model_from(&parsed_recs) + } else { + None + }; + let recs = if qoder { + crate::qoder::normalize_records(&parsed_recs) + } else { + parsed_recs + }; + // Codex rollouts (a dir's sessions/ tree, or snapshots imported into the app store) list + // through the codex shaper — the record format shares nothing with Claude's. + if crate::codex::looks_codex(&recs) { + return crate::codex::session_meta_from(file, &recs, dir_id, dir_label); + } + // Qoder sessions use Claude-like user/assistant envelopes plus inline title/workspace/runtime + // records and atomic assistant content wrappers. normalize_records makes the message stream + // Claude-shaped; Qoder-specific metadata remains app-sidecar + inline JSONL data. + let meta_rec = recs + .iter() + .find(|r| r.get("cwd").is_some()) + .or_else(|| recs.iter().find(|r| r.get("sessionId").is_some())); + let agent_rec = recs.iter().find(|r| r.get("agentId").is_some()); + let msgs: Vec = recs.iter().filter_map(line_to_message).collect(); + let (cc_title, cc_tags, cc_deleted) = + if qoder { crate::qoder::sidecar_meta(file) } else { read_ccbud(&recs) }; + let auto_title = qoder_title.unwrap_or_else(|| first_user_text(&msgs)); + let mut model: Option = None; + for r in &recs { + if r.get("type").and_then(|v| v.as_str()) == Some("assistant") { + if let Some(md) = r.get("message").and_then(|m| m.get("model")).and_then(|v| v.as_str()) { + model = Some(md.to_string()); + } + } + } + if qoder_model.is_some() { + model = qoder_model; + } + let subagent = agent_rec.is_some(); + let top_level_cwd = meta_rec + .and_then(|r| r.get("cwd")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + let cwd = (if qoder { + qoder_cwd.or(top_level_cwd) + } else { + top_level_cwd + }) + .or_else(|| decode_dir_name(dir_name)); + let stem = file.file_stem().and_then(|s| s.to_str()).unwrap_or("").to_string(); + let mt = mtime_ms(file); + Some(json!({ + "id": if qoder { format!("qoder:{}", stem) } else { format!("disk:{}{}", stem, if subagent { ":sub" } else { "" }) }, + "file": file.to_string_lossy(), + "source": if qoder { "qoder" } else { "disk" }, + "dirId": dir_id, + "dirLabel": dir_label, + "sessionId": meta_rec.and_then(|r| r.get("sessionId")).and_then(|v| v.as_str()).unwrap_or(&stem), + "cwd": cwd.clone(), + "project": cwd.as_deref().map(base_name).unwrap_or_default(), + "gitBranch": meta_rec.and_then(|r| r.get("gitBranch")).cloned().unwrap_or(Value::Null), + "title": cc_title.clone().unwrap_or_else(|| auto_title.clone()), + "autoTitle": auto_title, + "tags": cc_tags, + "model": model, + "isSubagent": subagent, + "imported": dir_id == "__imported__", + "deleted": cc_deleted, + "readError": read_error, + "createdAt": record_created_ms(&recs, file), + "lastActivity": mt, + "sizeKB": (size as f64 / 1024.0).round() as i64, + })) +} diff --git a/src-tauri/src/history/mod.rs b/src-tauri/src/history/mod.rs new file mode 100644 index 0000000..7b18c8d --- /dev/null +++ b/src-tauri/src/history/mod.rs @@ -0,0 +1,62 @@ +// Conversation history. +// +// Reads Claude Code and Codex on-disk sessions across configured dirs, imported snapshots, and +// the app-managed recycle bin. Shapes list/detail payloads for the renderer, including subagents, +// custom title/tags/delete metadata, bundle import/export helpers, and live-watch roots. + +#![allow(dead_code)] + +mod codexdedupe; +mod edit; +mod foreign; +mod import; +mod importpaths; +mod jsonl; +mod list; +mod meta; +mod norm; +mod paths; +mod search; +mod searchfmt; +mod searchscan; +mod searchtext; +mod selftest; +mod session; +mod skills; +mod subagents; +mod text; + +#[cfg(test)] +mod foreign_probe; +#[cfg(test)] +mod tests_foreign; +#[cfg(test)] +mod tests_misc; +#[cfg(test)] +mod tests_qoder; +#[cfg(test)] +mod tests_search; + +pub use edit::{delete_session_file, set_ccbud}; +pub use importpaths::{import_paths, remove_import}; +pub use list::{dir_stats, list_projects, list_sessions}; +pub use norm::Norm; +pub use paths::watch_roots; +pub use search::search_sessions; +pub use selftest::{history_selftest, import_selftest}; +pub use session::get_session; +pub use subagents::{export_bundle, session_has_subagents, subagent_transcript_paths}; + +pub(crate) use foreign::{foreign_kind, Foreign}; +pub(crate) use jsonl::{ + created_ms, parse_lines, raw_session_bytes, read_head, record_created_ms, session_read_error, +}; +pub(crate) use norm::image_block; +pub(crate) use paths::base_name; +pub(crate) use session::read_import_meta; +pub(crate) use skills::{apply_skill_names, skill_from_recs}; +pub(crate) use text::{first_user_text, read_ccbud}; + +/// Synthetic "recycle bin" bucket id. Not a real projects tree (never in all_dirs / +/// each_session_file) — a cross-cutting view of soft-deleted sessions across every dir. +pub const TRASH_ID: &str = "__trash__"; diff --git a/src-tauri/src/history/norm.rs b/src-tauri/src/history/norm.rs new file mode 100644 index 0000000..501f948 --- /dev/null +++ b/src-tauri/src/history/norm.rs @@ -0,0 +1,182 @@ +use serde_json::{json, Value}; + +fn usage_of(u: &Value) -> Value { + let mut usage = json!({ + "inputTokens": u.get("input_tokens").and_then(|v| v.as_i64()).unwrap_or(0), + "outputTokens": u.get("output_tokens").and_then(|v| v.as_i64()).unwrap_or(0), + "cacheRead": u.get("cache_read_input_tokens").and_then(|v| v.as_i64()).unwrap_or(0), + "cacheCreation": u.get("cache_creation_input_tokens").and_then(|v| v.as_i64()).unwrap_or(0), + }); + let object = usage.as_object_mut().unwrap(); + // Qoder supplies billing/context facts alongside its zeroed token counters. Keep them on the + // per-turn usage object without adding empty fields to ordinary Claude Code messages. + for (source, target) in [ + ("credits", "credits"), + ("original_credits", "originalCredits"), + ("context_usage_ratio", "contextUsageRatio"), + ] { + if let Some(value) = u.get(source).filter(|value| value.is_number()) { + object.insert(target.to_string(), value.clone()); + } + } + usage +} + +pub(super) fn line_to_message(rec: &Value) -> Option { + let t = rec.get("type").and_then(|v| v.as_str())?; + if t != "user" && t != "assistant" { + return None; + } + let m = rec.get("message")?; + let role = m.get("role").and_then(|v| v.as_str())?; + let mut out = json!({ + "role": role, + "content": m.get("content").cloned().unwrap_or(Value::Null), + "_ts": rec.get("timestamp").cloned().unwrap_or(Value::Null), + "_sidechain": rec.get("isSidechain").and_then(|v| v.as_bool()).unwrap_or(false), + "_meta": rec.get("isMeta").and_then(|v| v.as_bool()).unwrap_or(false), + }); + if t == "assistant" { + let o = out.as_object_mut().unwrap(); + o.insert("_model".into(), m.get("model").cloned().unwrap_or(Value::Null)); + o.insert("_usage".into(), m.get("usage").map(usage_of).unwrap_or(Value::Null)); + o.insert("_stopReason".into(), m.get("stop_reason").cloned().unwrap_or(Value::Null)); + } + Some(out) +} + +pub(super) struct Shaped { + pub(super) messages: Vec, + pub(super) totals: Value, + pub(super) model: Option, + pub(super) first_ts: Option, + pub(super) last_ts: Option, +} + +/// The renderer's normalized session shape shared by every non-Claude source (Codex, Grok, +/// Copilot, Antigravity): Anthropic-style messages (`role` + content blocks of +/// text/thinking/tool_use/tool_result) plus the session-level facts each format can recover. +pub struct Norm { + pub messages: Vec, + pub totals: Value, + pub model: Option, + pub first_ts: Option, + pub last_ts: Option, + pub cwd: Option, + pub session_id: Option, + pub thread_id: Option, + pub parent_thread_id: Option, + pub forked_from_id: Option, + pub is_subagent: bool, + pub agent_path: Option, + pub agent_nickname: Option, + pub agent_role: Option, + pub agent_depth: Option, + pub git_branch: Option, + pub version: Option, +} + +impl Default for Norm { + fn default() -> Self { + Norm { + messages: vec![], + totals: json!({ "in": 0, "out": 0, "cacheRead": 0, "cacheCreation": 0, "turns": 0 }), + model: None, + first_ts: None, + last_ts: None, + cwd: None, + session_id: None, + thread_id: None, + parent_thread_id: None, + forked_from_id: None, + is_subagent: false, + agent_path: None, + agent_nickname: None, + agent_role: None, + agent_depth: None, + git_branch: None, + version: None, + } + } +} + +/// data-URL image → Claude-style image source block, else None. +pub(crate) fn image_block(url: &str) -> Option { + let rest = url.strip_prefix("data:")?; + let (mime, b64) = rest.split_once(";base64,")?; + Some(json!({ "type": "image", "source": { "type": "base64", "media_type": mime, "data": b64 } })) +} + +pub(super) fn shape_messages(recs: &[Value]) -> Shaped { + let mut messages = vec![]; + let (mut tin, mut tout, mut tcr, mut tcc, mut turns) = (0i64, 0i64, 0i64, 0i64, 0i64); + let mut credits = 0.0f64; + let mut has_credits = false; + let mut model: Option = None; + let mut first_ts: Option = None; + let mut last_ts: Option = None; + for r in recs { + let lm = match line_to_message(r) { + Some(m) => m, + None => continue, + }; + if lm.get("_meta").and_then(|v| v.as_bool()).unwrap_or(false) { + continue; + } + let ts = lm.get("_ts").and_then(|v| v.as_str()).map(|s| s.to_string()); + if let Some(t) = &ts { + if first_ts.is_none() { + first_ts = Some(t.clone()); + } + last_ts = Some(t.clone()); + } + let mut msg = json!({ "role": lm.get("role").cloned().unwrap_or(Value::Null), "content": lm.get("content").cloned().unwrap_or(Value::Null) }); + let mo = msg.as_object_mut().unwrap(); + if lm.get("_sidechain").and_then(|v| v.as_bool()).unwrap_or(false) { + mo.insert("isSidechain".into(), json!(true)); + } + if let Some(t) = &ts { + mo.insert("ts".into(), json!(t)); + } + if r.get("type").and_then(|v| v.as_str()) == Some("assistant") { + if let Some(md) = lm.get("_model").and_then(|v| v.as_str()) { + mo.insert("modelActual".into(), json!(md)); + model = Some(md.to_string()); + } + let u = lm.get("_usage").cloned().unwrap_or(Value::Null); + if u.is_object() { + mo.insert("usage".into(), u.clone()); + tin += u.get("inputTokens").and_then(|v| v.as_i64()).unwrap_or(0); + tout += u.get("outputTokens").and_then(|v| v.as_i64()).unwrap_or(0); + tcr += u.get("cacheRead").and_then(|v| v.as_i64()).unwrap_or(0); + tcc += u.get("cacheCreation").and_then(|v| v.as_i64()).unwrap_or(0); + if let Some(value) = u.get("credits").and_then(|v| v.as_f64()) { + credits += value; + has_credits = true; + } + turns += 1; + } + if let Some(sr) = lm.get("_stopReason").and_then(|v| v.as_str()) { + mo.insert("stopReason".into(), json!(sr)); + } + } + messages.push(msg); + } + let mut totals = json!({ "in": tin, "out": tout, "cacheRead": tcr, "cacheCreation": tcc, "turns": turns }); + if has_credits { + let totals = totals.as_object_mut().unwrap(); + totals.insert("credits".into(), json!(credits)); + // Qoder's source log may omit usable token accounting while still providing real credits. + // Flag that state so the UI does not misrepresent unavailable token counts as zero usage. + if tin == 0 && tout == 0 && tcr == 0 && tcc == 0 { + totals.insert("tokenUsageAvailable".into(), json!(false)); + } + } + Shaped { + messages, + totals, + model, + first_ts, + last_ts, + } +} diff --git a/src-tauri/src/history/paths.rs b/src-tauri/src/history/paths.rs new file mode 100644 index 0000000..a7b2925 --- /dev/null +++ b/src-tauri/src/history/paths.rs @@ -0,0 +1,132 @@ +use serde_json::Value; +use std::fs; +use std::path::{Path, PathBuf}; + +fn home() -> PathBuf { + std::env::var("HOME").map(PathBuf::from).unwrap_or_else(|_| PathBuf::from(".")) +} + +fn expand_tilde(p: &str) -> PathBuf { + if let Some(rest) = p.strip_prefix("~/") { + home().join(rest) + } else if p == "~" { + home() + } else { + PathBuf::from(p) + } +} + +/// Configured dirs → (id, label, projects_dir). id == the dir string (historyActive matches it). +fn config_dirs(config: &Value) -> Vec<(String, String, PathBuf)> { + let mut out = vec![]; + if let Some(arr) = config.get("historyDirs").and_then(|v| v.as_array()) { + for d in arr { + if let Some(s) = d.as_str() { + out.push((s.to_string(), s.to_string(), expand_tilde(s).join("projects"))); + } + } + } + out +} + +pub(crate) fn base_name(p: &str) -> String { + p.split('/').filter(|s| !s.is_empty()).last().unwrap_or(p).to_string() +} + +/// Best-effort decode of an encoded project dir name → cwd (record cwd wins when present). +pub(super) fn decode_dir_name(name: &str) -> Option { + if name.is_empty() { + return None; + } + let trimmed = name.trim_start_matches('-'); + Some(format!("/{}", trimmed.replace('-', "/"))) +} + +pub(super) fn imports_root() -> PathBuf { + crate::store::ccbud_home().join("imports") +} +/// Configured dirs + the synthetic imported-transcripts store (id `__imported__`). +pub(super) fn all_dirs(config: &Value) -> Vec<(String, String, PathBuf)> { + let mut dirs = config_dirs(config); + dirs.push(("__imported__".to_string(), "导入".to_string(), imports_root().join("projects"))); + dirs +} +/// A sibling data tree next to a dir entry's `projects/`. Every configured dir is probed for +/// EVERY layout (Claude Code AND Qoder write `/projects/…`, Codex and Grok +/// `/sessions/…`, Copilot `/session-state/…`, Antigravity `/conversations/*.db`), +/// so `~/.codex`, `~/.grok`, `~/.copilot`, `~/.gemini/antigravity-cli`, `~/.qoder` are just +/// configured dirs rather than special cases. +pub(super) fn sibling_dir(projects_dir: &Path, name: &str) -> Option { + projects_dir.parent().map(|b| b.join(name)) +} + +fn sessions_dir(projects_dir: &Path) -> Option { + sibling_dir(projects_dir, "sessions") +} + +/// Dirs to watch for live history changes — each work dir's data trees (all four layouts). +pub fn watch_roots(config: &Value) -> Vec { + let mut roots: Vec = vec![]; + for (_, _, pd) in all_dirs(config) { + for name in ["sessions", "session-state", "conversations"] { + if let Some(sd) = sibling_dir(&pd, name) { + roots.push(sd); + } + } + roots.push(pd); + } + roots +} + +/// Walk every session .jsonl across the configured dirs (+ imports), invoking +/// `cb(file, dir_name, dir_id, dir_label)` — both the Claude projects/ tree and the +/// Codex sessions/ tree of each dir. +pub(super) fn each_session_file(config: &Value, mut cb: F) { + for (id, label, root) in all_dirs(config) { + if let Ok(entries) = fs::read_dir(&root) { + for ent in entries.flatten() { + if !ent.path().is_dir() { + continue; + } + let dir_name = ent.file_name().to_string_lossy().into_owned(); + let pfiles = match fs::read_dir(ent.path()) { + Ok(f) => f, + Err(_) => continue, + }; + for f in pfiles.flatten() { + let p = f.path(); + if p.is_file() && p.extension().and_then(|e| e.to_str()) == Some("jsonl") { + cb(p, dir_name.clone(), &id, &label); + } + } + } + } + // Codex rollouts live in a date-sharded sessions/ tree; Grok shares the same sessions/ + // root but keys children by percent-encoded cwd (and stuffs sidecar jsonl — events/ + // updates/rewind — beside each chat_history.jsonl), so children are routed one by one + // rather than letting the codex walker sweep grok trees into garbage rows. + if let Some(sd) = sessions_dir(&root) { + if let Ok(children) = fs::read_dir(&sd) { + for ent in children.flatten() { + let p = ent.path(); + let name = ent.file_name().to_string_lossy().into_owned(); + if p.is_dir() && crate::grok::is_cwd_dir_name(&name) { + crate::grok::walk_cwd_dir(&p, &mut |f| cb(f, String::new(), &id, &label)); + } else if p.is_dir() { + crate::codex::walk_sessions(&p, |f| cb(f, String::new(), &id, &label)); + } else if p.is_file() && p.extension().and_then(|e| e.to_str()) == Some("jsonl") { + cb(p, String::new(), &id, &label); + } + } + } + } + // Copilot session logs (flat .jsonl + /events.jsonl). + if let Some(ss) = sibling_dir(&root, "session-state") { + crate::copilot::walk(&ss, &mut |f| cb(f, String::new(), &id, &label)); + } + // Antigravity conversations (one SQLite per session). + if let Some(cd) = sibling_dir(&root, "conversations") { + crate::antigravity::walk(&cd, &mut |f| cb(f, String::new(), &id, &label)); + } + } +} diff --git a/src-tauri/src/history/search.rs b/src-tauri/src/history/search.rs new file mode 100644 index 0000000..fd3fa91 --- /dev/null +++ b/src-tauri/src/history/search.rs @@ -0,0 +1,72 @@ +use serde_json::Value; +use std::path::PathBuf; + +use super::foreign::is_session_deleted; +use super::jsonl::created_ms; +use super::list::list_sessions; +use super::searchscan::scan_session; +use super::TRASH_ID; + +/// Content search over the same candidate set (and dir/trash scoping) as the list view, newest +/// first. Returns [{ file, agent, agentType?, snippet, count }] for up to `limit` sessions. +pub fn search_sessions(config: &Value, active: &str, query: &str, limit: usize) -> Vec { + let q = query.trim(); + if q.is_empty() { + return vec![]; + } + let trash = active == TRASH_ID; + // The raw-bytes prefilter only applies to queries whose every byte is guaranteed to appear + // verbatim in the file's JSON encoding: printable ASCII minus the chars JSON escapes + // (quote/backslash/control). Non-ASCII stays OFF the prefilter — some producers (e.g. + // Python's json.dumps default) escape it as \uXXXX, which a byte scan would miss; those + // queries always take the parse+extract path (cached, so paid once per file version). + let raw_safe = q.bytes().all(|b| b.is_ascii() && b != b'"' && b != b'\\' && b >= 0x20); + // Reuse the list's pre-limit canonical-thread dedupe, directory/trash scope, and ordering. + // Otherwise duplicate physical rollouts could consume the 600-file search window even though + // the sidebar shows only their selected representative. + let files: Vec<(PathBuf, f64)> = list_sessions(config, active, 600) + .into_iter() + .filter_map(|session| { + let file = PathBuf::from(session.get("file")?.as_str()?); + let created = session + .get("createdAt") + .and_then(Value::as_f64) + .unwrap_or_else(|| created_ms(&file)); + Some((file, created)) + }) + .collect(); + // One batch helper call instead of a spawn per protected qoder file inside the worker loop + // (repeat scans of unchanged files are then served by the extraction + helper caches). + let qoder_files: Vec = files + .iter() + .map(|(file, _)| file.clone()) + .filter(|file| crate::qoder::looks_qoder_path(file)) + .collect(); + crate::qoder::prefetch(&qoder_files); + let hits = std::sync::Mutex::new(Vec::<(f64, Value)>::new()); + let next = std::sync::atomic::AtomicUsize::new(0); + let workers = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(4).clamp(1, 8); + std::thread::scope(|s| { + for _ in 0..workers { + s.spawn(|| loop { + let i = next.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + if i >= files.len() { + break; + } + let (file, ct) = &files[i]; + if is_session_deleted(file) != trash { + continue; + } + if let Some(hit) = scan_session(file, q, raw_safe) { + if let Ok(mut h) = hits.lock() { + h.push((*ct, hit)); + } + } + }); + } + }); + let mut hits = hits.into_inner().unwrap_or_else(|e| e.into_inner()); + hits.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal)); + hits.truncate(limit); + hits.into_iter().map(|(_, v)| v).collect() +} diff --git a/src-tauri/src/history/searchfmt.rs b/src-tauri/src/history/searchfmt.rs new file mode 100644 index 0000000..8a1a567 --- /dev/null +++ b/src-tauri/src/history/searchfmt.rs @@ -0,0 +1,136 @@ +/// Mirror of the renderer's formatCodexBootstrap (conversations.js / runtime.js): Codex records +/// its initial AGENTS.md instructions + environment snapshot as one XML-ish user text block, and +/// the panel renders it as compact Markdown — search must index that same Markdown, not the raw +/// transport shape. None = not a bootstrap message (ordinary prose passes through untouched). +fn format_codex_bootstrap(source: &str) -> Option { + static AGENTS_RE: std::sync::OnceLock = std::sync::OnceLock::new(); + static ENV_RE: std::sync::OnceLock = std::sync::OnceLock::new(); + static ROOT_RE: std::sync::OnceLock = std::sync::OnceLock::new(); + let agents_re = AGENTS_RE.get_or_init(|| { + regex::Regex::new( + r"(?is)^\s*#\s+AGENTS\.md instructions for ([^\r\n]+).*?]*>(.*?)", + ) + .unwrap() + }); + let env_re = ENV_RE.get_or_init(|| { + regex::Regex::new(r"(?is)]*>(.*?)").unwrap() + }); + let root_re = + ROOT_RE.get_or_init(|| regex::Regex::new(r"(?is)]*>(.*?)").unwrap()); + let agents = agents_re.captures(source)?; + + // Dynamic per-name regexes are fine here: at most one bootstrap message exists per session. + let tag = |block: &str, name: &str| -> String { + regex::Regex::new(&format!(r"(?is)<{name}\b[^>]*>(.*?)")) + .ok() + .and_then(|re| re.captures(block).and_then(|c| c.get(1).map(|m| m.as_str().trim().to_string()))) + .unwrap_or_default() + }; + let attr = |block: &str, name: &str, attribute: &str| -> String { + regex::Regex::new(&format!(r#"(?i)<{name}\b[^>]*\b{attribute}=["']([^"']+)["']"#)) + .ok() + .and_then(|re| re.captures(block).and_then(|c| c.get(1).map(|m| m.as_str().trim().to_string()))) + .unwrap_or_default() + }; + let code = |value: &str| -> String { + if value.is_empty() { String::new() } else { format!("`{}`", value) } + }; + + let mut parts: Vec = + vec![format!("# AGENTS.md instructions for {}", agents.get(1).map(|m| m.as_str().trim()).unwrap_or(""))]; + let instructions = agents.get(2).map(|m| m.as_str().trim()).unwrap_or(""); + if !instructions.is_empty() { + let lines: Vec<&str> = instructions.lines().filter(|line| !line.trim().is_empty()).collect(); + parts.push(if lines.len() == 1 { + format!("**INSTRUCTIONS:** {}", lines[0].trim()) + } else { + format!("**INSTRUCTIONS:**\n\n{}", instructions) + }); + } + + let env = env_re.captures(source); + if let Some(env) = &env { + let block = env.get(1).map(|m| m.as_str()).unwrap_or(""); + let roots: Vec = root_re + .captures_iter(block) + .filter_map(|c| c.get(1).map(|m| m.as_str().trim().to_string())) + .filter(|r| !r.is_empty()) + .map(|r| code(&r)) + .collect(); + let fields: Vec<(&str, String)> = vec![ + ("environment_context", code(&tag(block, "cwd"))), + ("shell", tag(block, "shell")), + ("current_date", tag(block, "current_date")), + ("timezone", tag(block, "timezone")), + ("workspace_roots", roots.join(", ")), + ("permission_profile", attr(block, "permission_profile", "type")), + ("file_system", attr(block, "file_system", "type")), + ] + .into_iter() + .filter(|(_, value)| !value.is_empty()) + .collect(); + if !fields.is_empty() { + parts.push( + fields + .iter() + .map(|(name, value)| format!("**{}:** {}", name, value)) + .collect::>() + .join(" \n"), + ); + } + } + + let mut rest = source.replacen(agents.get(0).map(|m| m.as_str()).unwrap_or(""), "", 1); + if let Some(env) = &env { + rest = rest.replacen(env.get(0).map(|m| m.as_str()).unwrap_or(""), "", 1); + } + let rest = rest.trim(); + if !rest.is_empty() { + parts.push(rest.to_string()); + } + Some(parts.join("\n\n").trim().to_string()) +} + +/// Strip harness-injected blocks from user prose — MUST stay rule-for-rule in sync with the +/// renderer's stripInjected (conversations.js) and the export viewer's copy (runtime.js), so what +/// the big search matches is exactly what the in-conversation search (and the panel) will show. +/// The Codex AGENTS bootstrap reformats to the same Markdown the panel renders; task-notification +/// envelopes keep their human-facing body; the transport metadata (ids, status, summary) +/// is dropped and must therefore never be searchable. +pub(super) fn strip_injected(s: &str) -> String { + static SKILL_RE: std::sync::OnceLock = std::sync::OnceLock::new(); + static TASK_RE: std::sync::OnceLock = std::sync::OnceLock::new(); + static RESULT_RE: std::sync::OnceLock = std::sync::OnceLock::new(); + static RE: std::sync::OnceLock = std::sync::OnceLock::new(); + // Same rule order as the JS: the Codex AGENTS bootstrap is reformatted FIRST, then the + // envelope rules run over the (possibly rewritten) text. + let bootstrap = format_codex_bootstrap(s); + let s: &str = bootstrap.as_deref().unwrap_or(s); + // A turn that is nothing but a envelope is Codex's recorded skill-instruction + // injection — runtime context the panel suppresses wholesale, so search must too. Prose that + // merely quotes markup alongside other text stays searchable. + let skill_re = SKILL_RE + .get_or_init(|| regex::Regex::new(r"(?is)^\s*]*>.*\s*$").unwrap()); + if skill_re.is_match(s) { + return String::new(); + } + let task_re = TASK_RE.get_or_init(|| { + regex::Regex::new(r"(?is)]*>.*?").unwrap() + }); + let result_re = + RESULT_RE.get_or_init(|| regex::Regex::new(r"(?is)]*>(.*?)").unwrap()); + let re = RE.get_or_init(|| { + regex::Regex::new( + r"(?s).*?|.*?|.*?", + ) + .unwrap() + }); + let unwrapped = task_re.replace_all(s, |caps: ®ex::Captures| { + result_re + .captures(caps.get(0).map(|m| m.as_str()).unwrap_or("")) + .and_then(|c| c.get(1)) + .map(|m| format!("\n{}\n", m.as_str().trim())) + .unwrap_or_default() + }); + re.replace_all(&unwrapped, "").trim().to_string() +} diff --git a/src-tauri/src/history/searchscan.rs b/src-tauri/src/history/searchscan.rs new file mode 100644 index 0000000..9aa4bc1 --- /dev/null +++ b/src-tauri/src/history/searchscan.rs @@ -0,0 +1,157 @@ +use serde_json::{json, Value}; +use std::fs; +use std::path::{Path, PathBuf}; + +use super::foreign::{cache_stamp_ms, foreign_kind, Foreign}; +use super::jsonl::{parse_lines, read_session_text}; +use super::norm::shape_messages; +use super::searchtext::{extract_search_text, icount, ifind}; +use super::subagents::subagent_dir; + +struct SearchCache { + map: std::collections::HashMap)>, + bytes: usize, +} +/// Extracted-text memo, keyed path -> (mtime, size, text). Cleared wholesale past the byte budget +/// (crude but safe — the next search simply re-extracts what it touches). +fn search_cache() -> &'static std::sync::Mutex { + static CACHE: std::sync::OnceLock> = std::sync::OnceLock::new(); + CACHE.get_or_init(|| std::sync::Mutex::new(SearchCache { map: std::collections::HashMap::new(), bytes: 0 })) +} +const SEARCH_CACHE_BUDGET: usize = 128 * 1024 * 1024; + +/// Search one transcript file for `q`: (extracted text, first-match byte offset), or None. +/// Serves from the extraction cache when fresh; otherwise prefilters the raw bytes and only +/// parses candidates — files that can't match are neither parsed nor cached. +fn thread_scan(path: &Path, q: &str, raw_safe: bool) -> Option<(std::sync::Arc, usize)> { + let meta = fs::metadata(path).ok()?; + let (mt, sz) = (cache_stamp_ms(path), meta.len()); + if let Ok(cache) = search_cache().lock() { + if let Some((cmt, csz, text)) = cache.map.get(path) { + if *cmt == mt && *csz == sz { + let t = text.clone(); + drop(cache); + return ifind(&t, q, 0).map(|p| (t, p)); + } + } + } + let fk = foreign_kind(path); + let messages: Vec = if fk == Some(Foreign::Antigravity) { + // SQLite source: no raw-bytes prefilter (the payloads are binary) — extraction is + // cached, so the decode is paid once per file version. + crate::antigravity::normalize_db(path).messages + } else { + let raw = read_session_text(path).ok()?; + if raw_safe && ifind(&raw, q, 0).is_none() { + return None; + } + let parsed = parse_lines(&raw); + let recs = if crate::qoder::looks_qoder_path(path) { + crate::qoder::normalize_records(&parsed) + } else { + parsed + }; + match fk { + Some(Foreign::Grok) => crate::grok::normalize(&recs, None).messages, + Some(Foreign::Copilot) => crate::copilot::normalize(&recs).messages, + _ => { + if crate::codex::looks_codex(&recs) { + crate::codex::session_from_recs(&path.to_string_lossy(), &recs) + .get("messages") + .and_then(|v| v.as_array()) + .cloned() + .unwrap_or_default() + } else { + shape_messages(&recs).messages + } + } + } + }; + let text = std::sync::Arc::new(extract_search_text(&messages)); + if let Ok(mut cache) = search_cache().lock() { + if cache.bytes + text.len() > SEARCH_CACHE_BUDGET { + cache.map.clear(); + cache.bytes = 0; + } + if let Some((_, _, old)) = cache.map.insert(path.to_path_buf(), (mt, sz, text.clone())) { + cache.bytes = cache.bytes.saturating_sub(old.len()); // replaced a stale entry + } + cache.bytes += text.len(); + } + ifind(&text, q, 0).map(|p| (text, p)) +} + +/// Display snippet around the first match: ~56 chars of context either side, whitespace collapsed, +/// ellipsized at cut edges. Slice bounds snap outward/inward to char boundaries. +fn snippet_around(text: &str, pos: usize, match_len: usize) -> String { + const CTX: usize = 56; + let mut start = pos.saturating_sub(CTX); + while start > 0 && !text.is_char_boundary(start) { + start -= 1; + } + let mut end = (pos + match_len + CTX).min(text.len()); + while end < text.len() && !text.is_char_boundary(end) { + end += 1; + } + let body = text[start..end].split_whitespace().collect::>().join(" "); + format!("{}{}{}", if start > 0 { "…" } else { "" }, body, if end < text.len() { "…" } else { "" }) +} + +/// Scan one session — main thread first, then each subagent transcript — and shape the hit the +/// renderer needs to auto-locate: which agent matched, a snippet, and the occurrence count. +pub(super) fn scan_session(file: &Path, q: &str, raw_safe: bool) -> Option { + if let Some((text, pos)) = thread_scan(file, q, raw_safe) { + return Some(json!({ + "file": file.to_string_lossy(), + "agent": "main", + "snippet": snippet_around(&text, pos, q.len()), + "count": icount(&text, q), + })); + } + let dir = subagent_dir(file)?; + let mut names: Vec = vec![]; + if let Ok(entries) = fs::read_dir(&dir) { + for ent in entries.flatten() { + let name = ent.file_name().to_string_lossy().into_owned(); + if name.starts_with("agent-") && name.ends_with(".jsonl") { + names.push(name); + } + } + } + names.sort(); + for name in names { + if let Some((text, pos)) = thread_scan(&dir.join(&name), q, raw_safe) { + let agent_id = name.trim_start_matches("agent-").trim_end_matches(".jsonl").to_string(); + let meta_path = dir.join(format!("agent-{}.meta.json", agent_id)); + let meta_raw = if crate::qoder::looks_qoder_path(file) { + crate::qoder::read_text(&meta_path) + } else { + fs::read_to_string(&meta_path) + }; + let meta: Value = meta_raw + .ok() + .and_then(|s| serde_json::from_str(&s).ok()) + .unwrap_or_else(|| json!({})); + // Key by the spawning tool_use id — the same key read_subagents uses, so the renderer + // can switch its panel straight to this agent. + let key = meta + .get("toolUseId") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + .unwrap_or_else(|| format!("agent:{}", agent_id)); + let agent_type = meta + .get("agentType") + .and_then(|v| v.as_str()) + .or_else(|| meta.get("subagent_type").and_then(|v| v.as_str())) + .unwrap_or("agent"); + return Some(json!({ + "file": file.to_string_lossy(), + "agent": key, + "agentType": agent_type, + "snippet": snippet_around(&text, pos, q.len()), + "count": icount(&text, q), + })); + } + } + None +} diff --git a/src-tauri/src/history/searchtext.rs b/src-tauri/src/history/searchtext.rs new file mode 100644 index 0000000..397ab44 --- /dev/null +++ b/src-tauri/src/history/searchtext.rs @@ -0,0 +1,130 @@ +// ---- content search (the session list's "big search") ---- +// +// Scans session CONTENT (message text / thinking / tool calls + results) across every listed +// session — main threads, their subagent transcripts, and Codex rollouts — and reports, per +// matching session, WHERE the first match lives ("main" or a subagent's tool_use key) plus a +// display snippet. The renderer opens the session, switches the panel to that agent, and +// re-finds the query locally, so list hits and in-conversation positioning stay aligned. +// +// Performance model (this runs per keystroke, debounced): +// - extraction cache: path -> (mtime, size, extracted text), so repeated queries pay the JSON +// parse + shaping once per file version; +// - raw prefilter: on a cache miss the raw JSONL bytes are substring-scanned first, and only +// files that could match are parsed at all (JSON escapes quotes/backslashes/control chars, +// so the prefilter is skipped for queries containing those); +// - parallel scan: per-file work fans out over a small thread pool. + +use serde_json::Value; + +use super::searchfmt::strip_injected; + +/// ASCII-case-insensitive substring search (byte-wise; non-ASCII must match exactly — CJK has no +/// case). A valid-UTF-8 needle can only match at char boundaries of valid-UTF-8 text (ASCII bytes +/// never equal continuation bytes), so the returned byte offset is safe to slice on. +pub(super) fn ifind(hay: &str, needle: &str, from: usize) -> Option { + let h = hay.as_bytes(); + let n = needle.as_bytes(); + if n.is_empty() || h.len() < n.len() { + return None; + } + let last = h.len() - n.len(); + let n0 = n[0].to_ascii_lowercase(); + let mut i = from; + while i <= last { + if h[i].to_ascii_lowercase() == n0 { + let mut k = 1; + while k < n.len() && h[i + k].to_ascii_lowercase() == n[k].to_ascii_lowercase() { + k += 1; + } + if k == n.len() { + return Some(i); + } + } + i += 1; + } + None +} + +/// Non-overlapping case-insensitive occurrence count (same fold as ifind). +pub(super) fn icount(hay: &str, needle: &str) -> usize { + let (mut i, mut c) = (0usize, 0usize); + while let Some(p) = ifind(hay, needle, i) { + c += 1; + i = p + needle.len().max(1); + } + c +} + +fn tool_result_search_text(c: &Value) -> String { + if let Some(s) = c.as_str() { + return s.to_string(); + } + if let Some(arr) = c.as_array() { + return arr + .iter() + .filter_map(|x| x.get("text").and_then(|t| t.as_str())) + .collect::>() + .join("\n"); + } + String::new() +} + +/// One searchable text blob for a shaped message list — the renderer's messagePlainText, flattened: +/// user prose (injected blocks stripped), assistant text, thinking, tool name + input JSON, and +/// tool results. Images and raw structure are skipped so a hit here is findable in the panel. +pub(super) fn extract_search_text(messages: &[Value]) -> String { + let mut out = String::new(); + let mut push = |t: &str| { + if !t.is_empty() { + out.push_str(t); + out.push('\n'); + } + }; + for m in messages { + let role = m.get("role").and_then(|v| v.as_str()).unwrap_or(""); + let content = match m.get("content") { + Some(c) => c, + None => continue, + }; + if let Some(s) = content.as_str() { + if role == "user" { + push(&strip_injected(s)); + } else { + push(s); + } + continue; + } + let arr = match content.as_array() { + Some(a) => a, + None => continue, + }; + for b in arr { + match b.get("type").and_then(|v| v.as_str()).unwrap_or("") { + "text" => { + let t = b.get("text").and_then(|v| v.as_str()).unwrap_or(""); + if role == "user" { + push(&strip_injected(t)); + } else { + push(t); + } + } + "thinking" => push(b.get("thinking").and_then(|v| v.as_str()).unwrap_or("")), + "skill_load" => { + push(b.get("name").and_then(Value::as_str).unwrap_or("")); + push(b.get("path").and_then(Value::as_str).unwrap_or("")); + push(b.get("snapshot").and_then(Value::as_str).unwrap_or("")); + } + "tool_use" => { + let name = b.get("name").and_then(|v| v.as_str()).unwrap_or(""); + let input = b.get("input").map(|i| i.to_string()).unwrap_or_default(); + push(&format!("{} {}", name, input)); + } + "tool_result" => { + push(&tool_result_search_text(b.get("content").unwrap_or(&Value::Null))) + } + _ => {} + } + } + } + out +} diff --git a/src-tauri/src/history/selftest.rs b/src-tauri/src/history/selftest.rs new file mode 100644 index 0000000..9f0925d --- /dev/null +++ b/src-tauri/src/history/selftest.rs @@ -0,0 +1,97 @@ +use serde_json::{json, Value}; +use std::fs; +use std::path::Path; + +use super::edit::set_ccbud; +use super::importpaths::{import_paths, remove_import}; +use super::list::list_sessions; +use super::paths::imports_root; +use super::session::get_session; +use super::subagents::export_bundle; +use super::TRASH_ID; + +/// Self-contained round-trip test of set_ccbud + get_session in a throwaway projects tree. +pub fn history_selftest(base_dir: &Path) -> Value { + let proj = base_dir.join("test-claude").join("projects").join("-test-cwd"); + let _ = fs::create_dir_all(&proj); + let file = proj.join("sess1.jsonl"); + let content = "{\"type\":\"user\",\"message\":{\"role\":\"user\",\"content\":\"hello world from selfcheck\"},\"cwd\":\"/test/cwd\",\"sessionId\":\"sess1\",\"timestamp\":\"2025-01-01T10:00:00.000Z\"}\n"; + let _ = fs::write(&file, content); + let config = json!({ "historyDirs": [ base_dir.join("test-claude").to_string_lossy() ] }); + let fpath = file.to_string_lossy().to_string(); + let set = set_ccbud(&fpath, &json!({ "title": "My Title", "tags": ["a", "b", "b"] }), &config); + let sess = get_session(&fpath); + let title = sess.get("meta").and_then(|m| m.get("title")).and_then(|v| v.as_str()).unwrap_or("").to_string(); + let tags = sess.get("meta").and_then(|m| m.get("tags")).and_then(|v| v.as_array()).map(|a| a.len()).unwrap_or(0); + let auto = sess.get("meta").and_then(|m| m.get("autoTitle")).and_then(|v| v.as_str()).unwrap_or("").to_string(); + // Soft-delete round-trip: marked → hidden from "all" but present in trash → restored → back in "all". + let _ = set_ccbud(&fpath, &json!({ "delete": true }), &config); + let after_del = get_session(&fpath).get("meta").and_then(|m| m.get("deleted")).and_then(|v| v.as_bool()).unwrap_or(false); + let hidden_in_all = !list_sessions(&config, "all", 50).iter().any(|s| s.get("file").and_then(|v| v.as_str()) == Some(fpath.as_str())); + let shown_in_trash = list_sessions(&config, TRASH_ID, 50).iter().any(|s| s.get("file").and_then(|v| v.as_str()) == Some(fpath.as_str())); + let _ = set_ccbud(&fpath, &json!({ "delete": false }), &config); + let restored = !get_session(&fpath).get("meta").and_then(|m| m.get("deleted")).and_then(|v| v.as_bool()).unwrap_or(false); + json!({ + "setOk": set.get("ok").and_then(|v| v.as_bool()).unwrap_or(false), + "title": title, + "tagCount": tags, + "autoTitle": auto, + "deletedAfterMark": after_del, + "hiddenInAll": hidden_in_all, + "shownInTrash": shown_in_trash, + "restored": restored, + }) +} + +/// Self-contained test of import → list-as-imported → re-import-skip → remove. +pub fn import_selftest(base_dir: &Path) -> Value { + std::env::set_var("CCBUD_HOME", base_dir); // imports_root() honors CCBUD_HOME + let src_dir = base_dir.join("import-src"); + let _ = fs::create_dir_all(&src_dir); + let src = src_dir.join("foreign.jsonl"); + let _ = fs::write(&src, "{\"type\":\"user\",\"message\":{\"role\":\"user\",\"content\":\"imported hello\"},\"cwd\":\"/imp/cwd\",\"sessionId\":\"impsess\",\"timestamp\":\"2025-01-01T10:00:00.000Z\"}\n"); + let srcs = vec![src.to_string_lossy().to_string()]; + let r = import_paths(&srcs); + let r2 = import_paths(&srcs); + let config = json!({ "historyDirs": ["~/.claude"] }); + let sessions = list_sessions(&config, "__imported__", 50); + let found = sessions.iter().any(|s| { + s.get("imported").and_then(|v| v.as_bool()).unwrap_or(false) + && s.get("title").and_then(|v| v.as_str()) == Some("imported hello") + }); + let dest = imports_root().join("projects").join("-imp-cwd").join("impsess.jsonl"); + let rm = remove_import(&dest.to_string_lossy()); + + // ---- bundle round-trip: a session WITH subagents exports as a .zip and re-imports with its + // subagent transcripts restored (the export → import path the 对话 view drives). ---- + let bproj = base_dir.join("bundle-src").join("projects").join("-bnd-cwd"); + let _ = fs::create_dir_all(&bproj); + let bmain = bproj.join("bundsess.jsonl"); + let _ = fs::write(&bmain, "{\"type\":\"assistant\",\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"tool_use\",\"id\":\"tu9\",\"name\":\"Task\",\"input\":{}}]},\"cwd\":\"/bnd/cwd\",\"sessionId\":\"bundsess\",\"timestamp\":\"2025-01-01T10:00:00.000Z\"}\n"); + let bsub = bproj.join("bundsess").join("subagents"); + let _ = fs::create_dir_all(&bsub); + let _ = fs::write(bsub.join("agent-b1.jsonl"), "{\"type\":\"assistant\",\"isSidechain\":true,\"agentId\":\"b1\",\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"sub done\"}]},\"sessionId\":\"bundsess\",\"timestamp\":\"2025-01-01T10:00:01.000Z\"}\n"); + let _ = fs::write(bsub.join("agent-b1.meta.json"), "{\"agentType\":\"general-purpose\",\"description\":\"d\",\"toolUseId\":\"tu9\"}"); + let zip = export_bundle(&bmain.to_string_lossy()).unwrap_or_default(); + let zip_is_zip = zip.starts_with(&[0x50, 0x4b, 0x03, 0x04]); + let zip_path = base_dir.join("bundle-src").join("bundsess.zip"); + let _ = fs::write(&zip_path, &zip); + let rb = import_paths(&[zip_path.to_string_lossy().to_string()]); + let imp_dir = imports_root().join("projects").join("-bnd-cwd"); + let sub_restored = imp_dir.join("bundsess").join("subagents").join("agent-b1.jsonl").exists() + && imp_dir.join("bundsess").join("subagents").join("agent-b1.meta.json").exists(); + let bundle_sess = get_session(&imp_dir.join("bundsess.jsonl").to_string_lossy()); + let bundle_sub_count = bundle_sess.get("meta").and_then(|m| m.get("subagentCount")).and_then(|v| v.as_i64()).unwrap_or(0); + + json!({ + "imported": r.get("imported"), + "reskipped": r2.get("skipped"), + "appearsImported": found, + "removed": rm.get("ok"), + "gone": !dest.exists(), + "bundleZip": zip_is_zip, + "bundleImported": rb.get("imported"), + "bundleSubRestored": sub_restored, + "bundleSubagentCount": bundle_sub_count, + }) +} diff --git a/src-tauri/src/history/session.rs b/src-tauri/src/history/session.rs new file mode 100644 index 0000000..7d3965d --- /dev/null +++ b/src-tauri/src/history/session.rs @@ -0,0 +1,148 @@ +use serde_json::{json, Value}; +use std::fs; +use std::path::Path; + +use super::foreign::{foreign_kind, Foreign}; +use super::jsonl::{parse_lines, read_session_text, session_read_error}; +use super::norm::shape_messages; +use super::paths::base_name; +use super::skills::{apply_skill_names, skill_from_recs}; +use super::subagents::read_subagents; +use super::text::{first_user_text, read_ccbud}; + +/// Read the import provenance sidecar (`.import.json`) for an imported transcript. +pub(crate) fn read_import_meta(file: &str) -> Option { + let p = Path::new(file); + let stem = p.file_stem().and_then(|s| s.to_str())?; + let dir = p.parent()?; + let raw = fs::read_to_string(dir.join(format!("{}.import.json", stem))).ok()?; + serde_json::from_str(&raw).ok() +} + +pub fn get_session(file: &str) -> Value { + let path = Path::new(file); + let qoder = crate::qoder::looks_qoder_path(path); + // Foreign sources route by container shape BEFORE the text read — Antigravity sessions are + // SQLite, and grok/copilot jsonl would otherwise fall through to the Claude shaper. + match foreign_kind(path) { + Some(Foreign::Antigravity) => return crate::antigravity::session_from(file), + Some(fk) => { + let raw = match read_session_text(path) { + Ok(s) => s, + Err(error) => return session_read_error(path, &error), + }; + let recs = parse_lines(&raw); + return match fk { + Foreign::Grok => crate::grok::session_from_recs(file, &recs), + _ => crate::copilot::session_from_recs(file, &recs), + }; + } + None => {} + } + let raw = match read_session_text(path) { + Ok(s) => s, + Err(error) => return session_read_error(path, &error), + }; + let parsed_recs = parse_lines(&raw); + if crate::codex::looks_codex(&parsed_recs) { + return crate::codex::session_from_recs(file, &parsed_recs); + } + let qoder_title = if qoder { + crate::qoder::session_title_from(&parsed_recs) + } else { + None + }; + let qoder_cwd = if qoder { + crate::qoder::working_dir_from(&parsed_recs) + } else { + None + }; + let qoder_model = if qoder { + crate::qoder::model_from(&parsed_recs) + } else { + None + }; + let recs = if qoder { + crate::qoder::normalize_records(&parsed_recs) + } else { + parsed_recs + }; + let meta_rec = recs + .iter() + .find(|r| r.get("cwd").is_some()) + .or_else(|| recs.iter().find(|r| r.get("sessionId").is_some())); + let agent_rec = recs.iter().find(|r| r.get("agentId").is_some()); + let agent_id = agent_rec + .and_then(|r| r.get("agentId")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + let summary = recs + .iter() + .find(|r| r.get("type").and_then(|v| v.as_str()) == Some("summary") && r.get("summary").is_some()) + .and_then(|r| r.get("summary").cloned()); + // Qoder detail mirrors build_session_meta: normalized atomic wrappers, inline metadata, and + // app-owned title/tags/delete overrides without rewriting another CLI's transcript. + let (cc_title, cc_tags, cc_deleted) = + if qoder { crate::qoder::sidecar_meta(path) } else { read_ccbud(&recs) }; + let shaped = shape_messages(&recs); + let auto_title = qoder_title.unwrap_or_else(|| first_user_text(&shaped.messages)); + let subagent = agent_rec.is_some(); + let top_level_cwd = meta_rec + .and_then(|r| r.get("cwd")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + let cwd = if qoder { + qoder_cwd.or(top_level_cwd) + } else { + top_level_cwd + }; + let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("").to_string(); + let base_id = meta_rec + .and_then(|r| r.get("sessionId")) + .and_then(|v| v.as_str()) + .unwrap_or(&stem) + .to_string(); + // A subagent session's id carries the agent suffix; only a top-level session embeds subagents. + let sess_id = match (subagent, &agent_id) { + (true, Some(aid)) => format!("{}-{}", base_id, aid), + _ => base_id.clone(), + }; + let mut subs = if subagent { serde_json::Map::new() } else { read_subagents(file) }; + apply_skill_names(&shaped.messages, &mut subs); + // Live Qoder files are never imported snapshots; avoid probing a protected sibling sidecar. + let import_meta = if qoder { None } else { read_import_meta(file) }; + + json!({ + "meta": { + "id": if qoder { format!("qoder:{}", stem) } else { format!("disk:{}{}", stem, if subagent { ":sub" } else { "" }) }, + "file": file, + "source": if qoder { "qoder" } else { "disk" }, + // Renderer falls back to Claude when null (the app's home turf carries no label). + "assistant": if qoder { json!("Qoder") } else { Value::Null }, + "title": cc_title.clone().unwrap_or_else(|| auto_title.clone()), + "autoTitle": auto_title, + "tags": cc_tags, + "summary": summary, + "sessionId": sess_id, + "cwd": cwd.clone(), + "project": cwd.as_deref().map(base_name).unwrap_or_default(), + "gitBranch": meta_rec.and_then(|r| r.get("gitBranch")).cloned().unwrap_or(Value::Null), + "version": meta_rec.and_then(|r| r.get("version")).cloned().unwrap_or(Value::Null), + "isSubagent": subagent, + // A standalone subagent transcript self-reports its invoking skill via the sentinel. + "skill": if subagent { skill_from_recs(&recs) } else { None:: }, + "deleted": cc_deleted, + "imported": import_meta.is_some(), + "importedFrom": import_meta.as_ref().and_then(|m| m.get("originalPath")).cloned().unwrap_or(Value::Null), + "importedAt": import_meta.as_ref().and_then(|m| m.get("importedAt")).cloned().unwrap_or(Value::Null), + "model": qoder_model.or(shaped.model), + "totals": shaped.totals, + "messages": shaped.messages.len(), + "subagentCount": subs.len(), + "firstTs": shaped.first_ts, + "lastTs": shaped.last_ts, + }, + "messages": shaped.messages, + "subagents": subs, + }) +} diff --git a/src-tauri/src/history/skills.rs b/src-tauri/src/history/skills.rs new file mode 100644 index 0000000..4e0ca1e --- /dev/null +++ b/src-tauri/src/history/skills.rs @@ -0,0 +1,68 @@ +use serde_json::{json, Value}; + +use super::text::content_text; + +/// A skill-forked subagent transcript opens with a sentinel user line +/// "Base directory for this skill: /" — the last path segment names the skill. +/// Fallback attribution only: the spawning `Skill` tool_use in the parent thread +/// (apply_skill_names) is authoritative and overrides this when present. (history.js skillFromRecs) +const SKILL_BASE_DIR_PREFIX: &str = "Base directory for this skill: "; +pub(crate) fn skill_from_recs(recs: &[Value]) -> Option { + let first = recs.iter().find(|r| { + r.get("type").and_then(|v| v.as_str()) == Some("user") + && r.get("message").is_some() + && !r.get("isMeta").and_then(|v| v.as_bool()).unwrap_or(false) + })?; + let text = content_text(first.get("message")?.get("content").unwrap_or(&Value::Null)); + // Only the opening prompt carries the sentinel — don't scan further user turns. + let rest = text.trim().strip_prefix(SKILL_BASE_DIR_PREFIX)?; + let line = rest.lines().next().unwrap_or("").trim(); + line.split(['/', '\\']).filter(|s| !s.is_empty()).last().map(|s| s.to_string()) +} + +/// Primary skill attribution (history.js applySkillNames): a subagent spawned by the `Skill` tool +/// is named by the spawning tool_use's input.skill (matched by tool_use id — the subagents map +/// key), in whichever thread the call lives (main or a nested subagent). Overrides the sentinel +/// fallback from skill_from_recs. +pub(crate) fn apply_skill_names(main_messages: &[Value], subs: &mut serde_json::Map) { + if subs.is_empty() { + return; + } + fn scan(msgs: &[Value], subs: &serde_json::Map, out: &mut Vec<(String, String)>) { + for m in msgs { + let blocks = match m.get("content").and_then(|c| c.as_array()) { + Some(b) => b, + None => continue, + }; + for b in blocks { + if b.get("type").and_then(|v| v.as_str()) != Some("tool_use") + || b.get("name").and_then(|v| v.as_str()) != Some("Skill") + { + continue; + } + let id = match b.get("id").and_then(|v| v.as_str()) { + Some(i) if subs.contains_key(i) => i, + _ => continue, + }; + if let Some(s) = b.get("input").and_then(|i| i.get("skill")).and_then(|v| v.as_str()) { + let s = s.trim(); + if !s.is_empty() { + out.push((id.to_string(), s.to_string())); + } + } + } + } + } + let mut named: Vec<(String, String)> = vec![]; + scan(main_messages, subs, &mut named); + for (_, v) in subs.iter() { + if let Some(msgs) = v.get("messages").and_then(|m| m.as_array()) { + scan(msgs, subs, &mut named); + } + } + for (id, name) in named { + if let Some(o) = subs.get_mut(&id).and_then(|s| s.as_object_mut()) { + o.insert("skill".into(), json!(name)); + } + } +} diff --git a/src-tauri/src/history/subagents.rs b/src-tauri/src/history/subagents.rs new file mode 100644 index 0000000..3ec5223 --- /dev/null +++ b/src-tauri/src/history/subagents.rs @@ -0,0 +1,181 @@ +use serde_json::{json, Value}; +use std::fs; +use std::path::{Path, PathBuf}; + +use super::jsonl::{parse_lines, read_session_bytes, read_session_text}; +use super::norm::shape_messages; +use super::skills::skill_from_recs; + +/// Read a session's child subagent dialogues from `/subagents/agent-*.jsonl` (+ .meta.json), +/// keyed by the spawning tool_use id so the renderer can nest them. {} when none. (history.js readSubagents) +pub(super) fn read_subagents(file: &str) -> serde_json::Map { + let p = Path::new(file); + let qoder = crate::qoder::looks_qoder_path(p); + let stem = p.file_stem().and_then(|s| s.to_str()).unwrap_or(""); + let dir = match p.parent() { + Some(d) => d.join(stem).join("subagents"), + None => return serde_json::Map::new(), + }; + let mut by_tool = serde_json::Map::new(); + let entries = match fs::read_dir(&dir) { + Ok(e) => e, + Err(_) => return by_tool, + }; + let mut agent_files: Vec<(String, PathBuf)> = vec![]; + for ent in entries.flatten() { + let name = ent.file_name().to_string_lossy().to_string(); + if name.starts_with("agent-") && name.ends_with(".jsonl") { + agent_files.push((name, ent.path())); + } + } + // A protected qoder session's subagent transcripts + meta sidecars warm in one helper batch + // instead of two spawns per agent. + if qoder { + let mut warm: Vec = vec![]; + for (name, path) in &agent_files { + warm.push(path.clone()); + let agent_id = name.trim_start_matches("agent-").trim_end_matches(".jsonl"); + warm.push(dir.join(format!("agent-{}.meta.json", agent_id))); + } + crate::qoder::prefetch(&warm); + } + for (name, transcript_path) in agent_files { + let agent_id = name + .trim_start_matches("agent-") + .trim_end_matches(".jsonl") + .to_string(); + let meta_path = dir.join(format!("agent-{}.meta.json", agent_id)); + let meta_raw = if qoder { + crate::qoder::read_text(&meta_path) + } else { + fs::read_to_string(&meta_path) + }; + let meta: Value = meta_raw + .ok() + .and_then(|s| serde_json::from_str(&s).ok()) + .unwrap_or_else(|| json!({})); + let raw = match read_session_text(&transcript_path) { + Ok(s) => s, + Err(_) => continue, + }; + let parsed = parse_lines(&raw); + let recs = if qoder { + crate::qoder::normalize_records(&parsed) + } else { + parsed + }; + let shaped = shape_messages(&recs); + let key = meta + .get("toolUseId") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + .unwrap_or_else(|| format!("agent:{}", agent_id)); + let agent_type = meta + .get("agentType") + .and_then(|v| v.as_str()) + .or_else(|| meta.get("subagent_type").and_then(|v| v.as_str())) + .unwrap_or("agent"); + by_tool.insert( + key, + json!({ + "agentId": agent_id, + "file": transcript_path.to_string_lossy(), + "type": agent_type, + "description": meta.get("description").and_then(|v| v.as_str()).unwrap_or(""), + "skill": skill_from_recs(&recs), + "count": shaped.messages.len(), + "totals": shaped.totals, + "messages": shaped.messages, + }), + ); + } + by_tool +} + +/// A session's subagents directory: `//subagents`. None when the path has no stem. +pub(super) fn subagent_dir(file: &Path) -> Option { + let stem = file.file_stem().and_then(|s| s.to_str())?; + file.parent().map(|d| d.join(stem).join("subagents")) +} + +/// The raw subagent sidecar files for a session — `(agent-*.jsonl | agent-*.meta.json, bytes)`. +/// Empty when the session spawned no subagents. Shared by bundle export, import, and replay-merge. +pub(super) fn read_subagent_files(file: &Path) -> Vec<(String, Vec)> { + let dir = match subagent_dir(file) { + Some(d) => d, + None => return vec![], + }; + let qoder = crate::qoder::looks_qoder_path(file); + let mut out = vec![]; + if let Ok(entries) = fs::read_dir(&dir) { + for ent in entries.flatten() { + let p = ent.path(); + if !p.is_file() { + continue; + } + let name = ent.file_name().to_string_lossy().into_owned(); + let lower = name.to_lowercase(); + if lower.starts_with("agent-") && (lower.ends_with(".jsonl") || lower.ends_with(".meta.json")) { + let bytes = if qoder { + crate::qoder::read_bytes(&p) + } else { + fs::read(&p) + }; + if let Ok(bytes) = bytes { + out.push((name, bytes)); + } + } + } + } + out.sort_by(|a, b| a.0.cmp(&b.0)); // deterministic bundle order + out +} + +/// Whether a session has any subagent transcripts (drives export → .zip vs plain .jsonl). +pub fn session_has_subagents(file: &str) -> bool { + !read_subagent_files(Path::new(file)).is_empty() +} + +/// Build a conversation-bundle ZIP: the main session `.jsonl` at the top level and each +/// subagent file under `subagents/`. Caller uses this only when the session actually has subagents +/// (a plain .jsonl export otherwise). Round-trips through import_zip / splitBundle. +pub fn export_bundle(file: &str) -> std::io::Result> { + let path = Path::new(file); + let main = read_session_bytes(path)?; + let main_name = path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("conversation.jsonl") + .to_string(); + let mut entries = vec![crate::ziputil::Entry { name: main_name, data: main }]; + for (name, bytes) in read_subagent_files(path) { + entries.push(crate::ziputil::Entry { name: format!("subagents/{}", name), data: bytes }); + } + Ok(crate::ziputil::build(&entries)) +} + +/// Absolute paths of a session's subagent transcripts (`/subagents/agent-*.jsonl`), sorted. +/// Empty when the session has no subagents. Powers "Claude 分析": every subagent transcript is +/// attached alongside the main session in the Cowork deep link (which takes a repeated `file=` param), +/// so the analysis covers subagent runs — not just the main thread. +pub fn subagent_transcript_paths(file: &str) -> Vec { + let dir = match subagent_dir(Path::new(file)) { + Some(d) => d, + None => return vec![], + }; + let mut out = vec![]; + if let Ok(entries) = fs::read_dir(&dir) { + for ent in entries.flatten() { + let p = ent.path(); + if !p.is_file() { + continue; + } + let name = ent.file_name().to_string_lossy().to_lowercase(); + if name.starts_with("agent-") && name.ends_with(".jsonl") { + out.push(p.to_string_lossy().into_owned()); + } + } + } + out.sort(); + out +} diff --git a/src-tauri/src/history/tests_foreign.rs b/src-tauri/src/history/tests_foreign.rs new file mode 100644 index 0000000..fa53494 --- /dev/null +++ b/src-tauri/src/history/tests_foreign.rs @@ -0,0 +1,150 @@ +use serde_json::json; +use std::fs; +use std::path::Path; + +use super::edit::delete_session_file; +use super::list::list_sessions; +use super::search::search_sessions; +use super::session::get_session; + +// One work dir carrying ALL foreign layouts (grok sessions/%2F…, copilot session-state/, +// antigravity conversations/*.db): each session must list under its own source with cwd, +// title and detail routed through its shaper, hard-delete must refuse, and content search +// must reach every format. +#[test] +fn foreign_sources_route_end_to_end() { + let base = std::env::temp_dir().join("ccbud-foreign-route-test"); + let _ = fs::remove_dir_all(&base); + + // grok: sessions///chat_history.jsonl + summary.json + let gdir = base.join("sessions").join("%2Ftmp%2Fgproj").join("0199-grok-uuid"); + fs::create_dir_all(&gdir).unwrap(); + fs::write( + gdir.join("chat_history.jsonl"), + "{\"type\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"grok needle walrus\"}]}\n\ + {\"type\":\"assistant\",\"content\":\"done\",\"tool_calls\":[{\"id\":\"c1\",\"name\":\"run_terminal_command\",\"arguments\":\"{\\\"command\\\":\\\"ls\\\"}\"}]}\n", + ) + .unwrap(); + fs::write( + gdir.join("summary.json"), + "{\"info\":{\"id\":\"0199-grok-uuid\",\"cwd\":\"/tmp/gproj\"},\"generated_title\":\"Grok 会话\",\"created_at\":\"2026-06-18T06:27:07.777Z\",\"current_model_id\":\"grok-build\"}", + ) + .unwrap(); + // …and a stray sidecar jsonl the codex walker must NOT sweep into a session row + fs::write(gdir.join("events.jsonl"), "{\"ts\":\"x\",\"type\":\"mcp_config_resolved\"}\n").unwrap(); + + // copilot: session-state//events.jsonl + workspace.yaml + let cdir = base.join("session-state").join("cp-uuid-1"); + fs::create_dir_all(&cdir).unwrap(); + fs::write( + cdir.join("events.jsonl"), + "{\"type\":\"session.start\",\"data\":{\"sessionId\":\"cp-uuid-1\",\"context\":{\"cwd\":\"/tmp/cproj\"}},\"timestamp\":\"2026-07-12T07:26:54.363Z\"}\n\ + {\"type\":\"user.message\",\"data\":{\"content\":\"copilot needle pelican\"},\"timestamp\":\"2026-07-12T07:27:14.463Z\"}\n", + ) + .unwrap(); + fs::write( + cdir.join("workspace.yaml"), + "id: cp-uuid-1\ncwd: /tmp/cproj\nname: Copilot 会话\ncreated_at: 2026-07-12T07:26:54.368Z\n", + ) + .unwrap(); + + // antigravity: conversations/.db with one user step (hand-encoded wire format) + let adir = base.join("conversations"); + fs::create_dir_all(&adir).unwrap(); + let adb = adir.join("agy-uuid-1.db"); + { + fn enc_varint(mut v: u64, out: &mut Vec) { + loop { + let b = (v & 0x7f) as u8; + v >>= 7; + if v == 0 { + out.push(b); + break; + } + out.push(b | 0x80); + } + } + fn put_varint(field: u32, v: u64, out: &mut Vec) { + enc_varint(((field as u64) << 3) | 0, out); + enc_varint(v, out); + } + fn put_bytes(field: u32, data: &[u8], out: &mut Vec) { + enc_varint(((field as u64) << 3) | 2, out); + enc_varint(data.len() as u64, out); + out.extend_from_slice(data); + } + let mut ts = vec![]; + put_varint(1, 1_783_811_237, &mut ts); + let mut meta5 = vec![]; + put_bytes(1, &ts, &mut meta5); + let mut u19 = vec![]; + put_bytes(2, "agy needle capybara".as_bytes(), &mut u19); + let mut step = vec![]; + put_varint(1, 14, &mut step); + put_varint(4, 3, &mut step); + put_bytes(5, &meta5, &mut step); + put_bytes(19, &u19, &mut step); + let conn = rusqlite::Connection::open(&adb).unwrap(); + conn.execute_batch( + "CREATE TABLE steps (idx INTEGER PRIMARY KEY, step_type INTEGER NOT NULL DEFAULT 0, status INTEGER NOT NULL DEFAULT 0, step_payload BLOB);", + ) + .unwrap(); + conn.execute("INSERT INTO steps (idx, step_type, status, step_payload) VALUES (0, 14, 3, ?1)", [&step]) + .unwrap(); + } + { + let conn = rusqlite::Connection::open(base.join("conversation_summaries.db")).unwrap(); + conn.execute_batch( + "CREATE TABLE conversation_summaries (conversation_id TEXT PRIMARY KEY, title TEXT NOT NULL DEFAULT '', preview TEXT NOT NULL DEFAULT '', step_count INTEGER NOT NULL DEFAULT 0, last_modified_time DATETIME, workspace_uris TEXT NOT NULL DEFAULT '[]');", + ) + .unwrap(); + conn.execute( + "INSERT INTO conversation_summaries (conversation_id, title, preview, step_count, workspace_uris) VALUES ('agy-uuid-1', 'Agy 会话', 'p', 1, '[\"file:///tmp/aproj\"]')", + [], + ) + .unwrap(); + } + + let config = json!({ "historyDirs": [ base.to_string_lossy() ] }); + let rows = list_sessions(&config, "all", 50); + let by = |src: &str| { + rows.iter() + .find(|r| r.get("source").and_then(|v| v.as_str()) == Some(src)) + .unwrap_or_else(|| panic!("no {} row in {:?}", src, rows)) + .clone() + }; + // exactly one row per source — the grok dir's stray events.jsonl must not add a fourth + assert_eq!(rows.len(), 3, "rows: {:?}", rows); + let (g, c, a) = (by("grok"), by("copilot"), by("antigravity")); + assert_eq!(g["cwd"], "/tmp/gproj"); + assert_eq!(g["title"], "Grok 会话"); + assert_eq!(g["model"], "grok-build"); + assert_eq!(c["cwd"], "/tmp/cproj"); + assert_eq!(c["title"], "Copilot 会话"); + assert_eq!(a["cwd"], "/tmp/aproj"); + assert_eq!(a["title"], "Agy 会话"); + + // detail routes through each shaper (assistant name is the renderer's header/stat hook) + for (row, assistant, first_text) in [ + (&g, "Grok", "grok needle walrus"), + (&c, "Copilot", "copilot needle pelican"), + (&a, "Antigravity", "agy needle capybara"), + ] { + let file = row["file"].as_str().unwrap(); + let d = get_session(file); + assert_eq!(d["meta"]["assistant"], assistant); + assert_eq!(d["messages"][0]["content"][0]["text"], first_text); + // another tool's live file: delete-forever must refuse and leave it on disk + let del = delete_session_file(file, &config); + assert_eq!(del["reason"], "foreign"); + assert!(Path::new(file).is_file()); + } + + // content search reaches every format (agy has no raw-text prefilter path) + for needle in ["walrus", "pelican", "capybara"] { + let hits = search_sessions(&config, "all", needle, 10); + assert_eq!(hits.len(), 1, "search {}: {:?}", needle, hits); + } + + let _ = fs::remove_dir_all(&base); +} diff --git a/src-tauri/src/history/tests_misc.rs b/src-tauri/src/history/tests_misc.rs new file mode 100644 index 0000000..7e7a0f6 --- /dev/null +++ b/src-tauri/src/history/tests_misc.rs @@ -0,0 +1,171 @@ +use serde_json::{json, Value}; +use std::fs; + +use super::edit::{delete_session_file, set_ccbud}; +use super::jsonl::session_read_error; +use super::list::list_sessions; +use super::session::get_session; +use super::subagents::{export_bundle, session_has_subagents, subagent_transcript_paths}; + +#[test] +fn session_read_errors_are_structured() { + let base = std::env::temp_dir().join(format!( + "ccbud-session-read-error-test-{}", + std::process::id() + )); + let _ = fs::remove_dir_all(&base); + let project = base.join(".qoder").join("projects").join("-tmp-error"); + fs::create_dir_all(&project).unwrap(); + + let missing = project.join("missing.jsonl"); + let detail = get_session(&missing.to_string_lossy()); + assert_eq!(detail["error"]["kind"], "notFound"); + + let invalid = project.join("invalid.jsonl"); + fs::write(&invalid, [0xff, 0xfe]).unwrap(); + let detail = get_session(&invalid.to_string_lossy()); + assert_eq!(detail["error"]["kind"], "readFailed"); + + let denied = session_read_error( + &invalid, + &std::io::Error::new(std::io::ErrorKind::PermissionDenied, "denied"), + ); + assert_eq!(denied["error"]["kind"], "permissionDenied"); + + let _ = fs::remove_dir_all(&base); +} + +// A live Codex rollout (a work dir's sessions/ tree, no .import.json) must NEVER be hard-deleted +// by "delete forever" — it's another tool's file. delete_session_file must refuse and leave it on +// disk. A Claude session in the same dir's projects/ tree is still deletable. +#[test] +fn delete_forever_refuses_live_codex_rollout() { + let base = std::env::temp_dir().join("ccbud-codex-del-test"); + let _ = fs::remove_dir_all(&base); + // codex rollout under /sessions/… + let sdir = base.join("sessions").join("2026").join("07").join("04"); + fs::create_dir_all(&sdir).unwrap(); + let codex_file = sdir.join("rollout-x.jsonl"); + fs::write( + &codex_file, + "{\"timestamp\":\"2026-07-04T00:00:00Z\",\"type\":\"session_meta\",\"payload\":{\"session_id\":\"x\",\"cwd\":\"/x\"}}\n\ + {\"timestamp\":\"2026-07-04T00:00:01Z\",\"type\":\"response_item\",\"payload\":{\"type\":\"message\",\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"hi\"}]}}\n", + ) + .unwrap(); + // claude session under /projects/… + let pdir = base.join("projects").join("-x"); + fs::create_dir_all(&pdir).unwrap(); + let claude_file = pdir.join("s1.jsonl"); + fs::write(&claude_file, "{\"type\":\"user\",\"message\":{\"role\":\"user\",\"content\":\"hi\"},\"cwd\":\"/x\",\"sessionId\":\"s1\"}\n").unwrap(); + + let config = json!({ "historyDirs": [ base.to_string_lossy() ] }); + + // live codex → refused, file survives + let r = delete_session_file(&codex_file.to_string_lossy(), &config); + assert_eq!(r.get("reason").and_then(|v| v.as_str()), Some("foreign"), "live codex must be refused"); + assert!(codex_file.is_file(), "codex rollout must NOT be deleted"); + + // claude session → deleted + let r2 = delete_session_file(&claude_file.to_string_lossy(), &config); + assert_eq!(r2.get("ok").and_then(|v| v.as_bool()), Some(true)); + assert!(!claude_file.is_file(), "claude session should be gone"); + + let _ = fs::remove_dir_all(&base); +} + +// Export a session-with-subagents and prove the .zip splits back into the main session + both +// subagent sidecars (the shape import_zip then writes into the store). Avoids mutating CCBUD_HOME +// so it can't race other threads under `cargo test`; the store round-trip is covered by the +// in-app import_selftest and confirms in review via write_imported (shared with import_one). +#[test] +fn export_bundle_round_trips_through_split() { + let base = std::env::temp_dir().join("ccbud-bundle-test"); + let _ = fs::remove_dir_all(&base); + let proj = base.join("projects").join("-bnd-cwd"); + fs::create_dir_all(&proj).unwrap(); + let main = proj.join("bundsess.jsonl"); + fs::write(&main, "{\"type\":\"assistant\",\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"tool_use\",\"id\":\"tu9\",\"name\":\"Task\",\"input\":{}}]},\"cwd\":\"/bnd/cwd\",\"sessionId\":\"bundsess\",\"timestamp\":\"2025-01-01T10:00:00.000Z\"}\n").unwrap(); + let sub = proj.join("bundsess").join("subagents"); + fs::create_dir_all(&sub).unwrap(); + fs::write(sub.join("agent-b1.jsonl"), "{\"type\":\"assistant\",\"isSidechain\":true,\"agentId\":\"b1\",\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"sub done\"}]},\"sessionId\":\"bundsess\",\"timestamp\":\"2025-01-01T10:00:01.000Z\"}\n").unwrap(); + fs::write(sub.join("agent-b1.meta.json"), "{\"agentType\":\"general-purpose\",\"description\":\"d\",\"toolUseId\":\"tu9\"}").unwrap(); + + assert!(session_has_subagents(&main.to_string_lossy())); + + let zip = export_bundle(&main.to_string_lossy()).unwrap(); + assert!(zip.starts_with(&[0x50, 0x4b, 0x03, 0x04]), "starts with PK local header"); + + let (m, subs) = crate::ziputil::split_bundle(crate::ziputil::read(&zip)); + assert_eq!(m.as_ref().map(|(n, _)| n.as_str()), Some("bundsess.jsonl")); + assert_eq!(subs.len(), 2); + assert!(subs.iter().any(|(n, d)| n == "agent-b1.jsonl" && String::from_utf8_lossy(d).contains("sub done"))); + assert!(subs.iter().any(|(n, _)| n == "agent-b1.meta.json")); + + let _ = fs::remove_dir_all(&base); +} + +// The list is ordered by the session's FIRST RECORD TIMESTAMP, not fs times — a title/tag +// edit rewrites the file via tmp+rename (which resets its fs birth time to "now") and must +// NOT reshuffle the list. +#[test] +fn list_order_survives_title_and_tag_edits() { + let base = std::env::temp_dir().join("ccbud-order-test"); + let _ = fs::remove_dir_all(&base); + let proj = base.join("projects").join("-ord-cwd"); + fs::create_dir_all(&proj).unwrap(); + let older = proj.join("older.jsonl"); + let newer = proj.join("newer.jsonl"); + fs::write(&older, "{\"type\":\"user\",\"message\":{\"role\":\"user\",\"content\":\"old one\"},\"cwd\":\"/ord/cwd\",\"sessionId\":\"older\",\"timestamp\":\"2025-01-01T10:00:00.000Z\"}\n").unwrap(); + fs::write(&newer, "{\"type\":\"user\",\"message\":{\"role\":\"user\",\"content\":\"new one\"},\"cwd\":\"/ord/cwd\",\"sessionId\":\"newer\",\"timestamp\":\"2025-06-01T10:00:00.000Z\"}\n").unwrap(); + let config = json!({ "historyDirs": [ base.to_string_lossy() ] }); + let order = |cfg: &Value| -> Vec { + list_sessions(cfg, "all", 50) + .iter() + .filter(|s| s.get("cwd").and_then(|v| v.as_str()) == Some("/ord/cwd")) + .map(|s| s.get("sessionId").and_then(|v| v.as_str()).unwrap_or("").to_string()) + .collect() + }; + assert_eq!(order(&config), vec!["newer", "older"], "newest record time first"); + + // Rename + tag the OLDER session: the file is rewritten through a fresh tmp inode, yet + // the list order must not change. + let r = set_ccbud(&older.to_string_lossy(), &json!({ "title": "Renamed", "tags": ["pinned"] }), &config); + assert_eq!(r.get("ok").and_then(|v| v.as_bool()), Some(true)); + assert_eq!(order(&config), vec!["newer", "older"], "tag/title edit must not reshuffle"); + + // And the row's createdAt still reflects the record timestamp, not the rewrite moment, + // while the edited title shows up immediately (list-meta memo invalidated by the write). + let rows = list_sessions(&config, "all", 50); + let row = rows.iter().find(|s| s.get("sessionId").and_then(|v| v.as_str()) == Some("older")).unwrap(); + let want = chrono::DateTime::parse_from_rfc3339("2025-01-01T10:00:00.000Z").unwrap().timestamp_millis() as f64; + assert_eq!(row.get("createdAt").and_then(|v| v.as_f64()), Some(want)); + assert_eq!(row.get("title").and_then(|v| v.as_str()), Some("Renamed")); + + let _ = fs::remove_dir_all(&base); +} + +#[test] +fn subagent_transcript_paths_lists_only_agent_jsonl() { + let base = std::env::temp_dir().join("ccbud-subpaths-test"); + let _ = fs::remove_dir_all(&base); + let proj = base.join("projects").join("-m-cwd"); + fs::create_dir_all(&proj).unwrap(); + let main = proj.join("m.jsonl"); + fs::write(&main, "{\"type\":\"user\",\"message\":{\"role\":\"user\",\"content\":\"hi\"},\"sessionId\":\"m\"}\n").unwrap(); + // no subagents → empty (caller attaches only the main file) + assert!(subagent_transcript_paths(&main.to_string_lossy()).is_empty()); + + let sub = proj.join("m").join("subagents"); + fs::create_dir_all(&sub).unwrap(); + fs::write(sub.join("agent-a.jsonl"), "{}\n").unwrap(); + fs::write(sub.join("agent-b.jsonl"), "{}\n").unwrap(); + fs::write(sub.join("agent-a.meta.json"), "{}").unwrap(); // sidecar must be excluded + + let paths = subagent_transcript_paths(&main.to_string_lossy()); + assert_eq!(paths.len(), 2, "only the two agent-*.jsonl, not the .meta.json"); + assert!(paths.iter().all(|p| p.ends_with(".jsonl"))); + assert!(paths.iter().any(|p| p.ends_with("agent-a.jsonl"))); + assert!(paths.iter().any(|p| p.ends_with("agent-b.jsonl"))); + + let _ = fs::remove_dir_all(&base); +} diff --git a/src-tauri/src/history/tests_qoder.rs b/src-tauri/src/history/tests_qoder.rs new file mode 100644 index 0000000..e52808e --- /dev/null +++ b/src-tauri/src/history/tests_qoder.rs @@ -0,0 +1,160 @@ +use serde_json::{json, Value}; +use std::fs; +use std::path::Path; + +use super::edit::delete_session_file; +use super::import::qoder_import_raw; +use super::list::list_sessions; +use super::search::search_sessions; +use super::session::get_session; + +// Qoder writes Claude-like atomic event wrappers plus inline metadata into its own tree. +// Rows and detail must use that metadata, merge one assistant response's content blocks, +// retain queued commands as user turns, nest subagents, and remain searchable/exportable. +#[test] +fn qoder_sessions_route_end_to_end() { + let base = std::env::temp_dir().join("ccbud-qoder-route-test"); + let _ = fs::remove_dir_all(&base); + let root = base.join(".qoder"); + let proj = root.join("projects").join("-tmp-qproj"); + fs::create_dir_all(&proj).unwrap(); + let uuid = "11111111-1111-4111-8111-111111111111"; + let sess = proj.join(format!("{}.jsonl", uuid)); + let records = vec![ + json!({ "type": "agent-setting", "agentSetting": "triage", "entrypoint": "sdk-cli", "sessionId": uuid }), + json!({ "type": "last-prompt", "sessionId": uuid, "lastPrompt": "last prompt fallback" }), + json!({ "type": "ai-title", "sessionId": uuid, "aiTitle": "Qoder 会话" }), + json!({ "type": "workspace-directories", "sessionId": uuid, "directories": ["/tmp/qproj"] }), + json!({ "type": "runtime-config", "sessionId": uuid, "model": "ultimate", "reasoningEffort": "high" }), + json!({ + "type": "user", "uuid": "u1", "timestamp": "2026-06-04T09:47:27.966Z", + "message": { "role": "user", "content": "qoder needle axolotl" }, + "sessionId": uuid, "version": "1.1.13" + }), + json!({ + "type": "assistant", "uuid": "a1", "parentUuid": "u1", "timestamp": "2026-06-04T09:47:32.116Z", + "message": { "id": "msg_1", "type": "message", "role": "assistant", "model": "wire-model", "content": [ + { "type": "redacted_thinking", "data": "must not render" } + ]}, "sessionId": uuid + }), + json!({ + "type": "assistant", "uuid": "a2", "parentUuid": "a1", "timestamp": "2026-06-04T09:47:32.216Z", + "message": { "id": "msg_1", "type": "message", "role": "assistant", "content": [ + { "type": "thinking", "thinking": "considering" } + ]}, "sessionId": uuid + }), + json!({ + "type": "assistant", "uuid": "a3", "parentUuid": "a2", "timestamp": "2026-06-04T09:47:32.316Z", + "message": { "id": "msg_1", "type": "message", "role": "assistant", "content": [ + { "type": "text", "text": "done" } + ]}, "sessionId": uuid + }), + json!({ + "type": "assistant", "uuid": "a4", "parentUuid": "a3", "timestamp": "2026-06-04T09:47:32.416Z", + "message": { + "id": "msg_1", "type": "message", "role": "assistant", "stop_reason": "end_turn", + "usage": { "input_tokens": 100, "cache_creation_input_tokens": 7, "cache_read_input_tokens": 50, "output_tokens": 30 }, + "content": [{ "type": "tool_use", "id": "tu1", "name": "Task", "input": {} }] + }, "sessionId": uuid + }), + json!({ + "type": "attachment", "attachment": { "type": "queued_command", "prompt": "queued narwhal follow-up", "commandMode": false }, + "uuid": "u2", "parentUuid": "a4", "timestamp": "2026-06-04T09:47:35.000Z", "sessionId": uuid + }), + ]; + let raw = records + .iter() + .map(|record| serde_json::to_string(record).unwrap()) + .collect::>() + .join("\n") + + "\n"; + fs::write(&sess, raw).unwrap(); + let sub = proj.join(uuid).join("subagents"); + fs::create_dir_all(&sub).unwrap(); + fs::write( + sub.join("agent-q1.jsonl"), + format!("{{\"type\":\"assistant\",\"isSidechain\":true,\"agentId\":\"q1\",\"message\":{{\"role\":\"assistant\",\"content\":[{{\"type\":\"text\",\"text\":\"sub quetzal done\"}}]}},\"sessionId\":\"{}\",\"timestamp\":\"2026-06-04T09:47:40.000Z\"}}\n", uuid), + ) + .unwrap(); + fs::write( + sub.join("agent-q1.meta.json"), + "{\"agentType\":\"general-purpose\",\"description\":\"d\",\"toolUseId\":\"tu1\"}", + ) + .unwrap(); + + let config = json!({ "historyDirs": [ root.to_string_lossy() ] }); + let rows = list_sessions(&config, "all", 50); + assert_eq!(rows.len(), 1, "rows: {:?}", rows); + let r = &rows[0]; + assert_eq!(r["source"], "qoder"); + assert_eq!(r["id"], format!("qoder:{}", uuid)); + assert_eq!(r["title"], "Qoder 会话"); + assert_eq!(r["autoTitle"], "Qoder 会话"); + assert_eq!(r["cwd"], "/tmp/qproj"); + assert_eq!(r["model"], "ultimate"); + assert_eq!(r["deleted"], false); + + let file = r["file"].as_str().unwrap(); + let d = get_session(file); + assert_eq!(d["meta"]["assistant"], "Qoder"); + assert_eq!(d["meta"]["source"], "qoder"); + assert_eq!(d["meta"]["id"], format!("qoder:{}", uuid)); + assert_eq!(d["meta"]["title"], "Qoder 会话"); + assert_eq!(d["meta"]["model"], "ultimate"); + assert_eq!(d["meta"]["subagentCount"], 1); + assert_eq!(d["messages"].as_array().unwrap().len(), 3); + assert_eq!(d["messages"][0]["content"], "qoder needle axolotl"); // string-content user turn + let assistant_blocks = d["messages"][1]["content"].as_array().unwrap(); + assert_eq!( + assistant_blocks + .iter() + .filter_map(|block| block.get("type").and_then(Value::as_str)) + .collect::>(), + vec!["thinking", "text", "tool_use"] + ); + assert_eq!(d["messages"][1]["usage"]["inputTokens"], 100); + assert_eq!(d["messages"][1]["stopReason"], "end_turn"); + assert_eq!(d["messages"][2]["role"], "user"); + assert_eq!(d["messages"][2]["content"], "queued narwhal follow-up"); + assert_eq!(d["subagents"]["tu1"]["messages"][0]["content"][0]["text"], "sub quetzal done"); + + // another tool's live file: delete-forever must refuse and leave it on disk + let del = delete_session_file(file, &config); + assert_eq!(del["reason"], "foreign"); + assert!(Path::new(file).is_file()); + + // content search reaches the main thread and the subagent transcript + let hits = search_sessions(&config, "all", "axolotl", 10); + assert_eq!(hits.len(), 1, "{:?}", hits); + assert_eq!(hits[0]["agent"], "main"); + let hits = search_sessions(&config, "all", "narwhal", 10); + assert_eq!(hits.len(), 1, "{:?}", hits); + assert_eq!(hits[0]["agent"], "main"); + let hits = search_sessions(&config, "all", "quetzal", 10); + assert_eq!(hits.len(), 1, "{:?}", hits); + assert_eq!(hits[0]["agent"], "tu1"); + + let _ = fs::remove_dir_all(&base); +} + +// Imported qoder content is rewritten to Claude shape (wrappers merged, queued commands +// materialized) with qoder's own title carried onto __ccbud__; Claude content passes through. +#[test] +fn qoder_imports_are_normalized_with_title() { + let recs = vec![ + json!({ "type": "ai-title", "aiTitle": "Qoder 导入标题" }), + json!({ "type": "assistant", "uuid": "w1", "message": { "id": "m1", "role": "assistant", "content": [{ "type": "thinking", "thinking": "t" }] } }), + json!({ "type": "assistant", "uuid": "w2", "message": { "id": "m1", "role": "assistant", "content": [{ "type": "text", "text": "done" }] } }), + json!({ "type": "attachment", "attachment": { "type": "queued_command", "prompt": "queued prompt" } }), + ]; + let (text, normalized) = qoder_import_raw(&recs).expect("sniffs as qoder"); + let assistants: Vec<&Value> = normalized.iter().filter(|r| r["type"] == "assistant").collect(); + assert_eq!(assistants.len(), 1, "wrappers merged: {:?}", normalized); + assert_eq!(assistants[0]["message"]["content"].as_array().unwrap().len(), 2); + assert!(normalized + .iter() + .any(|r| r["type"] == "user" && r["message"]["content"] == "queued prompt")); + let first: Value = serde_json::from_str(text.lines().next().unwrap()).unwrap(); + assert_eq!(first["__ccbud__"]["title"], "Qoder 导入标题"); + assert!(qoder_import_raw(&[json!({ "type": "user", "message": { "content": "hi" } })]).is_none()); +} diff --git a/src-tauri/src/history/tests_search.rs b/src-tauri/src/history/tests_search.rs new file mode 100644 index 0000000..26b95c6 --- /dev/null +++ b/src-tauri/src/history/tests_search.rs @@ -0,0 +1,122 @@ +use serde_json::json; +use std::fs; + +use super::search::search_sessions; +use super::searchfmt::strip_injected; + +// The Rust search extractor and the renderer's stripInjected must agree: a task-notification +// envelope surfaces only its body — transport metadata must never be searchable. +#[test] +fn strip_injected_unwraps_task_notifications() { + let s = strip_injected( + "before \ncompleted\ntransport-noise\n\nDone **ok**\n\n after", + ); + assert!(s.contains("before") && s.contains("after")); + assert!(s.contains("Done **ok**")); + assert!(!s.contains("transport-noise")); + assert!(!s.contains("task-notification")); + // an envelope without a vanishes wholesale, like a system-reminder + let gone = strip_injected("x running y"); + assert!(gone.contains('x') && gone.contains('y') && !gone.contains("running")); + // the pre-existing rules still apply after the unwrap + assert_eq!(strip_injected("himeta"), "hi"); + // a standalone Codex injection vanishes; quoting one alongside prose does not + assert_eq!(strip_injected(" skill-body\n"), ""); + assert!(strip_injected("see quoted here").contains("quoted")); +} + +// The Codex AGENTS bootstrap must index as the SAME compact Markdown the panel renders +// (formatCodexBootstrap parity) — not as the raw XML-ish transport shape. +#[test] +fn strip_injected_formats_codex_bootstrap_like_the_panel() { + let raw = "# AGENTS.md instructions for /work/proj\n\n\nAlways run tests.\n\n\n\n /work/proj\n zsh\n /work/proj\n \n"; + let s = strip_injected(raw); + assert!(s.starts_with("# AGENTS.md instructions for /work/proj"), "{s}"); + assert!(s.contains("**INSTRUCTIONS:** Always run tests."), "{s}"); + assert!(s.contains("**environment_context:** `/work/proj`"), "{s}"); + assert!(s.contains("**shell:** zsh"), "{s}"); + assert!(s.contains("**workspace_roots:** `/work/proj`"), "{s}"); + assert!(s.contains("**permission_profile:** workspace-write"), "{s}"); + assert!(!s.contains("\na\nb\n"); + assert!(multi.contains("**INSTRUCTIONS:**\n\na\nb"), "{multi}"); + // ordinary prose is untouched + assert_eq!(strip_injected("ordinary prose"), "ordinary prose"); +} + +// Content search: a main-thread hit reports agent "main"; a subagent-only hit reports the +// spawning tool_use key (+ agent type); injected text never matches; and +// ASCII case folds. Runs twice so the second pass exercises the extraction cache. +#[test] +fn search_sessions_finds_main_and_subagent_content() { + let base = std::env::temp_dir().join("ccbud-search-test"); + let _ = fs::remove_dir_all(&base); + let proj = base.join("projects").join("-srch-cwd"); + fs::create_dir_all(&proj).unwrap(); + let main = proj.join("srchsess.jsonl"); + fs::write( + &main, + "{\"type\":\"user\",\"message\":{\"role\":\"user\",\"content\":\"find the zebra crossingreminder-secret\"},\"cwd\":\"/srch/cwd\",\"sessionId\":\"srchsess\",\"timestamp\":\"2025-01-01T10:00:00.000Z\"}\n\ + {\"type\":\"assistant\",\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"tool_use\",\"id\":\"tu1\",\"name\":\"Task\",\"input\":{}}]},\"sessionId\":\"srchsess\",\"timestamp\":\"2025-01-01T10:00:01.000Z\"}\n", + ) + .unwrap(); + let sub = proj.join("srchsess").join("subagents"); + fs::create_dir_all(&sub).unwrap(); + fs::write( + sub.join("agent-s1.jsonl"), + "{\"type\":\"assistant\",\"isSidechain\":true,\"agentId\":\"s1\",\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"the quokka was found here\"}]},\"sessionId\":\"srchsess\",\"timestamp\":\"2025-01-01T10:00:02.000Z\"}\n", + ) + .unwrap(); + fs::write(sub.join("agent-s1.meta.json"), "{\"agentType\":\"explore\",\"description\":\"d\",\"toolUseId\":\"tu1\"}").unwrap(); + // Content stored as \uXXXX escapes (e.g. python json.dumps output) — a byte scan can't + // see the decoded text, so non-ASCII queries must bypass the raw prefilter. + let esc = proj.join("escsess.jsonl"); + fs::write( + &esc, + "{\"type\":\"user\",\"message\":{\"role\":\"user\",\"content\":\"\\u4e2d\\u6587\\u5185\\u5bb9 escaped\"},\"cwd\":\"/srch/cwd\",\"sessionId\":\"escsess\",\"timestamp\":\"2025-01-02T10:00:00.000Z\"}\n", + ) + .unwrap(); + // A codex rollout in the same work dir's sessions/ tree — its own record format, scanned + // through the codex shaper. + let cdir = base.join("sessions").join("2026").join("07").join("04"); + fs::create_dir_all(&cdir).unwrap(); + fs::write( + cdir.join("rollout-c.jsonl"), + "{\"timestamp\":\"2026-07-04T00:00:00Z\",\"type\":\"session_meta\",\"payload\":{\"session_id\":\"c1\",\"cwd\":\"/cx\"}}\n\ + {\"timestamp\":\"2026-07-04T00:00:01Z\",\"type\":\"response_item\",\"payload\":{\"type\":\"message\",\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"codex kangaroo request\"}]}}\n", + ) + .unwrap(); + let config = json!({ "historyDirs": [ base.to_string_lossy() ] }); + + for pass in 0..2 { + // main-thread hit + let hits = search_sessions(&config, "all", "zebra crossing", 50); + assert_eq!(hits.len(), 1, "pass {}: one session matches", pass); + assert_eq!(hits[0].get("agent").and_then(|v| v.as_str()), Some("main")); + assert!(hits[0].get("snippet").and_then(|v| v.as_str()).unwrap_or("").contains("zebra")); + + // subagent-only hit → keyed by the spawning tool_use id, labeled with the agent type + let hits = search_sessions(&config, "all", "QUOKKA", 50); // also proves case folding + assert_eq!(hits.len(), 1); + assert_eq!(hits[0].get("agent").and_then(|v| v.as_str()), Some("tu1")); + assert_eq!(hits[0].get("agentType").and_then(|v| v.as_str()), Some("explore")); + + // codex rollout content is searchable too + let hits = search_sessions(&config, "all", "kangaroo", 50); + assert_eq!(hits.len(), 1, "pass {}: codex rollout matches", pass); + assert_eq!(hits[0].get("agent").and_then(|v| v.as_str()), Some("main")); + + // \uXXXX-escaped content still matches a non-ASCII query (no raw prefilter for those) + let hits = search_sessions(&config, "all", "中文", 50); + assert_eq!(hits.len(), 1, "pass {}: escaped unicode content matches", pass); + assert!(hits[0].get("snippet").and_then(|v| v.as_str()).unwrap_or("").contains("中文内容")); + + // injected system-reminder content is NOT searchable (matches the renderer) + assert!(search_sessions(&config, "all", "reminder-secret", 50).is_empty()); + // no match at all + assert!(search_sessions(&config, "all", "wombat", 50).is_empty()); + } + + let _ = fs::remove_dir_all(&base); +} diff --git a/src-tauri/src/history/text.rs b/src-tauri/src/history/text.rs new file mode 100644 index 0000000..2499089 --- /dev/null +++ b/src-tauri/src/history/text.rs @@ -0,0 +1,87 @@ +use serde_json::Value; + +pub(super) fn content_text(content: &Value) -> String { + if let Some(s) = content.as_str() { + return s.to_string(); + } + if let Some(arr) = content.as_array() { + return arr + .iter() + .filter(|b| b.get("type").and_then(|t| t.as_str()) == Some("text")) + .filter_map(|b| b.get("text").and_then(|t| t.as_str())) + .collect::>() + .join(" "); + } + String::new() +} + +fn command_label(raw: &str) -> String { + let name = raw + .split_once("") + .and_then(|(_, r)| r.split_once("")) + .map(|(n, _)| n.trim().to_string()) + .unwrap_or_default(); + if name.is_empty() { + return String::new(); + } + let args = raw + .split_once("") + .and_then(|(_, r)| r.split_once("")) + .map(|(a, _)| a.trim().to_string()) + .unwrap_or_default(); + format!("{} {}", name, args).trim().to_string() +} + +/// First human prose turn (skips slash-command XML / meta / interrupt notices), capped at 90 chars. +pub(crate) fn first_user_text(messages: &[Value]) -> String { + let mut fallback_cmd = String::new(); + for m in messages { + if m.get("role").and_then(|r| r.as_str()) != Some("user") { + continue; + } + if m.get("_meta").and_then(|v| v.as_bool()).unwrap_or(false) { + continue; + } + let content = m.get("content").cloned().unwrap_or(Value::Null); + let raw = content_text(&content); + let raw = raw.trim(); + if raw.is_empty() { + continue; + } + if raw.starts_with('<') { + if fallback_cmd.is_empty() { + fallback_cmd = command_label(raw); + } + continue; + } + let t: String = raw.split_whitespace().collect::>().join(" "); + if t.starts_with("[Request interrupted") || t.starts_with("Caveat:") { + continue; + } + return t.chars().take(90).collect(); + } + fallback_cmd.chars().take(90).collect() +} + +/// __ccbud__ customization (custom title + tags + soft-delete flag) from any record carrying it. +pub(crate) fn read_ccbud(recs: &[Value]) -> (Option, Vec, bool) { + let c = recs.iter().find_map(|r| r.get("__ccbud__")); + let title = c + .and_then(|c| c.get("title")) + .and_then(|t| t.as_str()) + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()); + let tags = c + .and_then(|c| c.get("tagList")) + .and_then(|t| t.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|t| t.as_str()) + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect() + }) + .unwrap_or_default(); + let deleted = c.and_then(|c| c.get("delete")).and_then(|v| v.as_bool()).unwrap_or(false); + (title, tags, deleted) +} diff --git a/src-tauri/src/legacybundle.rs b/src-tauri/src/legacybundle.rs new file mode 100644 index 0000000..ccc1af9 --- /dev/null +++ b/src-tauri/src/legacybundle.rs @@ -0,0 +1,51 @@ +// Legacy macOS bundle rename, moved verbatim from lib.rs. (run() calls the copy that lives in +// startup.rs; this one is kept as-is by the split.) + +/// Older installs live in "ccbud.app" (pre-1.3.4) or "CCBuddy.app" (1.3.4). The +/// in-app updater swaps the bundle's contents but never the folder itself, and +/// macOS shows CFBundleDisplayName only when the folder name matches CFBundleName +/// ("CC Buddy") — any mismatch makes the Dock and the Applications list fall back +/// to the folder name. Rename the bundle once, relaunch from the new path so +/// Launch Services re-registers it, and exit. Bails out on any obstacle +/// (translocation, read-only volume, name already taken) and keeps running under +/// the old name. +#[cfg(target_os = "macos")] +fn migrate_legacy_bundle_name() { + let exe = match std::env::current_exe() { + Ok(p) => p, + Err(_) => return, + }; + // exe = /.app/Contents/MacOS/ + let bundle = match exe.ancestors().nth(3) { + Some(p) + if matches!( + p.file_name().and_then(|n| n.to_str()), + Some("ccbud.app") | Some("CCBuddy.app") + ) => + { + p.to_path_buf() + } + _ => return, + }; + let target = match bundle.parent() { + Some(dir) => dir.join("CC Buddy.app"), + None => return, + }; + if target.exists() || std::fs::rename(&bundle, &target).is_err() { + return; + } + // `open -n` asks Launch Services to start a fresh instance from the new path + // (which also re-registers the name). Wait for its verdict rather than exiting + // on spawn: a refusal must restore the old name so the running process keeps a + // valid bundle path behind it instead of leaving the user with nothing open. + let launched = std::process::Command::new("/usr/bin/open") + .arg("-n") + .arg(&target) + .status() + .map(|s| s.success()) + .unwrap_or(false); + if launched { + std::process::exit(0); + } + let _ = std::fs::rename(&target, &bundle); +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 3567870..646beed 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -9,1783 +9,40 @@ mod antigravity; mod claude; mod codex; mod codexconnect; +mod commands; mod copilot; mod counttokens; mod exporthtml; mod gateway; mod grok; mod history; +mod legacybundle; mod plugin; +mod popover; mod protocol; mod qoder; mod sidecar; +mod startup; mod store; +mod tray; +mod trayicon; mod usage; mod ziputil; -use serde_json::{json, Value}; -use tauri::{Emitter, Manager}; +use tauri::Manager; -// Timestamp (ms since epoch) of the last popover hide — used to debounce the tray click, -// which would otherwise re-show the popover on the very click that blurred it shut. -static LAST_POPOVER_HIDE_MS: std::sync::atomic::AtomicI64 = std::sync::atomic::AtomicI64::new(0); -// Timestamp of the last popover show — a fullscreen app steals focus the instant the popover -// appears, so we ignore blur within a grace window after show (else it hides before being seen). -static LAST_POPOVER_SHOW_MS: std::sync::atomic::AtomicI64 = std::sync::atomic::AtomicI64::new(0); -fn now_ms() -> i64 { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_millis() as i64) - .unwrap_or(0) -} - -// ---- config / providers (real, store.rs) ---- -#[tauri::command] -fn config_get() -> Value { - store::read_config() -} -/// Last gateway start error (e.g. a bad port the user typed). Surfaced via server:status so the -/// renderer can show the failure banner. Mirrors main.js lastStartError. -static LAST_START_ERROR: std::sync::Mutex> = std::sync::Mutex::new(None); -#[tauri::command] -async fn config_save( - app: tauri::AppHandle, - gw: tauri::State<'_, std::sync::Arc>, - cfg: Value, -) -> Result { - let prev = store::read_config(); - let prev_port = prev.get("port").and_then(|v| v.as_u64()).unwrap_or(8788) as u16; - let next_port = cfg - .get("port") - .and_then(|v| v.as_u64()) - .map(|p| p as u16) - .unwrap_or(prev_port); - let was_connected = claude::is_connected(prev_port); - let codex_was_connected = codexconnect::is_connected(prev_port); - let prev_dirs = prev.get("historyDirs").cloned(); - - // If the gateway is running and the port changed, bind the NEW port BEFORE committing so a bad - // port can never lock the user out — roll back to the old port and report on failure. - if next_port != prev_port && gw.current_port().await.is_some() { - gw.stop().await; - if let Err(e) = gw.start(next_port).await { - let _ = gw.start(prev_port).await; - let msg = format!("端口 {} 启动失败:{}", next_port, e); - *LAST_START_ERROR.lock().unwrap() = Some(msg.clone()); - gw.emit("gateway:status", full_status(&gw).await); - return Err(msg); - } - *LAST_START_ERROR.lock().unwrap() = None; - } - - let saved = store::write_config(cfg); - use tauri_plugin_autostart::ManagerExt; - let want = saved.get("openAtLogin").and_then(|v| v.as_bool()).unwrap_or(false); - let mgr = app.autolaunch(); - let _ = if want { mgr.enable() } else { mgr.disable() }; - - // Keep each connected CLI's config in sync if connected (port/token may have changed). - if was_connected || codex_was_connected { - let port = saved.get("port").and_then(|v| v.as_u64()).unwrap_or(8788) as u16; - let token = claude::current_token(&saved); - if was_connected { - claude::connect(port, &token); - } - if codex_was_connected { - codexconnect::connect(port, &token, &codex_model(&saved)); - } - } - - // History dirs changed → invalidate + re-warm the usage cache and notify the renderer. - if saved.get("historyDirs").cloned() != prev_dirs { - usage::invalidate_cache(); - let cfg2 = saved.clone(); - std::thread::spawn(move || usage::warm_cache(&cfg2, "all")); - let _ = app.emit("history:changed", json!({ "files": [] })); - } - - update_tray_title(&app); - gw.emit("gateway:status", full_status(&gw).await); - Ok(saved) -} -#[tauri::command] -fn provider_upsert(p: Value) -> Value { - let mut cfg = store::read_config(); - let mut provider = p; - let pid = provider.get("id").and_then(|v| v.as_str()).map(|s| s.to_string()); - { - let provs = cfg["providers"].as_array_mut().unwrap(); - match pid { - Some(id) if !id.is_empty() => { - if let Some(i) = provs - .iter() - .position(|x| x.get("id").and_then(|v| v.as_str()) == Some(id.as_str())) - { - provs[i] = provider; - } else { - provs.push(provider); - } - } - _ => { - let id = store::gen_id(); - provider - .as_object_mut() - .unwrap() - .insert("id".into(), json!(id.clone())); - provs.push(provider); - if cfg["activeProviderId"].is_null() { - cfg["activeProviderId"] = json!(id); - } - } - } - } - store::write_config(cfg) -} -#[tauri::command] -fn provider_delete(id: String) -> Value { - let mut cfg = store::read_config(); - let kept: Vec = cfg["providers"] - .as_array() - .map(|a| { - a.iter() - .filter(|p| p.get("id").and_then(|v| v.as_str()) != Some(id.as_str())) - .cloned() - .collect() - }) - .unwrap_or_default(); - cfg["providers"] = json!(kept); - if cfg["activeProviderId"].as_str() == Some(id.as_str()) { - cfg["activeProviderId"] = cfg["providers"] - .as_array() - .and_then(|a| a.first()) - .and_then(|p| p.get("id").cloned()) - .unwrap_or(Value::Null); - } - store::write_config(cfg) -} -#[tauri::command] -fn provider_set_active(pm: PluginState<'_>, id: String) -> Result { - let cfg = store::read_config(); - // A plugin-backed service can only be activated while its plugin is running — - // otherwise the gateway would forward to a dead port. The UI localizes this code. - if let Some(p) = cfg - .get("providers") - .and_then(|v| v.as_array()) - .and_then(|arr| arr.iter().find(|p| p.get("id").and_then(|v| v.as_str()) == Some(id.as_str()))) - { - if p.get("backend").and_then(|v| v.as_str()) == Some("plugin") { - let plugin_id = p.get("pluginId").and_then(|v| v.as_str()).unwrap_or(""); - if !pm.is_running(plugin_id) { - return Err("pluginNotRunning".into()); - } - } - } - let mut cfg = store::read_config(); - cfg["activeProviderId"] = json!(id); - Ok(store::write_config(cfg)) -} - -// ---- plugins (sidecar coding-agent backends, see plugin.rs) ---- -type PluginState<'a> = tauri::State<'a, std::sync::Arc>; - -/// List discovered plugins with running + auth status. -#[tauri::command] -async fn plugin_list(pm: PluginState<'_>) -> Result { - Ok(pm.list().await) -} -/// Single plugin status snapshot. -#[tauri::command] -async fn plugin_status(pm: PluginState<'_>, id: String) -> Result { - Ok(pm.status(&id).await) -} -/// Enable (spawn + health-gate + register provider) or disable (stop the process; the service stays until uninstalled) a plugin. -#[tauri::command] -async fn plugin_set_enabled(pm: PluginState<'_>, id: String, enabled: bool) -> Result { - if enabled { - pm.start(&id).await?; - } else { - pm.stop(&id)?; - } - Ok(pm.status(&id).await) -} -/// Run a plugin-declared UI action: forward form `values` to its control plane. -#[tauri::command] -async fn plugin_action(pm: PluginState<'_>, id: String, action: String, values: Value) -> Result { - pm.action(&id, &action, values).await -} -/// Prefill a plugin action form with the plugin's current values. -#[tauri::command] -async fn plugin_action_load(pm: PluginState<'_>, id: String, action: String) -> Result { - pm.action_load(&id, &action).await -} -/// Add a plugin: pick a local folder containing plugin.json and install it. -/// `title` is the localized folder-picker title (supplied by the renderer). -#[tauri::command] -async fn plugin_install(pm: PluginState<'_>, title: Option) -> Result { - let title = title.filter(|t| !t.trim().is_empty()).unwrap_or_else(|| "Select the plugin folder".into()); - let picked = rfd::AsyncFileDialog::new() - .set_title(&title) - .pick_folder() - .await; - let dir = match picked { - Some(f) => f.path().to_path_buf(), - None => return Ok(json!({ "canceled": true })), - }; - let id = pm.install(&dir)?; - Ok(json!({ "ok": true, "id": id })) -} -/// Remove a plugin (the renderer confirms first): stop it, drop its service, delete its files. -#[tauri::command] -async fn plugin_uninstall(pm: PluginState<'_>, id: String) -> Result { - // The confirmation is shown by the renderer (localized confirmDialog) before - // this is called, so we just do the work here. - pm.uninstall(&id)?; - Ok(json!({ "ok": true })) -} -/// Open the plugins folder in the OS file browser. -#[tauri::command] -fn plugin_open_dir() -> bool { - let dir = plugin::plugins_root(); - let _ = std::fs::create_dir_all(&dir); - #[cfg(target_os = "macos")] - { - std::process::Command::new("open").arg(&dir).spawn().is_ok() - } - #[cfg(target_os = "windows")] - { - std::process::Command::new("explorer").arg(&dir).spawn().is_ok() - } - #[cfg(not(any(target_os = "macos", target_os = "windows")))] - { - std::process::Command::new("xdg-open").arg(&dir).spawn().is_ok() - } -} -/// Install a plugin from a git repository (clone + build + install). Runs the -/// blocking git/build work off the async runtime. -#[tauri::command] -async fn plugin_install_git(pm: PluginState<'_>, url: String) -> Result { - let mgr = pm.inner().clone(); - let id = tokio::task::spawn_blocking(move || mgr.install_from_git(&url)) - .await - .map_err(|e| e.to_string())??; - Ok(json!({ "ok": true, "id": id })) -} -/// Check whether a plugin's git source has a newer version. -#[tauri::command] -async fn plugin_check_update(pm: PluginState<'_>, id: String) -> Result { - Ok(pm.check_update(&id).await) -} -/// Update a plugin from its recorded git source (re-clone + build + replace). -#[tauri::command] -async fn plugin_update(pm: PluginState<'_>, id: String) -> Result { - let mgr = pm.inner().clone(); - let id = tokio::task::spawn_blocking(move || mgr.update(&id)) - .await - .map_err(|e| e.to_string())??; - Ok(json!({ "ok": true, "id": id })) -} -async fn send_provider_probe( - client: &reqwest::Client, - url: &str, - wire: crate::protocol::Wire, - token: &str, - body: &Value, -) -> Result { - let mut request = client - .post(url) - .header("content-type", "application/json") - .header("authorization", format!("Bearer {}", token)); - if wire == crate::protocol::Wire::Anthropic { - request = request.header("anthropic-version", "2023-06-01"); - } - request.json(body).send().await -} - -/// Live connection test: POST a tiny ping to the provider, shaped for its declared wire protocol -/// (Anthropic /messages, OpenAI /chat/completions, or /responses), and report ok/error/timeout. -/// The renderer localizes the result message. -#[tauri::command] -async fn provider_test(app: tauri::AppHandle, p: Value) -> Value { - let base = p.get("baseUrl").and_then(|v| v.as_str()).unwrap_or("").trim(); - if base.is_empty() { - return json!({ "ok": false, "reason": "baseUrlEmpty" }); - } - if !(base.starts_with("http://") || base.starts_with("https://")) { - return json!({ "ok": false, "reason": "baseUrlInvalid" }); - } - // Test against the provider's DECLARED protocol endpoint — an openai-chat provider must be - // pinged at /chat/completions with a Chat body, not the Anthropic /v1/messages default. - let wire = crate::protocol::Wire::from_provider(p.get("protocol").and_then(|v| v.as_str())); - let url = wire.upstream_url(base); - let model = p - .get("defaultModel") - .and_then(|v| v.as_str()) - .filter(|s| !s.is_empty()) - .or_else(|| { - p.get("models") - .and_then(|m| m.as_array()) - .and_then(|a| a.first()) - .and_then(|m| m.get("upstream")) - .and_then(|v| v.as_str()) - .filter(|s| !s.is_empty()) - }) - .unwrap_or("claude-3-5-haiku-20241022") - .to_string(); - let token = p.get("authToken").and_then(|v| v.as_str()).unwrap_or("").to_string(); - let insecure = store::read_config() - .get("insecureSkipVerify") - .and_then(|v| v.as_bool()) - .unwrap_or(false); - // Protocol-shaped ping body. - let body = match wire { - crate::protocol::Wire::OpenAiResponses => json!({ "model": model, "max_output_tokens": 16, "input": "ping" }), - crate::protocol::Wire::OpenAiChat => json!({ "model": model, "max_tokens": 16, "messages": [{ "role": "user", "content": "ping" }] }), - crate::protocol::Wire::Anthropic => json!({ "model": model, "max_tokens": 16, "messages": [{ "role": "user", "content": "ping" }] }), - }; - let client = match reqwest::Client::builder() - .danger_accept_invalid_certs(insecure) - .timeout(std::time::Duration::from_secs(30)) - .build() - { - Ok(c) => c, - Err(e) => return json!({ "ok": false, "message": e.to_string() }), - }; - // Auth via Authorization: Bearer only. Sending both authorization and x-api-key trips - // providers that reject having the two auth headers present at once. - let first = send_provider_probe(&client, &url, wire, &token, &body).await; - match first { - Ok(mut r) => { - let mut migrated_base_url: Option = None; - if crate::protocol::should_try_v1_fallback(r.status().as_u16()) { - if let Some(fallback_url) = wire.v1_fallback_url(base) { - if let Ok(candidate) = send_provider_probe(&client, &fallback_url, wire, &token, &body).await { - if candidate.status().is_success() { - r = candidate; - migrated_base_url = Some(format!("{}/v1", base.trim_end_matches('/'))); - } - } - } - } - let status = r.status().as_u16(); - let text = r.text().await.unwrap_or_default(); - let parsed: Option = serde_json::from_str(&text).ok(); - let http_ok = (200..300).contains(&status); - // A well-shaped reply for the tested protocol: Anthropic `type:message`, Chat `choices`, - // Responses `output`/`id`. - let shape_ok = parsed.as_ref().map(|j| match wire { - crate::protocol::Wire::Anthropic => j.get("type").and_then(|v| v.as_str()) == Some("message"), - crate::protocol::Wire::OpenAiChat => j.get("choices").map(|c| c.is_array()).unwrap_or(false), - crate::protocol::Wire::OpenAiResponses => j.get("output").is_some() || j.get("id").is_some(), - }).unwrap_or(false); - if http_ok && shape_ok { - let m = parsed - .as_ref() - .and_then(|j| j.get("model")) - .and_then(|v| v.as_str()) - .unwrap_or(&model); - if let Some(next_base) = migrated_base_url.as_deref() { - if let Some(id) = p.get("id").and_then(Value::as_str) { - if let Some(saved) = store::migrate_provider_base_url_to_v1(id, base) { - let _ = app.emit("config:changed", saved); - return json!({ "ok": true, "status": status, "model": m, "baseUrl": next_base }); - } - } else { - return json!({ "ok": true, "status": status, "model": m, "baseUrl": next_base }); - } - } - return json!({ "ok": true, "status": status, "model": m }); - } - let msg = parsed - .as_ref() - .and_then(|j| j.get("error")) - .and_then(|e| e.get("message")) - .and_then(|v| v.as_str()) - .map(|s| s.to_string()) - .unwrap_or_else(|| { - if !text.is_empty() { - text.chars().take(200).collect() - } else { - format!("HTTP {}", status) - } - }); - json!({ "ok": false, "status": status, "message": msg }) - } - Err(e) => { - if e.is_timeout() { - json!({ "ok": false, "reason": "timeout" }) - } else { - json!({ "ok": false, "message": e.to_string() }) - } - } - } -} - -// ---- coding CLI connect / replay ---- -/// The literal selected CLIs from config `connectTargets` (subset of {claude, codex}, deduped). -/// Empty is a valid state ("nothing connected") — the hero Connect button substitutes a default. -fn connect_targets(cfg: &Value) -> Vec { - let mut out: Vec = vec![]; - if let Some(a) = cfg.get("connectTargets").and_then(|v| v.as_array()) { - for v in a { - if let Some(s) = v.as_str() { - if (s == "claude" || s == "codex") && !out.iter().any(|x| x == s) { - out.push(s.to_string()); - } - } - } - } - out -} - -/// Plan the safe subset of connections to repair on startup. Older releases could persist the -/// then-default `["claude"]` without the user ever connecting, so selection alone is insufficient: -/// a target's compatibility backup is the proof that CC Buddy previously took ownership of it. -fn startup_reconcile_targets(cfg: &Value) -> Vec { - connect_targets(cfg) - .into_iter() - .filter(|target| { - let backup_key = if target == "claude" { - "claudeBackup" - } else { - "codexBackup" - }; - cfg.get(backup_key).map(Value::is_object).unwrap_or(false) - }) - .collect() -} - -/// Repair only previously managed, still-selected targets. This intentionally has no disconnect -/// branch: startup must not restore/consume an unselected target's compatibility backup. Since each -/// connect call is gated on an existing object backup, it also cannot create a first-time backup. -fn reconcile_connections_on_startup(cfg: &Value) { - let selected = startup_reconcile_targets(cfg); - if selected.is_empty() { - return; - } - let port = cfg.get("port").and_then(|v| v.as_u64()).unwrap_or(8788) as u16; - let token = claude::current_token(cfg); - if selected.iter().any(|target| target == "claude") { - claude::connect(port, &token); - } - if selected.iter().any(|target| target == "codex") { - codexconnect::connect(port, &token, &codex_model(cfg)); - } -} - -/// The legacy one-click Connect command still has a useful default even though startup does not: -/// when no target is selected, choose Claude and persist that now-explicit selection. -fn ensure_hero_connect_target(cfg: &mut Value) -> bool { - if connect_targets(cfg).is_empty() { - cfg["connectTargets"] = json!(["claude"]); - true - } else { - false - } -} - -/// The model written into Codex's config. `gpt-5.4` is a stable model identity understood by the -/// current CLI and enables its normal function/custom tool registry for custom providers. The -/// synthetic `gpt-5.6-sol-pro` identity previously used here selected code-mode metadata and made -/// Codex send an empty Responses `tools` array, so the gateway could never drive an agent turn. -fn codex_model(_cfg: &Value) -> String { - "gpt-5.4".to_string() -} - -/// Make each CLI's config file match the selected `connectTargets`: write the selected ones to -/// point at the gateway, restore the rest. PURELY a config-file operation — the gateway service -/// itself is an independent switch (`gatewayEnabled`), never started or stopped from here. -fn apply_connections(cfg: &Value) { - let selected = connect_targets(cfg); - let claude_on = selected.iter().any(|t| t == "claude"); - let codex_on = selected.iter().any(|t| t == "codex"); - let port = cfg.get("port").and_then(|v| v.as_u64()).unwrap_or(8788) as u16; - let token = claude::current_token(cfg); - if claude_on { - claude::connect(port, &token); - } else { - claude::disconnect(); - } - if codex_on { - codexconnect::connect(port, &token, &codex_model(cfg)); - } else { - codexconnect::disconnect(); - } -} - -#[tauri::command] -async fn claude_connect( - app: tauri::AppHandle, - gw: tauri::State<'_, std::sync::Arc>, -) -> Result { - let mut cfg = store::read_config(); - let n = cfg.get("providers").and_then(|v| v.as_array()).map(|a| a.len()).unwrap_or(0); - if n == 0 { - return Ok(json!({ "ok": false, "reason": "noProvider" })); - } - // Hero "一键接入" with nothing selected connects Claude Code by default (and persists it, so the - // toggle reflects it). - if ensure_hero_connect_target(&mut cfg) { - cfg = store::write_config(cfg); - } - apply_connections(&cfg); - let status = full_status(&gw).await; - gw.emit("gateway:status", status); - refresh_tray_menu(&app); - Ok(json!({ "ok": true })) -} -#[tauri::command] -async fn claude_disconnect( - app: tauri::AppHandle, - gw: tauri::State<'_, std::sync::Arc>, -) -> Result { - // Master off: restore BOTH CLIs' config files (idempotent). The gateway service keeps its own - // switch — removing the CLI wiring doesn't stop it. - claude::disconnect(); - codexconnect::disconnect(); - let status = full_status(&gw).await; - gw.emit("gateway:status", status); - refresh_tray_menu(&app); - Ok(json!({ "ok": true })) -} - -/// Independent gateway-service switch: persist `gatewayEnabled` and start/stop the localhost -/// server. CLI config files are untouched — connect/disconnect is a separate, config-only action. -#[tauri::command] -async fn gateway_set_enabled( - app: tauri::AppHandle, - gw: tauri::State<'_, std::sync::Arc>, - on: bool, -) -> Result { - let mut cfg = store::read_config(); - cfg["gatewayEnabled"] = json!(on); - let saved = store::write_config(cfg); - if on { - let port = saved.get("port").and_then(|v| v.as_u64()).unwrap_or(8788) as u16; - if let Err(e) = gw.start(port).await { - let msg = format!("port {} failed: {}", port, e); - *LAST_START_ERROR.lock().unwrap() = Some(msg.clone()); - gw.emit("gateway:status", full_status(&gw).await); - refresh_tray_menu(&app); - return Ok(json!({ "ok": false, "reason": "portFailed", "message": msg })); - } - *LAST_START_ERROR.lock().unwrap() = None; - } else { - gw.stop().await; - } - let status = full_status(&gw).await; - gw.emit("gateway:status", status); - refresh_tray_menu(&app); - Ok(json!({ "ok": true })) -} - -/// Live per-CLI switch: flip one target on/off, persist the selection, and immediately write or -/// restore that CLI's config file. Config-only — the gateway service has its own switch. -#[tauri::command] -async fn set_connect_target( - app: tauri::AppHandle, - gw: tauri::State<'_, std::sync::Arc>, - target: String, - on: bool, -) -> Result { - let mut cfg = store::read_config(); - if on && cfg.get("providers").and_then(|v| v.as_array()).map(|a| a.is_empty()).unwrap_or(true) { - return Ok(json!({ "ok": false, "reason": "noProvider" })); - } - let mut targets = connect_targets(&cfg); - targets.retain(|t| t != &target); - if on && (target == "claude" || target == "codex") { - targets.push(target.clone()); - } - cfg["connectTargets"] = json!(targets); - let saved = store::write_config(cfg); - apply_connections(&saved); - let status = full_status(&gw).await; - gw.emit("gateway:status", status); - refresh_tray_menu(&app); - Ok(json!({ "ok": true })) -} -fn pct(s: &str) -> String { - s.bytes() - .map(|b| match b { - b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => (b as char).to_string(), - _ => format!("%{:02X}", b), - }) - .collect() -} -#[tauri::command] -fn desktop_replay(file: String, prompt: Option) -> Value { - if file.is_empty() { - return json!({ "ok": false, "reason": "noFile" }); - } - if !cfg!(target_os = "macos") { - return json!({ "ok": false, "reason": "unsupported" }); - } - // The full review prompt comes from the renderer's i18n (desktop.replayPrompt) so it stays - // localized; fall back to a minimal default only if the renderer didn't supply one. - let prompt = prompt - .filter(|p| !p.is_empty()) - .unwrap_or_else(|| "请基于这些对话记录在 Claude 桌面版里继续。".to_string()); - // Attach the main session AND every subagent transcript (they live in a separate subagents/ dir), - // each as its own `file=` — the Cowork deep link honors repeated `file=` — so the analysis covers - // subagent runs, not just the main thread. - let mut url = format!("claude://cowork/new?q={}&file={}", pct(&prompt), pct(&file)); - for sub in history::subagent_transcript_paths(&file) { - url.push_str("&file="); - url.push_str(&pct(&sub)); - } - #[cfg(target_os = "macos")] - { - let ok = std::process::Command::new("/usr/bin/open").arg(&url).spawn().is_ok(); - json!({ "ok": ok }) - } - #[cfg(not(target_os = "macos"))] - { - let _ = url; - json!({ "ok": false, "reason": "unsupported" }) - } -} -#[tauri::command] -fn chatgpt_replay(file: String, prompt: Option) -> Value { - if file.is_empty() { - return json!({ "ok": false, "reason": "noFile" }); - } - if !cfg!(target_os = "macos") { - return json!({ "ok": false, "reason": "unsupported" }); - } - // The ChatGPT desktop app (Codex era) keeps the codex:// scheme: codex://new takes - // `prompt` (initial composer text) and `path` (workspace dir). It has no file-attach - // param, so the workspace is pointed at the transcripts' directory and the prompt - // lists the absolute JSONL paths — main session plus every subagent (they live under - // `//subagents/`, inside the same workspace) — for the task to read. - let prompt = prompt - .filter(|p| !p.is_empty()) - .unwrap_or_else(|| "请读取下列 Coding CLI 会话的 JSONL 记录并帮我复盘。".to_string()); - let mut text = prompt; - text.push_str("\n\nTranscripts:\n"); - text.push_str(&file); - for sub in history::subagent_transcript_paths(&file) { - text.push('\n'); - text.push_str(&sub); - } - let mut url = format!("codex://new?prompt={}", pct(&text)); - if let Some(dir) = std::path::Path::new(&file).parent() { - url.push_str("&path="); - url.push_str(&pct(&dir.to_string_lossy())); - } - #[cfg(target_os = "macos")] - { - // `open` exits non-zero when nothing handles the scheme → app not installed. - match std::process::Command::new("/usr/bin/open").arg(&url).status() { - Ok(s) if s.success() => json!({ "ok": true }), - Ok(_) => json!({ "ok": false, "reason": "notInstalled" }), - Err(_) => json!({ "ok": false, "reason": "failed" }), - } - } - #[cfg(not(target_os = "macos"))] - { - let _ = url; - json!({ "ok": false, "reason": "unsupported" }) - } -} - -// ---- server / usage / monitor / logs ---- -async fn full_status(gw: &std::sync::Arc) -> Value { - let mut s = gw.status().await; - let port = gw - .current_port() - .await - .unwrap_or_else(|| store::read_config().get("port").and_then(|v| v.as_u64()).unwrap_or(8788) as u16); - if let Some(o) = s.as_object_mut() { - let claude_on = claude::is_connected(port); - let codex_on = codexconnect::is_connected(port); - // `connected` = any CLI wired to the gateway (drives the tray "已接入" indicator). - o.insert("connected".into(), json!(claude_on || codex_on)); - o.insert("connectedClaude".into(), json!(claude_on)); - o.insert("connectedCodex".into(), json!(codex_on)); - o.insert("codexAvailable".into(), json!(codexconnect::is_available())); - o.insert( - "gatewayEnabled".into(), - json!(store::read_config().get("gatewayEnabled").and_then(|v| v.as_bool()).unwrap_or(true)), - ); - o.insert( - "lastStartError".into(), - LAST_START_ERROR - .lock() - .ok() - .and_then(|g| g.clone()) - .map(Value::String) - .unwrap_or(Value::Null), - ); - o.insert("claudePath".into(), json!(claude::settings_path().to_string_lossy())); - } - s -} -#[tauri::command] -async fn server_status( - gw: tauri::State<'_, std::sync::Arc>, -) -> Result { - Ok(full_status(&gw).await) -} -#[tauri::command] -fn usage_get(range: Option) -> Value { - let t = std::time::Instant::now(); - let cfg = store::read_config(); - // Usage surfaces (popover heatmap/stats, hero) always aggregate EVERY configured dir — the - // conversations-page directory switcher must not silently filter the calendar down to one CLI. - let r = usage::usage_get(&cfg, "all", range.as_deref().unwrap_or("7d")); - eprintln!( - "[TIMING] usage_get(range={}) {}ms", - range.as_deref().unwrap_or("7d"), - t.elapsed().as_millis() - ); - r -} - -/// Compact token count (mirror of usage.js `formatTokens`): 1234→"1.2K", 4.9e9→"4.9B". -fn format_tokens(n: i64) -> String { - let n = n.max(0); - if n < 1000 { - return n.to_string(); - } - let strip = |s: String| s.strip_suffix(".0").map(|p| p.to_string()).unwrap_or(s); - if n < 1_000_000 { - let v = n as f64 / 1e3; - let s = if n < 10_000 { format!("{:.1}", v) } else { format!("{:.0}", v) }; - return format!("{}K", strip(s)); - } - if n < 1_000_000_000 { - let v = n as f64 / 1e6; - let s = if n < 10_000_000 { format!("{:.1}", v) } else { format!("{:.0}", v) }; - return format!("{}M", strip(s)); - } - let v = n as f64 / 1e9; - format!("{}B", strip(format!("{:.1}", v))) -} - -#[cfg(test)] -mod fmt_tests { - use super::{ensure_hero_connect_target, format_tokens, startup_reconcile_targets}; - use serde_json::json; - - #[test] - fn startup_reconciliation_requires_the_targets_own_backup() { - assert!(startup_reconcile_targets(&json!({})).is_empty()); - assert!(startup_reconcile_targets(&json!({ - "connectTargets": ["claude"], - "claudeBackup": null - })) - .is_empty()); - assert!(startup_reconcile_targets(&json!({ - "connectTargets": ["codex"], - "codexBackup": "not-a-backup" - })) - .is_empty()); - - assert_eq!( - startup_reconcile_targets(&json!({ - "connectTargets": ["claude", "codex"], - "claudeBackup": { "model": null, "env": {} }, - "codexBackup": null - })), - vec!["claude"] - ); - assert_eq!( - startup_reconcile_targets(&json!({ - "connectTargets": ["claude", "codex"], - "claudeBackup": null, - "codexBackup": { - "model": "gpt-5", - "model_provider": "openai", - "model_reasoning_effort": null - } - })), - vec!["codex"] - ); - } - - #[test] - fn hero_connect_defaults_to_claude_without_overriding_a_selection() { - let mut fresh = json!({ "connectTargets": [] }); - assert!(ensure_hero_connect_target(&mut fresh)); - assert_eq!(fresh["connectTargets"], json!(["claude"])); - - let mut selected = json!({ "connectTargets": ["codex"] }); - assert!(!ensure_hero_connect_target(&mut selected)); - assert_eq!(selected["connectTargets"], json!(["codex"])); - } - - #[test] - fn matches_js_format_tokens() { - assert_eq!(format_tokens(0), "0"); - assert_eq!(format_tokens(999), "999"); - assert_eq!(format_tokens(1000), "1K"); - assert_eq!(format_tokens(1234), "1.2K"); - assert_eq!(format_tokens(9999), "10K"); - assert_eq!(format_tokens(12_345), "12K"); - assert_eq!(format_tokens(1_000_000), "1M"); - assert_eq!(format_tokens(4_900_000), "4.9M"); - assert_eq!(format_tokens(12_000_000), "12M"); - assert_eq!(format_tokens(1_000_000_000), "1B"); - assert_eq!(format_tokens(4_892_112_447), "4.9B"); - } -} - -/// Set the macOS menu-bar tray title to the configured usage token count (or clear it when -/// trayUsage is off). Heavy work (config read + usage scan) runs on the caller's thread; -/// only the set_title call hops to the main thread, where macOS requires UI mutation. -fn update_tray_title(app: &tauri::AppHandle) { - let config = store::read_config(); - let tu = config.get("trayUsage").cloned().unwrap_or_else(|| json!({})); - let enabled = tu.get("enabled").and_then(|v| v.as_bool()).unwrap_or(false); - let title: Option = if enabled { - let range = tu.get("range").and_then(|v| v.as_str()).unwrap_or("7d").to_string(); - // Same global scope as the popover — the tray count is a whole-machine number. - let tokens = usage::usage_get(&config, "all", &range) - .get("tokens") - .and_then(|v| v.as_i64()) - .unwrap_or(0); - Some(format!(" {}", format_tokens(tokens))) - } else { - None - }; - let app2 = app.clone(); - let _ = app.run_on_main_thread(move || { - if let Some(tray) = app2.tray_by_id("main") { - let _ = tray.set_title(title.as_deref()); - } - }); -} - -// ---- system tray: dynamic, localized context menu (parity with main.js buildTrayMenu) ---- -struct TrayLabels { - running_with: &'static str, - stopped: &'static str, - open_main: &'static str, - stop_gw: &'static str, - start_gw: &'static str, - quit: &'static str, - check_updates: &'static str, -} -fn tray_labels(lang: &str) -> TrayLabels { - match lang { - // config.language stores "zh" (store.rs normalize) — accept both spellings. - "zh" | "zh-CN" => TrayLabels { running_with: "● 网关运行中 · {name}", stopped: "○ 网关已停止", open_main: "打开主界面", stop_gw: "停止网关服务", start_gw: "启动网关服务", quit: "退出 CC Buddy", check_updates: "检查更新…" }, - "zh-TW" => TrayLabels { running_with: "● 閘道執行中 · {name}", stopped: "○ 閘道已停止", open_main: "開啟主視窗", stop_gw: "停止閘道服務", start_gw: "啟動閘道服務", quit: "結束 CC Buddy", check_updates: "檢查更新…" }, - "ja" => TrayLabels { running_with: "● ゲートウェイ稼働中 · {name}", stopped: "○ ゲートウェイ停止中", open_main: "メインウィンドウを開く", stop_gw: "ゲートウェイを停止", start_gw: "ゲートウェイを起動", quit: "CC Buddy を終了", check_updates: "更新を確認…" }, - "ko" => TrayLabels { running_with: "● 게이트웨이 실행 중 · {name}", stopped: "○ 게이트웨이 중지됨", open_main: "메인 창 열기", stop_gw: "게이트웨이 중지", start_gw: "게이트웨이 시작", quit: "CC Buddy 종료", check_updates: "업데이트 확인…" }, - _ => TrayLabels { running_with: "● Gateway running · {name}", stopped: "○ Gateway stopped", open_main: "Open main window", stop_gw: "Stop gateway service", start_gw: "Start gateway service", quit: "Quit CC Buddy", check_updates: "Check for updates…" }, - } -} -fn config_lang(config: &Value) -> String { - config.get("language").and_then(|v| v.as_str()).unwrap_or("en").to_string() -} -fn active_provider_name(config: &Value) -> String { - let id = match config.get("activeProviderId").and_then(|v| v.as_str()) { - Some(i) => i, - None => return String::new(), - }; - config - .get("providers") - .and_then(|v| v.as_array()) - .and_then(|arr| { - arr.iter() - .find(|p| p.get("id").and_then(|v| v.as_str()) == Some(id)) - .and_then(|p| p.get("name").and_then(|v| v.as_str())) - }) - .unwrap_or("") - .to_string() -} -fn build_tray_menu( - app: &tauri::AppHandle, - running: bool, - provider: &str, - lang: &str, -) -> tauri::Result> { - use tauri::menu::{Menu, MenuItem, PredefinedMenuItem}; - let l = tray_labels(lang); - let status_txt = if running { - let name = if provider.is_empty() { "CC Buddy" } else { provider }; - l.running_with.replace("{name}", name) - } else { - l.stopped.to_string() - }; - // Status row is disabled (it's an indicator, like main.js { enabled: false }). - let status_i = MenuItem::with_id(app, "tray_status", status_txt, false, None::<&str>)?; - let open_i = MenuItem::with_id(app, "tray_open", l.open_main, true, None::<&str>)?; - let conn_i = if running { - MenuItem::with_id(app, "tray_gw_stop", l.stop_gw, true, None::<&str>)? - } else { - MenuItem::with_id(app, "tray_gw_start", l.start_gw, true, None::<&str>)? - }; - let check_i = MenuItem::with_id(app, "tray_check", l.check_updates, true, None::<&str>)?; - let quit_i = MenuItem::with_id(app, "tray_quit", l.quit, true, None::<&str>)?; - let sep1 = PredefinedMenuItem::separator(app)?; - let sep2 = PredefinedMenuItem::separator(app)?; - Menu::with_items(app, &[&status_i, &sep1, &open_i, &conn_i, &check_i, &sep2, &quit_i]) -} -/// Rebuild the tray menu to reflect the gateway service state + locale + active provider. -fn refresh_tray_menu(app: &tauri::AppHandle) { - let app2 = app.clone(); - let _ = app.run_on_main_thread(move || { - let config = store::read_config(); - let running = app2 - .try_state::>() - .map(|s| s.port_sync().is_some()) - .unwrap_or(false); - let provider = active_provider_name(&config); - let lang = config_lang(&config); - if let Ok(menu) = build_tray_menu(&app2, running, &provider, &lang) { - if let Some(tray) = app2.tray_by_id("main") { - let _ = tray.set_menu(Some(menu)); - } - } - }); -} -#[tauri::command] -async fn monitor_get( - gw: tauri::State<'_, std::sync::Arc>, - id: Value, -) -> Result { - let idn = id.as_i64().or_else(|| id.as_str().and_then(|s| s.parse().ok())).unwrap_or(-1); - Ok(gw.monitor_get(idn).await) -} -#[tauri::command] -async fn monitor_clear( - gw: tauri::State<'_, std::sync::Arc>, -) -> Result { - gw.monitor_clear().await; - Ok(json!(true)) -} -#[tauri::command] -fn logs_get(gw: tauri::State<'_, std::sync::Arc>) -> Value { - gw.logs_snapshot() -} -#[tauri::command] -fn logs_clear(gw: tauri::State<'_, std::sync::Arc>) -> Value { - gw.logs_clear(); - Value::Null -} - -// ---- window / app lifecycle ---- -/// macOS Dock icon follows the main window: Regular (Dock shown) while a window is open, -/// Accessory (menu-bar only) when it's closed. The popover floats over fullscreen apps via its -/// NSPanel regardless of this policy, so showing the Dock icon with the main window is safe. -fn set_dock_visible(app: &tauri::AppHandle, visible: bool) { - #[cfg(target_os = "macos")] - { - let app2 = app.clone(); - let _ = app.run_on_main_thread(move || { - let policy = if visible { - tauri::ActivationPolicy::Regular - } else { - tauri::ActivationPolicy::Accessory - }; - let _ = app2.set_activation_policy(policy); - }); - } -} -#[tauri::command] -fn app_open_main(app: tauri::AppHandle) -> Value { - if let Some(win) = app.get_webview_window("main") { - set_dock_visible(&app, true); - let _ = win.show(); - let _ = win.unminimize(); - let _ = win.set_focus(); - } - Value::Null -} -#[tauri::command] -fn app_quit(app: tauri::AppHandle) -> Value { - app.exit(0); - Value::Null -} -#[tauri::command] fn window_settings_mode(on: bool) -> Value { Value::Null } -#[tauri::command] -fn window_view_min_width(app: tauri::AppHandle, w: i64) -> Value { - if let Some(win) = app.get_webview_window("main") { - let min_w = std::cmp::max(600, if w > 0 { w } else { 900 }) as f64; - let _ = win.set_min_size(Some(tauri::Size::Logical(tauri::LogicalSize::new(min_w, 600.0)))); - } - Value::Null -} - -// ---- conversation history ---- -#[tauri::command] -fn history_projects() -> Value { - let cfg = store::read_config(); - let active = cfg.get("historyActive").and_then(|v| v.as_str()).unwrap_or("all").to_string(); - json!(history::list_projects(&cfg, &active)) -} -#[tauri::command] -fn history_list() -> Value { - let cfg = store::read_config(); - let active = cfg.get("historyActive").and_then(|v| v.as_str()).unwrap_or("all").to_string(); - json!(history::list_sessions(&cfg, &active, 400)) -} -#[tauri::command] -fn history_get(file: String) -> Value { - history::get_session(&file) -} -#[tauri::command] -async fn history_search(query: String) -> Result { - let cfg = store::read_config(); - let active = cfg.get("historyActive").and_then(|v| v.as_str()).unwrap_or("all").to_string(); - // Content scan is read/parse heavy — keep it off the IPC thread so the UI stays responsive. - tauri::async_runtime::spawn_blocking(move || json!(history::search_sessions(&cfg, &active, &query, 120))) - .await - .map_err(|e| e.to_string()) -} -#[tauri::command] -fn history_dirs() -> Value { - let cfg = store::read_config(); - let active = cfg.get("historyActive").and_then(|v| v.as_str()).unwrap_or("all").to_string(); - json!({ "dirs": history::dir_stats(&cfg), "active": active }) -} -#[tauri::command] -async fn history_pick_dir() -> Result { - let folder = rfd::AsyncFileDialog::new().set_title("选择工作目录").pick_folder().await; - match folder { - // Return the picked path (home-collapsed to `~/…`) and let the renderer persist it - // via saveConfig, matching the renderer contract. - Some(f) => { - let mut picked = f.path().to_path_buf(); - // If the user drilled into a data subdir (projects/ = Claude, sessions/ = Codex), - // store its parent (the work dir) so both trees are probed correctly. - let name = picked.file_name().and_then(|n| n.to_str()).map(|s| s.to_string()); - if matches!(name.as_deref(), Some("projects") | Some("sessions")) - && !picked.join(name.as_deref().unwrap()).is_dir() - { - if let Some(parent) = picked.parent() { - picked = parent.to_path_buf(); - } - } - let path = store::collapse_home(&picked.to_string_lossy()); - Ok(json!({ "ok": true, "path": path })) - } - None => Ok(json!({ "ok": false, "canceled": true })), - } -} -#[tauri::command] -fn history_set_active(app: tauri::AppHandle, id: String) -> Value { - let mut cfg = store::read_config(); - cfg["historyActive"] = json!(if id.is_empty() { "all".to_string() } else { id }); - let saved = store::write_config(cfg); - let _ = app.emit( - "history:changed", - json!({ "files": [], "active": saved.get("historyActive").cloned().unwrap_or(json!("all")) }), - ); - saved -} -#[tauri::command] -async fn history_import(app: tauri::AppHandle) -> Result { - match rfd::AsyncFileDialog::new().add_filter("对话记录 (.jsonl / .zip)", &["jsonl", "zip"]).set_title("导入对话记录").pick_files().await { - Some(files) => { - let paths: Vec = files.iter().map(|f| f.path().to_string_lossy().to_string()).collect(); - let r = history::import_paths(&paths); - let _ = app.emit("history:changed", json!({ "files": [] })); - Ok(r) - } - None => Ok(json!({ "canceled": true })), - } -} -#[tauri::command] -fn history_import_paths(app: tauri::AppHandle, paths: Value) -> Value { - let list: Vec = paths - .as_array() - .map(|a| a.iter().filter_map(|p| p.as_str().map(|s| s.to_string())).collect()) - .unwrap_or_default(); - let r = history::import_paths(&list); - let _ = app.emit("history:changed", json!({ "files": [] })); - r -} -#[tauri::command] -fn history_remove_import(app: tauri::AppHandle, file: String) -> Value { - let r = history::remove_import(&file); - if r.get("ok").and_then(|v| v.as_bool()).unwrap_or(false) { - let _ = app.emit("history:changed", json!({ "files": [] })); - } - r -} -#[tauri::command] -fn history_set_meta(app: tauri::AppHandle, file: String, patch: Value) -> Value { - let cfg = store::read_config(); - let r = history::set_ccbud(&file, &patch, &cfg); - if r.get("ok").and_then(|v| v.as_bool()).unwrap_or(false) { - let _ = app.emit("history:changed", json!({ "files": [file] })); - } - r -} -#[tauri::command] -fn history_delete_forever(app: tauri::AppHandle, file: String) -> Value { - let cfg = store::read_config(); - let r = history::delete_session_file(&file, &cfg); - if r.get("ok").and_then(|v| v.as_bool()).unwrap_or(false) { - let _ = app.emit("history:changed", json!({ "files": [] })); - } - r -} -#[tauri::command] -async fn history_export_raw(file: String) -> Result { - let base = exporthtml::export_base_name(&file); - // Antigravity sessions are SQLite DBs (not text) — export the raw bytes as .db so the - // original conversation remains intact. Other foreign sources and Claude/Codex stay - // verbatim text (.jsonl); sessions with subagents keep the existing zip bundle. - let path = std::path::Path::new(&file); - if matches!( - history::foreign_kind(path), - Some(history::Foreign::Antigravity) - ) { - let bytes = std::fs::read(&file).map_err(|e| e.to_string())?; - return match rfd::AsyncFileDialog::new() - .add_filter("SQLite", &["db"]) - .set_file_name(format!("{}.db", base)) - .save_file() - .await - { - Some(d) => { - let p = d.path().to_path_buf(); - std::fs::write(&p, bytes).map_err(|e| e.to_string())?; - Ok(json!({ "canceled": false, "path": p.to_string_lossy(), "bundled": false })) - } - None => Ok(json!({ "canceled": true })), - }; - } - // A session with subagents exports as a .zip bundle (main .jsonl at the top level + subagents/); - // a plain session stays a verbatim .jsonl. import_paths accepts either. - if history::session_has_subagents(&file) { - let bytes = history::export_bundle(&file).map_err(|e| e.to_string())?; - match rfd::AsyncFileDialog::new() - .add_filter("ZIP", &["zip"]) - .set_file_name(format!("{}.zip", base)) - .save_file() - .await - { - Some(d) => { - let p = d.path().to_path_buf(); - std::fs::write(&p, bytes).map_err(|e| e.to_string())?; - Ok(json!({ "canceled": false, "path": p.to_string_lossy(), "bundled": true })) - } - None => Ok(json!({ "canceled": true })), - } - } else { - let data = history::raw_session_bytes(&file).map_err(|e| e.to_string())?; - match rfd::AsyncFileDialog::new() - .add_filter("JSONL", &["jsonl"]) - .set_file_name(format!("{}.jsonl", base)) - .save_file() - .await - { - Some(d) => { - let p = d.path().to_path_buf(); - std::fs::write(&p, data).map_err(|e| e.to_string())?; - Ok(json!({ "canceled": false, "path": p.to_string_lossy(), "bundled": false })) - } - None => Ok(json!({ "canceled": true })), - } - } -} -#[tauri::command] -async fn history_export_html(payload: Value) -> Result { - let file = payload - .get("file") - .and_then(|v| v.as_str()) - .or_else(|| payload.as_str()) - .ok_or("no file")? - .to_string(); - // Build the export data once, then reuse it for both the HTML body and the filename. - let data = exporthtml::build_data(&file); - // An unreadable main transcript surfaces as a command error (renderer reports it) instead of - // silently saving an empty viewer page. - if let Some(error) = data.get("error") { - return Err(error - .get("message") - .and_then(|v| v.as_str()) - .unwrap_or("session read failed") - .to_string()); - } - let html = exporthtml::html_from_data(&data); - let base = exporthtml::export_base_name_from_data(&data); - match rfd::AsyncFileDialog::new().set_file_name(format!("{}.html", base)).save_file().await { - Some(d) => { - let p = d.path().to_path_buf(); - std::fs::write(&p, html).map_err(|e| e.to_string())?; - // Open the freshly-exported viewer in the user's default browser (issue #7). - open_path_native(&p); - Ok(json!({ "canceled": false, "path": p.to_string_lossy() })) - } - None => Ok(json!({ "canceled": true })), - } -} - -// ---- utilities ---- -#[tauri::command] -fn util_copy(text: String) -> bool { - match arboard::Clipboard::new() { - Ok(mut cb) => cb.set_text(text).is_ok(), - Err(_) => false, - } -} -#[tauri::command] -fn util_open_external(url: String) -> bool { - if !(url.starts_with("http://") || url.starts_with("https://")) { - return false; - } - let spawned = { - #[cfg(target_os = "macos")] - { - std::process::Command::new("open").arg(&url).spawn() - } - #[cfg(target_os = "windows")] - { - std::process::Command::new("cmd").args(["/C", "start", "", &url]).spawn() - } - #[cfg(target_os = "linux")] - { - std::process::Command::new("xdg-open").arg(&url).spawn() - } - }; - spawned.is_ok() -} - -// Open a local file with the OS default handler. Used to pop the freshly-exported HTML viewer in -// the user's browser so they don't have to hunt for it in the filesystem. Best-effort: a spawn -// failure must not fail the export. -fn open_path_native(path: &std::path::Path) { - #[cfg(target_os = "macos")] - let _ = std::process::Command::new("open").arg(path).spawn(); - #[cfg(target_os = "windows")] - let _ = std::process::Command::new("cmd").args(["/C", "start", ""]).arg(path).spawn(); - #[cfg(target_os = "linux")] - let _ = std::process::Command::new("xdg-open").arg(path).spawn(); -} - -// ---- in-app updates ---- -// In-app update state, mapped to the shape the renderer's about/update pane expects -// (runningVersion / latestVersion / mode / pending). Tauri's updater is in-app full → mode "hot". -static UPDATE_LATEST: std::sync::Mutex)>> = - std::sync::Mutex::new(None); -static UPDATE_CHECKED: std::sync::Mutex = std::sync::Mutex::new(false); -static UPDATE_STAGED: std::sync::Mutex = std::sync::Mutex::new(false); -// A download is in flight (manual or auto) — second caller gets "busy" instead of a duplicate. -static UPDATE_DOWNLOADING: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); -// Daily auto-check bookkeeping: the day (local YYYY-MM-DD) whose auto check already completed -// (in-memory mirror of the on-disk stamp), an in-flight guard, and the last attempt time so a -// failed attempt (offline) is retried on a later visibility change instead of on every focus. -static AUTO_UPDATE_DONE_DAY: std::sync::Mutex> = std::sync::Mutex::new(None); -static AUTO_UPDATE_RUNNING: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); -static AUTO_UPDATE_LAST_TRY_MS: std::sync::atomic::AtomicI64 = std::sync::atomic::AtomicI64::new(0); -const AUTO_UPDATE_RETRY_MS: i64 = 10 * 60 * 1000; - -/// Clears an in-flight flag on drop, so a panic/unwind or an early return can never leave -/// UPDATE_DOWNLOADING / AUTO_UPDATE_RUNNING stuck true for the rest of the process. -struct FlagGuard(&'static std::sync::atomic::AtomicBool); -impl Drop for FlagGuard { - fn drop(&mut self) { - self.0.store(false, std::sync::atomic::Ordering::SeqCst); - } -} - -fn build_update_state(app: &tauri::AppHandle) -> Value { - let cfg = store::read_config(); - let current = app.package_info().version.to_string(); - let latest = UPDATE_LATEST.lock().ok().and_then(|g| g.clone()); - let checked = UPDATE_CHECKED.lock().map(|g| *g).unwrap_or(false); - let staged = UPDATE_STAGED.lock().map(|g| *g).unwrap_or(false); - let (latest_v, notes, mode) = match (&latest, checked) { - (Some((v, n)), _) => (json!(v), n.clone().map(Value::String).unwrap_or(Value::Null), "hot"), - (None, true) => (Value::Null, Value::Null, "none"), - (None, false) => (Value::Null, Value::Null, "unknown"), - }; - json!({ - "ok": true, - "runningVersion": current, - "shellVersion": current, - "latestVersion": latest_v, - "mode": mode, - "notes": notes, - "pending": if staged { - json!({ "staged": true, "version": latest.as_ref().map(|(v, _)| v.clone()) }) - } else { - Value::Null - }, - "installMethod": "tauri", - "autoUpdate": cfg.get("autoUpdate").cloned().unwrap_or(json!({ "check": true, "autoDownload": true })), - }) -} -#[tauri::command] -fn update_state(app: tauri::AppHandle) -> Value { - build_update_state(&app) -} -/// Hit the updater endpoint and sync UPDATE_CHECKED/UPDATE_LATEST + the renderer's -/// update:state. Shared by the manual update_check command and the daily auto check. -async fn run_update_check(app: &tauri::AppHandle) -> Result, String> { - use tauri_plugin_updater::UpdaterExt; - *UPDATE_CHECKED.lock().unwrap() = true; - let result = match app.updater() { - Ok(updater) => updater.check().await, - Err(e) => Err(e), - }; - match result { - Ok(found) => { - *UPDATE_LATEST.lock().unwrap() = - found.as_ref().map(|u| (u.version.clone(), u.body.clone())); - let _ = app.emit("update:state", build_update_state(app)); - Ok(found) - } - Err(e) => Err(e.to_string()), - } -} -#[tauri::command] -async fn update_check(app: tauri::AppHandle) -> Result { - match run_update_check(&app).await { - Ok(_) => Ok(build_update_state(&app)), - Err(e) => Ok(json!({ - "ok": false, - "error": e, - "runningVersion": app.package_info().version.to_string(), - })), - } -} -/// Download + stage the available update (restart applies it). Shared by the manual -/// update_download command and the daily auto flow; UPDATE_DOWNLOADING dedupes the two. -async fn run_update_download(app: &tauri::AppHandle) -> Result { - use tauri_plugin_updater::UpdaterExt; - if UPDATE_DOWNLOADING.swap(true, std::sync::atomic::Ordering::SeqCst) { - return Err("busy".to_string()); - } - let _busy = FlagGuard(&UPDATE_DOWNLOADING); - let updater = app.updater().map_err(|e| e.to_string())?; - match updater.check().await.map_err(|e| e.to_string())? { - Some(u) => { - u.download_and_install(|_chunk, _total| {}, || {}).await.map_err(|e| e.to_string())?; - *UPDATE_STAGED.lock().unwrap() = true; - let st = build_update_state(app); - let _ = app.emit("update:staged", st.clone()); - let _ = app.emit("update:state", st.clone()); - Ok(st) - } - None => Ok(json!({ "ok": true, "mode": "none" })), - } -} -#[tauri::command] -async fn update_download(app: tauri::AppHandle) -> Result { - run_update_download(&app).await -} -#[tauri::command] -fn update_apply(app: tauri::AppHandle) -> Value { - app.restart(); -} -#[tauri::command] -fn update_set_auto(patch: Value) -> Value { - let mut cfg = store::read_config(); - let mut au = cfg.get("autoUpdate").cloned().unwrap_or(json!({ "check": true, "autoDownload": true })); - if let Some(o) = au.as_object_mut() { - if let Some(c) = patch.get("check") { - o.insert("check".into(), c.clone()); - } - if let Some(d) = patch.get("autoDownload") { - o.insert("autoDownload".into(), d.clone()); - } - } - cfg["autoUpdate"] = au.clone(); - store::write_config(cfg); - au -} - -// ---- daily auto update (first time the app becomes visible each day) ---- -// The stamp lives in its own tiny file (NOT config.json) so the daily writer never races the -// renderer's whole-config round-trips through config_save. -fn auto_update_stamp_file() -> std::path::PathBuf { - store::ccbud_home().join("update-check.json") -} -fn today_local() -> String { - chrono::Local::now().format("%Y-%m-%d").to_string() -} -fn last_auto_update_day() -> String { - std::fs::read_to_string(auto_update_stamp_file()) - .ok() - .and_then(|s| serde_json::from_str::(&s).ok()) - .and_then(|v| v.get("lastAutoCheckDay").and_then(|d| d.as_str()).map(|s| s.to_string())) - .unwrap_or_default() -} -fn mark_auto_update_day(day: &str) { - if let Ok(mut g) = AUTO_UPDATE_DONE_DAY.lock() { - *g = Some(day.to_string()); - } - let _ = std::fs::create_dir_all(store::ccbud_home()); - let _ = std::fs::write( - auto_update_stamp_file(), - serde_json::to_vec(&json!({ "lastAutoCheckDay": day })).unwrap_or_default(), - ); -} - -// Native restart prompt after an auto-downloaded update (localized like tray_labels — the main -// window may be hidden when the popover triggered the check, so this can't live in the renderer). -struct UpdatePromptLabels { - title: &'static str, - body: &'static str, // {v} → new version - restart: &'static str, - later: &'static str, -} -fn update_prompt_labels(lang: &str) -> UpdatePromptLabels { - match lang { - "zh" | "zh-CN" => UpdatePromptLabels { title: "更新已就绪", body: "新版本 {v} 已自动下载完成。是否立即重启以应用新版本?", restart: "立即重启", later: "稍后" }, - "zh-TW" => UpdatePromptLabels { title: "更新已就緒", body: "新版本 {v} 已自動下載完成。要立即重新啟動以套用新版本嗎?", restart: "立即重啟", later: "稍後" }, - "ja" => UpdatePromptLabels { title: "アップデートの準備ができました", body: "新しいバージョン {v} のダウンロードが完了しました。今すぐ再起動して適用しますか?", restart: "今すぐ再起動", later: "後で" }, - "ko" => UpdatePromptLabels { title: "업데이트 준비 완료", body: "새 버전 {v} 다운로드가 완료되었습니다. 지금 다시 시작하여 적용할까요?", restart: "지금 다시 시작", later: "나중에" }, - _ => UpdatePromptLabels { title: "Update ready", body: "Version {v} has been downloaded. Restart now to switch to the new version?", restart: "Restart now", later: "Later" }, - } -} -async fn prompt_restart_to_apply(app: &tauri::AppHandle) { - let version = UPDATE_LATEST - .lock() - .ok() - .and_then(|g| g.as_ref().map(|(v, _)| v.clone())) - .unwrap_or_default(); - let l = update_prompt_labels(&config_lang(&store::read_config())); - // On Linux this shells out to zenity; without it rfd logs an error and returns Cancel, - // degrading to the staged update applying on the next launch (About pane shows "restart"). - let res = rfd::AsyncMessageDialog::new() - .set_level(rfd::MessageLevel::Info) - .set_title(l.title) - .set_description(l.body.replace("{v}", &version)) - .set_buttons(rfd::MessageButtons::OkCancelCustom(l.restart.to_string(), l.later.to_string())) - .show() - .await; - if matches!(&res, rfd::MessageDialogResult::Custom(s) if s == l.restart) { - app.restart(); - } -} - -/// Called from every "app became visible" site (main window focus, popover show, launch). -/// The first such moment each day — with autoUpdate.check on — runs one update check; when an -/// update exists and autoUpdate.autoDownload is on it's downloaded, then the user is asked -/// whether to restart into the new version (declining leaves it staged for the next launch). -/// The day is stamped only after a flow that reached the network succeeds, so an offline -/// launch doesn't burn the day's only attempt — the next visibility (≥10 min later) retries. -fn auto_update_on_visible(app: &tauri::AppHandle) { - let today = today_local(); - if AUTO_UPDATE_DONE_DAY - .lock() - .map(|g| g.as_deref() == Some(today.as_str())) - .unwrap_or(false) - { - return; - } - if last_auto_update_day() == today { - // Stamped by a previous run of this process instance or a crashed one — mirror it. - if let Ok(mut g) = AUTO_UPDATE_DONE_DAY.lock() { - *g = Some(today); - } - return; - } - if now_ms() - AUTO_UPDATE_LAST_TRY_MS.load(std::sync::atomic::Ordering::Relaxed) < AUTO_UPDATE_RETRY_MS { - return; - } - if AUTO_UPDATE_RUNNING.swap(true, std::sync::atomic::Ordering::SeqCst) { - return; - } - let running = FlagGuard(&AUTO_UPDATE_RUNNING); - let au = store::read_config().get("autoUpdate").cloned().unwrap_or_else(|| json!({})); - if !au.get("check").and_then(|v| v.as_bool()).unwrap_or(true) { - return; // `running` drops here and clears the flag - } - AUTO_UPDATE_LAST_TRY_MS.store(now_ms(), std::sync::atomic::Ordering::Relaxed); - let auto_dl = au.get("autoDownload").and_then(|v| v.as_bool()).unwrap_or(true); - let app = app.clone(); - tauri::async_runtime::spawn(async move { - let _running = running; // held until the task ends (cleared even on panic/unwind) - match run_update_check(&app).await { - Ok(None) => mark_auto_update_day(&today), - Ok(Some(_)) => { - let staged = UPDATE_STAGED.lock().map(|g| *g).unwrap_or(false); - if staged { - // Downloaded on an earlier day but never restarted — just re-ask. - mark_auto_update_day(&today); - prompt_restart_to_apply(&app).await; - } else if !auto_dl { - mark_auto_update_day(&today); // surfaced in the About pane only - } else { - match run_update_download(&app).await { - Ok(_) => { - mark_auto_update_day(&today); - if UPDATE_STAGED.lock().map(|g| *g).unwrap_or(false) { - prompt_restart_to_apply(&app).await; - } - } - // A manual download is already in flight — the user took over today's - // update (the About pane drives the rest), so the day is done. - Err(e) if e == "busy" => mark_auto_update_day(&today), - Err(_) => {} // download failed → day left unstamped so a later visibility retries - } - } - } - Err(_) => {} // check failed (offline?) → retry on a later visibility - } - }); -} - -// ---- debug self-check (gated by CCBUD_SELFCHECK env; injected via on_page_load) ---- -#[tauri::command] -fn selfcheck_report(report: Value) { - let line = serde_json::to_string(&report).unwrap_or_default(); - eprintln!("[SELFCHECK] {}", line); - // Also append to a file when CCBUD_SELFCHECK_OUT is set — a GUI-session run - // (open .app via launchd) has no terminal-attached stderr to read. - if let Ok(path) = std::env::var("CCBUD_SELFCHECK_OUT") { - use std::io::Write; - if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(&path) { - let _ = writeln!(f, "{}", line); - } - } -} -#[tauri::command] -fn selfcheck_routing() -> Value { - gateway::routing_selftest() -} -#[tauri::command] -fn selfcheck_history() -> Value { - history::history_selftest(&store::ccbud_home()) -} -#[tauri::command] -fn selfcheck_import() -> Value { - history::import_selftest(&store::ccbud_home()) -} -#[tauri::command] -fn selfcheck_export() -> Value { - let base = store::ccbud_home(); - let _ = history::history_selftest(&base); - let file = base.join("test-claude").join("projects").join("-test-cwd").join("sess1.jsonl"); - let html = exporthtml::build_export_html(&file.to_string_lossy()); - json!({ - "len": html.len(), - "hasConv": html.contains("__CONV__"), - "hasContent": html.contains("hello world from selfcheck"), - "hasSkin": html.contains(""), - "embedded": html.len() > 180000, - "validHtml": html.starts_with(""), - }) -} -#[tauri::command] -fn selfcheck_popover(app: tauri::AppHandle) -> Value { - let pop = match app.get_webview_window("popover") { - Some(p) => p, - None => return json!({ "err": "no popover window" }), - }; - let mon = match pop.current_monitor() { - Ok(Some(m)) => m, - _ => return json!({ "err": "no monitor" }), - }; - let scale = mon.scale_factor(); - let pw = (424.0 * scale) as i32; - let sx = mon.position().x; - let sy = mon.position().y; - let sw = mon.size().width as i32; - let sh = mon.size().height as i32; - // Simulate a tray icon at the top-right of the menu bar, run the same placement - // math as the real tray click, then read back where the window actually lands. - let tray_cx = sx + sw - (12.0 * scale) as i32; - let x = (tray_cx - pw / 2).clamp(sx + 4, sx + sw - pw - 4); - let y = sy + (26.0 * scale) as i32; - // macOS window ops must run on the main thread — the real tray callback already - // does; here we hop onto it explicitly and read back inside the same closure so - // the probe sees the post-move geometry without a cross-thread timing race. - let (tx, rx) = std::sync::mpsc::channel(); - let pop2 = pop.clone(); - let _ = app.run_on_main_thread(move || { - let _ = pop2.show(); - let _ = pop2.set_position(tauri::PhysicalPosition::new(x, y)); - let pos = pop2.outer_position().ok().map(|p| (p.x, p.y)); - let size = pop2.outer_size().ok().map(|s| (s.width as i32, s.height as i32)); - let _ = pop2.hide(); - let _ = tx.send((pos, size)); - }); - let (pos, size) = rx - .recv_timeout(std::time::Duration::from_millis(1500)) - .unwrap_or((None, None)); - let in_screen = match (pos, size) { - (Some((px, py)), Some((sw2, sh2))) => { - px >= sx && py >= sy && (px + sw2) <= (sx + sw + 2) && (py + sh2) <= (sy + sh + 2) - } - _ => false, - }; - json!({ - "scale": scale, - "monitor": [sx, sy, sw, sh], - "computed": [x, y], - "popPos": pos.map(|(a, b)| json!([a, b])), - "popSize": size.map(|(a, b)| json!([a, b])), - "inScreen": in_screen, - }) -} -#[tauri::command] -async fn selfcheck_gateway( - gw: tauri::State<'_, std::sync::Arc>, -) -> Result { - // Mutates config (writes a mock provider) — only ever allowed in a throwaway self-check run. - if std::env::var("CCBUD_SELFCHECK").is_err() { - return Err("selfcheck disabled".into()); - } - let port = gw.current_port().await.unwrap_or(0); - let mut r = gateway::gateway_selftest(port).await; - let sse_ex = gw.monitor_recent().await; // last recorded by gateway_selftest = the SSE exchange - // Exercise HEAD / (mock 404 → gateway fallback 200 → recorded) to verify monitor detail + ms. - let head_status = reqwest::Client::new() - .head(format!("http://127.0.0.1:{}/", port)) - .send() - .await - .map(|x| x.status().as_u16()) - .unwrap_or(0); - tokio::time::sleep(std::time::Duration::from_millis(60)).await; - let head_ex = gw.monitor_recent().await; // now the HEAD exchange - if let Some(o) = r.as_object_mut() { - let req_ok = sse_ex.get("reqBody").and_then(|b| b.get("text")).and_then(|t| t.as_str()).map(|s| !s.is_empty()).unwrap_or(false); - let res_ok = sse_ex.get("resBody").and_then(|b| b.get("text")).and_then(|t| t.as_str()).map(|s| !s.is_empty()).unwrap_or(false); - let redacted = sse_ex.get("reqHeaders").map(|h| h.to_string().contains("已隐藏")).unwrap_or(false); - o.insert("monitorReqBody".into(), json!(req_ok)); - o.insert("monitorResBody".into(), json!(res_ok)); - o.insert("monitorRedacted".into(), json!(redacted)); - o.insert("recordHasMs".into(), json!(sse_ex.get("ms").map(|v| v.is_number()).unwrap_or(false))); - o.insert("headStatus".into(), json!(head_status)); - o.insert( - "headMonitored".into(), - json!(head_ex.get("method").and_then(|m| m.as_str()) == Some("HEAD") - && head_ex.get("reqHeaders").map(|h| h.is_object()).unwrap_or(false) - && head_ex.get("ms").map(|v| v.is_number()).unwrap_or(false)), - ); - } - Ok(r) -} -const SELFCHECK_JS: &str = r#" -(function(){ - if (window.__ccbud_sc) return; window.__ccbud_sc = 1; - window.__ccbud_errors = []; - window.addEventListener('error', function(e){ try{window.__ccbud_errors.push(String((e&&e.message)||(e&&e.error)||e));}catch(_){} }, true); - window.addEventListener('unhandledrejection', function(e){ try{window.__ccbud_errors.push('promise:'+String((e.reason&&e.reason.message)||e.reason));}catch(_){} }); - function rep(o){ try{ window.__TAURI__.core.invoke('selfcheck_report',{report:o}); }catch(_){} } - setTimeout(async function(){ - var o={}; - try{ - o.hasCcbud=!!window.ccbud; - o.hasTauri=!!(window.__TAURI__&&window.__TAURI__.core); - o.bodyLen=(document.body&&document.body.innerHTML.length)||0; - o.navItems=document.querySelectorAll('.nav-item,[data-view],[data-nav]').length; - o.colorMix=!!(window.CSS&&CSS.supports&&CSS.supports('color','color-mix(in srgb,red,blue)')); - o.highlight=!!(window.CSS&&CSS.highlights); - // store round-trip — self-check runs point CCBUD_HOME at a throwaway dir - try{ - var before=await window.ccbud.getConfig(); - o.provBefore=((before&&before.providers)||[]).length; - var saved=await window.ccbud.upsertProvider({name:'SelfTest',baseUrl:'https://x.test',authToken:'tok',defaultModel:'m1',smallFastModel:'m1',extra:'shouldDrop'}); - o.provAfter=((saved&&saved.providers)||[]).length; - o.savedName=saved&&saved.providers&&saved.providers[0]&&saved.providers[0].name; - o.savedHasId=!!(saved&&saved.providers&&saved.providers[0]&&saved.providers[0].id); - o.savedActiveMatches=!!(saved&&saved.activeProviderId&&saved.providers[0]&&saved.activeProviderId===saved.providers[0].id); - o.droppedExtra=!(saved&&saved.providers&&saved.providers[0]&&('extra' in saved.providers[0])); - var reread=await window.ccbud.getConfig(); - o.rereadProv=((reread&&reread.providers)||[]).length; - }catch(e){ o.storeErr=String(e); } - try{ o.routing=await window.__TAURI__.core.invoke('selfcheck_routing'); }catch(e){ o.routingErr=String(e); } - try{ o.server=await window.ccbud.serverStatus(); }catch(e){ o.serverErr=String(e); } - try{ o.gateway=await window.__TAURI__.core.invoke('selfcheck_gateway'); }catch(e){ o.gatewayErr=String(e); } - try{ - o.histDirs=(await window.ccbud.historyDirs()).dirs.length; - var hl=await window.ccbud.historyList(); - o.histCount=(hl||[]).length; - o.histSample=hl&&hl[0]?{title:String(hl[0].title||'').slice(0,40),project:hl[0].project,hasCwd:!!hl[0].cwd,hasFile:!!hl[0].file}:null; - if(hl&&hl[0]){ var ss=await window.ccbud.historyGet(hl[0].file); o.histMsgs=ss&&ss.messages?ss.messages.length:-1; o.histTotals=ss&&ss.meta?ss.meta.totals:null; } - }catch(e){ o.histErr=String(e); } - try{ var ug=await window.ccbud.usageGet('all'); o.usage={tokens:ug.tokens,requests:ug.requests,fav:ug.favoriteModel,heatmap:(ug.heatmap||[]).length,byModel:(ug.byModel||[]).length,activeDays:ug.activeDays}; }catch(e){ o.usageErr=String(e); } - try{ var cc=await window.ccbud.connect(); var s1=await window.ccbud.serverStatus(); var dd=await window.ccbud.disconnect(); var s2=await window.ccbud.serverStatus(); o.claude={connOk:cc&&cc.ok,connected:s1.connected,discOk:dd&&dd.ok,afterDisc:s2.connected}; }catch(e){ o.claudeErr=String(e); } - try{ o.copyOk=await window.ccbud.copy('selfcheck-clip'); }catch(e){ o.copyErr=String(e); } - try{ o.histMeta=await window.__TAURI__.core.invoke('selfcheck_history'); }catch(e){ o.histMetaErr=String(e); } - try{ o.export=await window.__TAURI__.core.invoke('selfcheck_export'); }catch(e){ o.exportErr=String(e); } - try{ o.import=await window.__TAURI__.core.invoke('selfcheck_import'); }catch(e){ o.importErr=String(e); } - try{ var us=await window.ccbud.updateState(); var sa=await window.ccbud.updateSetAuto({check:false}); o.update={current:us.current,status:us.status,setAutoCheck:sa.check}; }catch(e){ o.updateErr=String(e); } - try{ o.drag={regions:document.querySelectorAll('.drag-region').length,wired:document.querySelectorAll('[data-tauri-drag-region]').length}; }catch(e){ o.dragErr=String(e); } - try{ var cs=getComputedStyle(document.body); o.userSelect=cs.webkitUserSelect||cs.userSelect; }catch(e){} - try{ var ep=document.getElementById('endpoint'); var eb=document.getElementById('exportBlock'); o.epSel=ep?getComputedStyle(ep).webkitUserSelect:'-'; o.ebSel=eb?getComputedStyle(eb).webkitUserSelect:'-'; }catch(e){} - try{ o.popoverPos=await window.__TAURI__.core.invoke('selfcheck_popover'); }catch(e){ o.popoverPosErr=String(e); } - o.errors=window.__ccbud_errors.slice(0,20); - }catch(e){o.fatal=String((e&&e.stack)||e);} - rep(o); - },2200); -})(); -"#; - -const POPOVER_SELFCHECK_JS: &str = r#" -(function(){ - setTimeout(async function(){ - var o={win:"popover"}; - try{ o.hasCcbud=!!window.ccbud; var u=await window.ccbud.usageGet("all"); o.usageTokens=u?u.tokens:"null"; o.heatmapLen=u&&u.heatmap?u.heatmap.length:-1; o.heatmapFilled=u&&u.heatmap?u.heatmap.filter(function(c){return c.level>0;}).length:-1; }catch(e){ o.usageErr=String(e); } - try{ var st=document.getElementById("sTokens"); o.sTokensText=st?st.textContent:"noel"; var hm=document.getElementById("heatmap"); o.heatCells=hm?hm.children.length:-1; }catch(e){} - try{ - o.innerW=window.innerWidth; o.innerH=window.innerHeight; o.scrollH=document.body.scrollHeight; - var st2=document.getElementById("sTokens"); if(st2){var r=st2.getBoundingClientRect(); o.sTokTop=Math.round(r.top); o.sTokVisible=(r.top>=0&&r.bottom<=window.innerHeight);} - var hm2=document.getElementById("heatmap"); if(hm2){var hr=hm2.getBoundingClientRect(); o.hmTop=Math.round(hr.top); o.hmBottom=Math.round(hr.bottom);} - o.bodyBg=getComputedStyle(document.body).backgroundColor; - var root=document.querySelector(".pop-body-root"); o.rootBg=root?getComputedStyle(root).backgroundColor:"noel"; - }catch(e){ o.visErr=String(e); } - try{ window.__TAURI__.core.invoke("selfcheck_report",{report:o}); }catch(_){} - }, 1500); -})(); -"#; - -/// Older installs live in "ccbud.app" (pre-1.3.4) or "CCBuddy.app" (1.3.4). The -/// in-app updater swaps the bundle's contents but never the folder itself, and -/// macOS shows CFBundleDisplayName only when the folder name matches CFBundleName -/// ("CC Buddy") — any mismatch makes the Dock and the Applications list fall back -/// to the folder name. Rename the bundle once, relaunch from the new path so -/// Launch Services re-registers it, and exit. Bails out on any obstacle -/// (translocation, read-only volume, name already taken) and keeps running under -/// the old name. -#[cfg(target_os = "macos")] -fn migrate_legacy_bundle_name() { - let exe = match std::env::current_exe() { - Ok(p) => p, - Err(_) => return, - }; - // exe = /.app/Contents/MacOS/ - let bundle = match exe.ancestors().nth(3) { - Some(p) - if matches!( - p.file_name().and_then(|n| n.to_str()), - Some("ccbud.app") | Some("CCBuddy.app") - ) => - { - p.to_path_buf() - } - _ => return, - }; - let target = match bundle.parent() { - Some(dir) => dir.join("CC Buddy.app"), - None => return, - }; - if target.exists() || std::fs::rename(&bundle, &target).is_err() { - return; - } - // `open -n` asks Launch Services to start a fresh instance from the new path - // (which also re-registers the name). Wait for its verdict rather than exiting - // on spawn: a refusal must restore the old name so the running process keeps a - // valid bundle path behind it instead of leaving the user with nothing open. - let launched = std::process::Command::new("/usr/bin/open") - .arg("-n") - .arg(&target) - .status() - .map(|s| s.success()) - .unwrap_or(false); - if launched { - std::process::exit(0); - } - let _ = std::fs::rename(&target, &bundle); -} +// Every #[tauri::command] lives in a topic module under commands/; re-exported here so +// generate_handler! below can keep naming them unqualified, and so the paths other modules +// already use (crate::refresh_tray_menu, crate::update_tray_title, …) keep resolving. +pub(crate) use commands::*; +pub(crate) use tray::{refresh_tray_menu, update_tray_title}; #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { // Must run before the builder: it may rename the bundle and hand off to a // fresh instance at the new path. #[cfg(target_os = "macos")] - migrate_legacy_bundle_name(); + startup::migrate_legacy_bundle_name(); #[allow(unused_mut)] let mut builder = tauri::Builder::default() @@ -1832,411 +89,23 @@ pub fn run() { .build(), )?; } - // One-time migrations: detected installs of the other coding CLIs (Codex, Grok - // Build, Copilot CLI, Antigravity CLI, Qoder) and an XDG Claude tree join - // historyDirs as regular work dirs. Runs BEFORE the history watcher so their trees - // get watched. - store::ensure_codex_dir(); - store::ensure_xdg_claude_dir(); - store::ensure_grok_dir(); - store::ensure_copilot_dir(); - store::ensure_antigravity_dir(); - store::ensure_qoder_dir(); - - // Start the localhost gateway on the configured port (proxy.js parity). + // Gateway + plugin manager are managed immediately so the IPC surface is live the + // moment the webview loads. Every filesystem-heavy boot step (one-time migrations, + // plugin reconcile, CLI connection repair, gateway/plugin start, history watcher + // registration, usage-cache warm) runs off the main thread — see + // startup::spawn_background_boot — so the window paints and responds instantly. let gw = gateway::GatewayState::new(app.handle().clone()); app.manage(gw.clone()); - // Sidecar plugin manager (see plugin.rs) — discovers, launches, and health-gates - // coding-agent plugins, surfacing each installed one as a backend:"plugin" provider. let pm = plugin::PluginManager::new(); - pm.sync_providers(); // reconcile services with installed plugins on boot - let pm_boot = pm.clone(); - app.manage(pm); + app.manage(pm.clone()); let startup_cfg = store::read_config(); - // Repair previously managed targets that remain selected. A compatibility backup is - // required per target because old defaults could persist `["claude"]` without any - // connection action; startup never connects a first-time target or disconnects one. - reconcile_connections_on_startup(&startup_cfg); - // Rewrite the login item to the current exe path — in-place hot updates - // (and the one-time bundle rename above) otherwise leave it pointing at - // a binary that no longer exists. - if startup_cfg.get("openAtLogin").and_then(|v| v.as_bool()).unwrap_or(false) { - use tauri_plugin_autostart::ManagerExt; - let _ = app.autolaunch().enable(); - } - let port = startup_cfg.get("port").and_then(|v| v.as_u64()).unwrap_or(8788) as u16; - let enabled = startup_cfg.get("gatewayEnabled").and_then(|v| v.as_bool()).unwrap_or(true); - // If the active service is plugin-backed, remember its plugin id so we can - // auto-start it on boot — otherwise the active service would be dead until - // the user re-enables the plugin. - let active_plugin_id = startup_cfg - .get("activeProviderId") - .and_then(|v| v.as_str()) - .and_then(|aid| { - startup_cfg - .get("providers") - .and_then(|v| v.as_array()) - .and_then(|arr| arr.iter().find(|p| p.get("id").and_then(|v| v.as_str()) == Some(aid))) - }) - .filter(|p| p.get("backend").and_then(|v| v.as_str()) == Some("plugin")) - .and_then(|p| p.get("pluginId").and_then(|v| v.as_str())) - .map(|s| s.to_string()); - let app_for_tray = app.handle().clone(); - tauri::async_runtime::spawn(async move { - if enabled { - if let Err(e) = gw.start(port).await { - eprintln!("[ccbud] gateway start failed: {}", e); - } - } - if let Some(pid) = active_plugin_id { - if let Err(e) = pm_boot.start(&pid).await { - eprintln!("[ccbud] active plugin '{}' start failed: {}", pid, e); - } - } - refresh_tray_menu(&app_for_tray); - }); + startup::spawn_background_boot(app.handle().clone(), gw, pm, startup_cfg.clone()); - // System tray: icon + dynamic i18n menu (status / open / connect-or-disconnect / - // check-updates / quit, parity with main.js buildTrayMenu) + click-to-open popover. - { - use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}; - let init_cfg = store::read_config(); - let menu = build_tray_menu(app.handle(), false, "", &config_lang(&init_cfg))?; - // Menu-bar icon: monochrome template (like other macOS apps), auto black/white. - let tray_img = tauri::image::Image::from_bytes(include_bytes!("../../build/iconTemplate.png")) - .unwrap_or_else(|_| app.default_window_icon().cloned().unwrap()); - let _ = TrayIconBuilder::with_id("main") - .icon(tray_img) - .icon_as_template(true) - .tooltip("CC Buddy") - .menu(&menu) - .show_menu_on_left_click(false) - .on_menu_event(|app, event| match event.id.as_ref() { - "tray_open" => { - if let Some(w) = app.get_webview_window("main") { - set_dock_visible(app, true); - let _ = w.show(); - let _ = w.unminimize(); - let _ = w.set_focus(); - } - } - // Tray toggles the gateway SERVICE (start/stop), never the CLI configs. - "tray_gw_start" | "tray_gw_stop" => { - let on = event.id.as_ref() == "tray_gw_start"; - let app = app.clone(); - tauri::async_runtime::spawn(async move { - let mut cfg = store::read_config(); - cfg["gatewayEnabled"] = json!(on); - let saved = store::write_config(cfg); - let port = saved.get("port").and_then(|v| v.as_u64()).unwrap_or(8788) as u16; - let gw = app - .try_state::>() - .map(|s| s.inner().clone()); - if let Some(gw) = gw { - if on { - let _ = gw.start(port).await; - } else { - gw.stop().await; - } - let status = full_status(&gw).await; - gw.emit("gateway:status", status); - } - refresh_tray_menu(&app); - }); - } - "tray_check" => { - if let Some(w) = app.get_webview_window("main") { - set_dock_visible(app, true); - let _ = w.show(); - let _ = w.unminimize(); - let _ = w.set_focus(); - } - // Open the About/update pane shortly after the window is up (main.js parity). - let app2 = app.clone(); - tauri::async_runtime::spawn(async move { - tokio::time::sleep(std::time::Duration::from_millis(300)).await; - let _ = app2.emit("update:openPane", json!({})); - }); - } - "tray_quit" => app.exit(0), - _ => {} - }) - .on_tray_icon_event(|tray, event| { - if let TrayIconEvent::Click { - button: MouseButton::Left, - button_state: MouseButtonState::Up, - rect, - .. - } = event - { - let app = tray.app_handle(); - if let Some(pop) = app.get_webview_window("popover") { - #[cfg(target_os = "macos")] - let vis_before = { - use tauri_nspanel::ManagerExt as _; - app.get_webview_panel("popover") - .map(|p| p.is_visible()) - .unwrap_or(false) - }; - #[cfg(not(target_os = "macos"))] - let vis_before = pop.is_visible().unwrap_or(false); - let debounced = now_ms() - - LAST_POPOVER_HIDE_MS - .load(std::sync::atomic::Ordering::Relaxed) - < 250; - let action; - if vis_before { - #[cfg(target_os = "macos")] - { - use tauri_nspanel::ManagerExt as _; - if let Ok(p) = app.get_webview_panel("popover") { - p.order_out(None); - } - } - #[cfg(not(target_os = "macos"))] - let _ = pop.hide(); - LAST_POPOVER_HIDE_MS - .store(now_ms(), std::sync::atomic::Ordering::Relaxed); - action = "hide"; - } else if debounced { - // Debounce: clicking the tray first blurs (hides) the popover; - // without this the same click would re-show it instantly. - action = "debounce_skip"; - } else { - // Center under the tray icon, clamped to the monitor (rect + - // scale are physical px, so retina is handled correctly). - // - // Pick the monitor the TRAY icon sits on. pop.current_monitor() is the - // monitor the (hidden) popover window last sat on, which on a - // multi-display setup is often NOT the screen whose menu bar was - // clicked; using it clamps the popover to the wrong monitor's - // bounds. Find the monitor whose physical bounds contain the tray - // rect (each candidate's own scale converts the rect to px). - let mon = pop - .available_monitors() - .ok() - .and_then(|mons| { - mons.into_iter().find(|m| { - let p = rect - .position - .to_physical::(m.scale_factor()); - let mp = m.position(); - let ms = m.size(); - p.x >= mp.x as f64 - && p.x < mp.x as f64 + ms.width as f64 - && p.y >= mp.y as f64 - && p.y < mp.y as f64 + ms.height as f64 - }) - }) - .or_else(|| pop.current_monitor().ok().flatten()) - .or_else(|| pop.primary_monitor().ok().flatten()); - let geom = mon.map(|mon| { - let scale = mon.scale_factor(); - let pw = (424.0 * scale) as i32; - let sx = mon.position().x; - let sw = mon.size().width as i32; - let tray_pos = rect.position.to_physical::(scale); - let tray_size = rect.size.to_physical::(scale); - let tray_cx = (tray_pos.x + tray_size.width / 2.0) as i32; - let x = (tray_cx - pw / 2).clamp(sx + 4, sx + sw - pw - 4); - let y = (tray_pos.y + tray_size.height + 2.0) as i32; - tauri::PhysicalPosition::new(x, y) - }); - if let Some(p) = geom { - let _ = pop.set_position(p); - } - // Show via the NSPanel: nonactivating, so it appears on the - // CURRENT Space (incl. a fullscreen app's) without activating - // ccbud or switching Spaces. - #[cfg(target_os = "macos")] - { - use tauri_nspanel::ManagerExt as _; - if let Ok(p) = app.get_webview_panel("popover") { - p.show(); - } - } - #[cfg(not(target_os = "macos"))] - { - let _ = pop.show(); - let _ = pop.set_focus(); - } - if let Some(p) = geom { - let _ = pop.set_position(p); - } - let _ = app.emit("popover:show", ()); - LAST_POPOVER_SHOW_MS - .store(now_ms(), std::sync::atomic::Ordering::Relaxed); - // The popover appearing counts as "app became visible today". - auto_update_on_visible(app); - action = "show"; - } - if let Ok(path) = std::env::var("CCBUD_SELFCHECK_OUT") { - use std::io::Write; - if let Ok(mut f) = std::fs::OpenOptions::new() - .create(true) - .append(true) - .open(&path) - { - let _ = writeln!( - f, - "{}", - json!({ "trayClick": action, "visBefore": vis_before }) - ); - } - } - } - } - }) - .build(app)?; - } + trayicon::install_tray(app, &startup_cfg)?; - // Popover behavior: (1) float on the current Space AND over fullscreen apps; - // (2) auto-hide when it loses focus — clicking anywhere else closes it. - if let Some(pop) = app.get_webview_window("popover") { - // macOS: convert the popover into a non-activating NSPanel. Unlike a plain window, - // a nonactivating panel can float on the CURRENT Space — including another app's - // fullscreen Space — and shows without activating ccbud or switching Spaces. - #[cfg(target_os = "macos")] - { - use tauri_nspanel::cocoa::appkit::NSWindowCollectionBehavior as CB; - use tauri_nspanel::WebviewWindowExt as _; - if let Ok(panel) = pop.to_panel() { - panel.set_style_mask((1 << 7) as i32); // NSWindowStyleMaskNonactivatingPanel - panel.set_collection_behaviour( - CB::NSWindowCollectionBehaviorCanJoinAllSpaces - | CB::NSWindowCollectionBehaviorFullScreenAuxiliary - | CB::NSWindowCollectionBehaviorStationary, - ); - panel.set_floating_panel(true); - panel.set_level(24); // ~NSMainMenuWindowLevel: above fullscreen content - panel.set_hides_on_deactivate(false); - panel.set_released_when_closed(false); - } - } - let pop2 = pop.clone(); - pop.on_window_event(move |event| { - // Bind + deref: `Focused(false)` as a literal pattern does NOT match against - // &WindowEvent here (match ergonomics), so the handler would never fire. - if let tauri::WindowEvent::Focused(focused) = event { - if !*focused { - // Grace period: a fullscreen app steals focus the instant the popover - // shows; ignore that blur so it isn't hidden before being seen. A real - // click-away blur arrives well after the show. - if now_ms() - - LAST_POPOVER_SHOW_MS.load(std::sync::atomic::Ordering::Relaxed) - >= 400 - { - let _ = pop2.hide(); - LAST_POPOVER_HIDE_MS - .store(now_ms(), std::sync::atomic::Ordering::Relaxed); - } - } - } - }); - } + popover::setup_popover(app); - // Daily auto update, triggered by the app becoming visible (see auto_update_on_visible). - // Main-window focus covers launch, tray "open main", Dock/taskbar switches and the - // single-instance re-open; the popover-show branch of the tray click covers tray-only days. - if let Some(main) = app.get_webview_window("main") { - let h = app.handle().clone(); - main.on_window_event(move |event| { - if let tauri::WindowEvent::Focused(focused) = event { - if *focused { - auto_update_on_visible(&h); - } - } - }); - } - // Launch counts as today's first visibility even if no focus event fires (e.g. an - // autostarted login launch that opens unfocused). Delayed a few seconds so the - // network/gateway are up before the first check. - { - let h = app.handle().clone(); - tauri::async_runtime::spawn(async move { - tokio::time::sleep(std::time::Duration::from_secs(3)).await; - let visible = h - .get_webview_window("main") - .and_then(|w| w.is_visible().ok()) - .unwrap_or(false); - if visible { - auto_update_on_visible(&h); - } - }); - } - - // Tray usage title: show the configured token count next to the menu-bar icon - // (macOS), refreshed on a timer so it tracks new usage without any user action. - { - let h = app.handle().clone(); - std::thread::spawn(move || { - std::thread::sleep(std::time::Duration::from_millis(1500)); - loop { - update_tray_title(&h); - std::thread::sleep(std::time::Duration::from_secs(60)); - } - }); - } - - // History live-watch: fs events on the projects dirs → history:changed. - { - use notify_debouncer_mini::{new_debouncer, notify::RecursiveMode, DebounceEventResult}; - let app_w = app.handle().clone(); - if let Ok(mut deb) = new_debouncer( - std::time::Duration::from_millis(250), - move |res: DebounceEventResult| { - if let Ok(events) = res { - let files: Vec = events.iter().map(|e| e.path.to_string_lossy().to_string()).collect(); - let _ = app_w.emit("history:changed", json!({ "files": files })); - // History changed → drop the stale usage cache and re-warm off-thread - // (+ refresh the tray title) so the next popover open stays instant. - usage::invalidate_cache(); - let h = app_w.clone(); - std::thread::spawn(move || { - let cfg = store::read_config(); - usage::warm_cache(&cfg, "all"); - if let Some(g) = h.try_state::>() { - g.log("info", usage::diag(&cfg, "all")); - } - update_tray_title(&h); - }); - } - }, - ) { - for root in history::watch_roots(&store::read_config()) { - if root.is_dir() { - let _ = deb.watcher().watch(&root, RecursiveMode::Recursive); - } - } - std::mem::forget(deb); // keep watching for the app's lifetime - } - } - - // Warm the usage cache at startup (off the click path) so the FIRST popover open is - // instant instead of paying the ~0.5s cold-scan cost. - { - let cfg = store::read_config(); - let h = app.handle().clone(); - std::thread::spawn(move || { - usage::warm_cache(&cfg, "all"); - // Surface the scan shape in the settings Logs panel — the first place to look - // when the usage numbers look wrong. - if let Some(g) = h.try_state::>() { - g.log("info", usage::diag(&cfg, "all")); - } - }); - } - - // Reflect persisted Claude Code connection state in the tray menu on launch (the menu - // is built optimistically as "disconnected"; this corrects it if settings.json already - // points at us). - { - let h = app.handle().clone(); - tauri::async_runtime::spawn(async move { - tokio::time::sleep(std::time::Duration::from_millis(300)).await; - refresh_tray_menu(&h); - }); - } + popover::setup_window_hooks(app); Ok(()) }) diff --git a/src-tauri/src/plugin.rs b/src-tauri/src/plugin.rs deleted file mode 100644 index 05005b6..0000000 --- a/src-tauri/src/plugin.rs +++ /dev/null @@ -1,959 +0,0 @@ -// Sidecar plugin manager. -// -// A ccbud plugin is a standalone local program that reuses some coding agent's -// subscription login (e.g. Grok) and exposes a standard inference endpoint on -// localhost. The host does not do protocol/vendor work for it — see -// docs/plugin-system.md. This module owns the piece the gateway can't: process -// lifecycle, port assignment, and health gating. -// -// Key design choice: a running plugin is surfaced as an ordinary provider whose -// baseUrl points at the plugin's localhost port. Enabling a plugin upserts a -// `backend:"plugin"` provider (id = `plugin:`); disabling only stops the process -// (the service stays, removed on uninstall). The -// gateway then routes to it with zero plugin-specific code. - -use std::collections::HashMap; -use std::path::PathBuf; -use std::process::{Child, Command, Stdio}; -use std::sync::{Arc, Mutex}; -use std::time::{Duration, Instant}; - -use serde_json::{json, Value}; - -use crate::store; - -/// A plugin's parsed manifest (plugin.json). Only the fields the host needs. -pub struct Manifest { - pub dir: PathBuf, - pub id: String, - pub name: String, - pub version: String, - pub description: String, - /// Optional icon file relative to the plugin dir, e.g. "icon.svg". - pub icon: String, - /// endpoint.protocol → provider wire protocol. - pub protocol: String, - /// endpoint.basePath, e.g. "/v1". - pub base_path: String, - /// endpoint.healthPath, e.g. "/healthz". - pub health_path: String, - /// endpoint.readyTimeoutMs. - pub ready_timeout_ms: u64, - /// runtime.exec: { "-": "bin/..." }. - exec: Value, - /// runtime.args, with {port}/{home} placeholders. - args: Vec, - /// (alias, upstream) model pairs. - pub models: Vec<(String, String)>, - pub primary: String, - pub light: String, - /// Control-plane auth status path (read-only; the plugin reuses a CLI login). - pub auth_status_path: String, - /// source.git — upstream git repo used for install/update (optional). - pub source_git: String, - pub source_branch: String, - /// source.build — shell command run in the clone to produce the binary. - pub source_build: String, - /// ui.actions — plugin-declared buttons/forms. Raw objects: the renderer draws - /// them (label/kind/fields/url), the host reads submitPath/loadPath to forward - /// a click to the plugin's control plane. See docs/plugin-system.md. - pub actions: Vec, -} - -impl Manifest { - fn load(dir: PathBuf) -> Option { - let raw = std::fs::read(dir.join("plugin.json")).ok()?; - let v: Value = serde_json::from_slice(&raw).ok()?; - let id = v.get("id")?.as_str()?.to_string(); - - let s = |path: &[&str], default: &str| -> String { - let mut cur = &v; - for k in path { - match cur.get(*k) { - Some(next) => cur = next, - None => return default.to_string(), - } - } - cur.as_str().unwrap_or(default).to_string() - }; - - let args = v - .get("runtime") - .and_then(|r| r.get("args")) - .and_then(|a| a.as_array()) - .map(|a| a.iter().filter_map(|x| x.as_str().map(String::from)).collect()) - .unwrap_or_else(|| vec!["serve".into(), "--port".into(), "{port}".into(), "--home".into(), "{home}".into()]); - - let mut models = vec![]; - if let Some(arr) = v.get("models").and_then(|m| m.as_array()) { - for m in arr { - let alias = m.get("alias").and_then(|x| x.as_str()).unwrap_or("").to_string(); - let upstream = m - .get("upstream") - .and_then(|x| x.as_str()) - .unwrap_or(alias.as_str()) - .to_string(); - if !alias.is_empty() { - models.push((alias, upstream)); - } - } - } - - let ready_timeout_ms = v - .get("endpoint") - .and_then(|e| e.get("readyTimeoutMs")) - .and_then(|x| x.as_u64()) - .unwrap_or(8000); - - // ui.actions: keep only well-formed objects that carry an id. - let actions = v - .get("ui") - .and_then(|u| u.get("actions")) - .and_then(|a| a.as_array()) - .map(|a| { - a.iter() - .filter(|x| x.get("id").and_then(|i| i.as_str()).map(|s| !s.is_empty()).unwrap_or(false)) - .cloned() - .collect() - }) - .unwrap_or_default(); - - Some(Manifest { - dir, - id, - name: s(&["name"], "Plugin"), - version: s(&["version"], "0.0.0"), - description: s(&["description"], ""), - icon: s(&["icon"], ""), - protocol: s(&["endpoint", "protocol"], "openai-responses"), - base_path: s(&["endpoint", "basePath"], "/v1"), - health_path: s(&["endpoint", "healthPath"], "/healthz"), - ready_timeout_ms, - exec: v.get("runtime").and_then(|r| r.get("exec")).cloned().unwrap_or(Value::Null), - args, - models, - primary: s(&["modelMapping", "primary"], ""), - light: s(&["modelMapping", "light"], ""), - auth_status_path: s(&["auth", "statusPath"], "/v1/plugin/auth"), - source_git: s(&["source", "git"], ""), - source_branch: { - let b = s(&["source", "branch"], ""); - if b.trim().is_empty() { "main".to_string() } else { b } - }, - source_build: s(&["source", "build"], ""), - actions, - }) - } - - /// Find a declared action by id. - fn action(&self, action_id: &str) -> Option<&Value> { - self.actions - .iter() - .find(|a| a.get("id").and_then(|x| x.as_str()) == Some(action_id)) - } - - /// Resolve (submitPath, loadPath) for an action, applying defaults. - /// submitPath defaults to `/v1/plugin/action/`; loadPath defaults to submitPath. - fn action_paths(&self, action_id: &str) -> Option<(String, String)> { - let a = self.action(action_id)?; - let default_submit = format!("/v1/plugin/action/{}", action_id); - let submit = a - .get("submitPath") - .or_else(|| a.get("path")) - .and_then(|x| x.as_str()) - .unwrap_or(default_submit.as_str()) - .to_string(); - let load = a - .get("loadPath") - .and_then(|x| x.as_str()) - .unwrap_or(submit.as_str()) - .to_string(); - Some((submit, load)) - } - - /// Actions as sent to the renderer: host-internal wiring (submitPath/loadPath/ - /// path) stripped, display fields (label/kind/url/fields/…) kept. - fn public_actions(&self) -> Vec { - self.actions - .iter() - .map(|a| { - let mut o = a.clone(); - if let Some(m) = o.as_object_mut() { - m.remove("submitPath"); - m.remove("loadPath"); - m.remove("path"); - } - o - }) - .collect() - } - - /// Absolute path to the executable for the current platform, if declared. - fn exec_path(&self) -> Option { - let rel = self.exec.get(platform_key()).and_then(|x| x.as_str())?; - Some(self.dir.join(rel)) - } - - fn resolved_args(&self, port: u16, home: &str) -> Vec { - self.args - .iter() - .map(|a| a.replace("{port}", &port.to_string()).replace("{home}", home)) - .collect() - } - - fn base_url(&self, port: u16) -> String { - format!("http://127.0.0.1:{}{}", port, self.base_path) - } - - /// The plugin's icon as a data URI (data:image/...;base64,...), if declared - /// and readable — lets a plugin ship its own logo for the UI. - fn icon_data_uri(&self) -> Option { - let rel = self.icon.trim(); - if rel.is_empty() { - return None; - } - let path = self.dir.join(rel); - let bytes = std::fs::read(&path).ok()?; - if bytes.is_empty() || bytes.len() > 512 * 1024 { - return None; - } - let ext = path.extension().and_then(|e| e.to_str()).map(|e| e.to_ascii_lowercase()); - let mime = match ext.as_deref() { - Some("svg") => "image/svg+xml", - Some("png") => "image/png", - Some("jpg") | Some("jpeg") => "image/jpeg", - Some("webp") => "image/webp", - Some("gif") => "image/gif", - _ => return None, - }; - Some(format!("data:{};base64,{}", mime, base64_encode(&bytes))) - } -} - -struct RunningPlugin { - child: Child, - port: u16, -} - -/// Owns running plugin processes and their derived providers. -pub struct PluginManager { - running: Mutex>, - client: reqwest::Client, -} - -impl PluginManager { - pub fn new() -> Arc { - Arc::new(PluginManager { - running: Mutex::new(HashMap::new()), - client: reqwest::Client::new(), - }) - } - - fn plugins_dir(&self) -> PathBuf { - plugins_root() - } - - fn plugin_dir(&self, id: &str) -> PathBuf { - self.plugins_dir().join(id) - } - - fn manifest(&self, id: &str) -> Option { - Manifest::load(self.plugin_dir(id)) - } - - fn discover(&self) -> Vec { - let mut out = vec![]; - if let Ok(rd) = std::fs::read_dir(self.plugins_dir()) { - for e in rd.flatten() { - if e.path().is_dir() { - if let Some(m) = Manifest::load(e.path()) { - out.push(m); - } - } - } - } - out - } - - fn running_port(&self, id: &str) -> Option { - self.running.lock().unwrap().get(id).map(|rp| rp.port) - } - - /// True if the plugin process is alive; reaps and forgets an exited one. - pub fn is_running(&self, id: &str) -> bool { - let mut g = self.running.lock().unwrap(); - if let Some(rp) = g.get_mut(id) { - match rp.child.try_wait() { - Ok(Some(_)) => { - g.remove(id); - false - } - _ => true, - } - } else { - false - } - } - - /// Port for a plugin: the live one if running, else a remembered one from - /// runtime.json, else a freshly assigned free port (persisted). - fn port_for(&self, id: &str) -> u16 { - if let Some(p) = self.running_port(id) { - return p; - } - let rt = self.plugin_dir(id).join("runtime.json"); - if let Ok(raw) = std::fs::read(&rt) { - if let Ok(v) = serde_json::from_slice::(&raw) { - if let Some(p) = v.get("port").and_then(|x| x.as_u64()) { - if p > 0 { - return p as u16; - } - } - } - } - let p = free_port().unwrap_or(8899); - let _ = std::fs::create_dir_all(self.plugin_dir(id)); - let _ = std::fs::write(&rt, serde_json::to_vec(&json!({ "port": p })).unwrap_or_default()); - p - } - - /// A port we can actually bind for this plugin: the remembered one if it's free, - /// else a freshly assigned free port (persisted to runtime.json). Avoids colliding - /// with a stale sidecar squatting on the old port. - fn bindable_port(&self, id: &str) -> u16 { - let port = self.port_for(id); - if port_is_free(port) { - return port; - } - let fresh = free_port().unwrap_or(port); - let _ = std::fs::create_dir_all(self.plugin_dir(id)); - let rt = self.plugin_dir(id).join("runtime.json"); - let _ = std::fs::write(&rt, serde_json::to_vec(&json!({ "port": fresh })).unwrap_or_default()); - fresh - } - - /// Enable a plugin: spawn it, health-gate, then upsert its provider. - pub async fn start(&self, id: &str) -> Result<(), String> { - let man = self.manifest(id).ok_or_else(|| format!("plugin '{}' not found", id))?; - - if self.is_running(id) { - self.ensure_provider(&man, self.running_port(id).unwrap_or_else(|| self.port_for(id))); - return Ok(()); - } - - let exec = man - .exec_path() - .ok_or_else(|| format!("no binary for this platform ({})", platform_key()))?; - if !exec.exists() { - return Err(format!("plugin binary missing: {}", exec.display())); - } - - // Use the remembered port, but if it's already taken (e.g. a stale sidecar from a - // previous run still holding it), grab a fresh free port instead — otherwise our - // child can't bind and we'd falsely health-gate against the squatter. - let port = self.bindable_port(id); - let dir = self.plugin_dir(id); - let _ = std::fs::create_dir_all(&dir); - let home = dir.to_string_lossy().to_string(); - let args = man.resolved_args(port, &home); - - // stderr → plugin.log for diagnosis; stdout is the plugin's ready channel - // (we already know the port, so we discard it). - let stderr = std::fs::OpenOptions::new() - .create(true) - .append(true) - .open(dir.join("plugin.log")) - .map(Stdio::from) - .unwrap_or_else(|_| Stdio::null()); - - let child = Command::new(&exec) - .args(&args) - .current_dir(&man.dir) - .stdout(Stdio::null()) - .stderr(stderr) - .spawn() - .map_err(|e| format!("spawn {}: {}", exec.display(), e))?; - - self.running.lock().unwrap().insert(id.to_string(), RunningPlugin { child, port }); - - if !self.wait_ready(port, &man.health_path, man.ready_timeout_ms).await { - let _ = self.stop(id); - return Err("plugin did not become ready (see plugin.log)".into()); - } - // Guard against a false positive: if our child died during startup (e.g. it still - // failed to bind) even though something answered /healthz, don't register a dead - // provider — surface the failure so the UI doesn't flash "enabled" then revert. - if !self.is_running(id) { - return Err("plugin exited during startup (see plugin.log)".into()); - } - - self.ensure_provider(&man, port); - Ok(()) - } - - /// Disable a plugin: kill the process and remove its provider. - pub fn stop(&self, id: &str) -> Result<(), String> { - if let Some(mut rp) = self.running.lock().unwrap().remove(id) { - let _ = rp.child.kill(); - let _ = rp.child.wait(); - } - // Keep the provider (the service mirrors install state, not running state), - // but if this stopped plugin was the active provider, switch away — it can no - // longer serve requests. Pick the first other provider, else clear. - let pid = provider_id(id); - let mut cfg = store::read_config(); - if cfg.get("activeProviderId").and_then(|v| v.as_str()) == Some(pid.as_str()) { - let next = cfg - .get("providers") - .and_then(|v| v.as_array()) - .and_then(|arr| { - arr.iter() - .find(|p| p.get("id").and_then(|v| v.as_str()) != Some(pid.as_str())) - .and_then(|p| p.get("id").and_then(|v| v.as_str())) - .map(|s| s.to_string()) - }); - cfg["activeProviderId"] = next.map(Value::String).unwrap_or(Value::Null); - store::write_config(cfg); - } - Ok(()) - } - - async fn wait_ready(&self, port: u16, health_path: &str, timeout_ms: u64) -> bool { - let url = format!("http://127.0.0.1:{}{}", port, health_path); - let deadline = Instant::now() + Duration::from_millis(timeout_ms); - // Ramp the poll interval: a local sidecar usually starts in well under a - // second, so probe aggressively at first (catch "ready" the instant it - // happens) and back off toward 150ms to keep the tail cheap. Connection- - // refused before the server binds returns immediately, so early probes - // don't stall. - let mut delay = Duration::from_millis(20); - loop { - if let Ok(r) = self.client.get(&url).timeout(Duration::from_millis(1500)).send().await { - if r.status().is_success() { - return true; - } - } - if Instant::now() >= deadline { - return false; - } - tokio::time::sleep(delay).await; - delay = (delay * 2).min(Duration::from_millis(150)); - } - } - - /// Upsert the `backend:"plugin"` provider that fronts this plugin. The provider is - /// fully derived from the manifest each time: primary/light from modelMapping and - /// NO custom aliases — the plugin advertises the rest via its own /v1/models, so an - /// identity-alias list would just be noise. - fn ensure_provider(&self, man: &Manifest, port: u16) { - let pid = provider_id(&man.id); - let provider = json!({ - "id": pid, - "name": man.name, - "backend": "plugin", - "pluginId": man.id, - "baseUrl": man.base_url(port), - "authToken": "", - "protocol": man.protocol, - "defaultModel": man.primary, - "smallFastModel": man.light, - "mapDefaultModels": true, - "models": [], - "icon": man.icon_data_uri(), - }); - - let mut cfg = store::read_config(); - let arr = match cfg.get_mut("providers").and_then(|v| v.as_array_mut()) { - Some(a) => a, - None => { - cfg["providers"] = json!([]); - cfg["providers"].as_array_mut().unwrap() - } - }; - if let Some(i) = arr - .iter() - .position(|x| x.get("id").and_then(|v| v.as_str()) == Some(pid.as_str())) - { - arr[i] = provider; // keep position, refresh contents - } else { - arr.push(provider); - } - store::write_config(cfg); - } - - /// Reconcile the provider list with the set of installed plugins: add a provider - /// for every installed plugin (created even while stopped, so the service is - /// visible but — see provider_set_active — not switchable until running) and - /// prune plugin providers whose plugin is no longer installed. - pub fn sync_providers(&self) { - let discovered = self.discover(); - for man in &discovered { - let port = self.running_port(&man.id).unwrap_or_else(|| self.port_for(&man.id)); - self.ensure_provider(man, port); - } - let ids: std::collections::HashSet = discovered.iter().map(|m| m.id.clone()).collect(); - let mut cfg = store::read_config(); - if let Some(arr) = cfg.get_mut("providers").and_then(|v| v.as_array_mut()) { - arr.retain(|p| { - if p.get("backend").and_then(|v| v.as_str()) == Some("plugin") { - p.get("pluginId") - .and_then(|v| v.as_str()) - .map(|pid| ids.contains(pid)) - .unwrap_or(false) - } else { - true - } - }); - } - store::write_config(cfg); // normalize fixes activeProviderId if it was pruned - } - - /// Full snapshot for the UI: install info + running + auth (queried live). - pub async fn status(&self, id: &str) -> Value { - let man = self.manifest(id); - let running = self.is_running(id); - let mut auth = Value::Null; - if running { - if let (Some(m), Some(port)) = (man.as_ref(), self.running_port(id)) { - let url = format!("http://127.0.0.1:{}{}", port, m.auth_status_path); - if let Ok(r) = self.client.get(&url).timeout(Duration::from_secs(3)).send().await { - if let Ok(v) = r.json::().await { - auth = v; - } - } - } - } - json!({ - "id": id, - "name": man.as_ref().map(|m| m.name.clone()).unwrap_or_default(), - "version": man.as_ref().map(|m| m.version.clone()).unwrap_or_default(), - "description": man.as_ref().map(|m| m.description.clone()).unwrap_or_default(), - "protocol": man.as_ref().map(|m| m.protocol.clone()).unwrap_or_default(), - "icon": man.as_ref().and_then(|m| m.icon_data_uri()), - "hasSource": man.as_ref().map(|m| !m.source_git.trim().is_empty()).unwrap_or(false), - "official": man.as_ref().map(|m| is_official_source(&m.source_git)).unwrap_or(false), - "providerId": provider_id(id), - "running": running, - "auth": auth, - "actions": man.as_ref().map(|m| m.public_actions()).unwrap_or_default(), - }) - } - - /// List all discovered plugins with their status. - pub async fn list(&self) -> Value { - let mut out = vec![]; - for m in self.discover() { - out.push(self.status(&m.id).await); - } - json!(out) - } - - /// Run a declarative UI action: POST the form `values` to the action's control - /// plane endpoint and return the plugin's JSON response ({ ok, message }). A - /// non-2xx status surfaces the plugin's `message` as an error to the UI. - pub async fn action(&self, id: &str, action_id: &str, values: Value) -> Result { - let man = self.manifest(id).ok_or("plugin not found")?; - let (submit, _) = man.action_paths(action_id).ok_or("action not found")?; - let port = self.running_port(id).ok_or("plugin not running")?; - let url = format!("http://127.0.0.1:{}{}", port, submit); - let r = self - .client - .post(&url) - .json(&values) - .timeout(Duration::from_secs(30)) - .send() - .await - .map_err(|e| e.to_string())?; - let ok = r.status().is_success(); - let body = r.json::().await.unwrap_or_else(|_| json!({})); - if !ok { - let msg = body - .get("message") - .and_then(|x| x.as_str()) - .unwrap_or("plugin returned an error"); - return Err(msg.to_string()); - } - Ok(body) - } - - /// Fetch current values to prefill a declarative form (GET the action's - /// loadPath). Returns the plugin's JSON, typically `{ values: { ... } }`. - pub async fn action_load(&self, id: &str, action_id: &str) -> Result { - let man = self.manifest(id).ok_or("plugin not found")?; - let (_, load) = man.action_paths(action_id).ok_or("action not found")?; - let port = self.running_port(id).ok_or("plugin not running")?; - let url = format!("http://127.0.0.1:{}{}", port, load); - let r = self - .client - .get(&url) - .timeout(Duration::from_secs(10)) - .send() - .await - .map_err(|e| e.to_string())?; - r.json::().await.map_err(|e| e.to_string()) - } - - /// Install a plugin from a local directory (must contain plugin.json) into - /// ~/.ccbud/plugins/. Reinstalling replaces the existing copy. Returns - /// the installed plugin id. - pub fn install(&self, src: &std::path::Path) -> Result { - let src_dir = if src.is_file() { - src.parent().map(|p| p.to_path_buf()).ok_or("无效的路径")? - } else { - src.to_path_buf() - }; - let man = Manifest::load(src_dir.clone()).ok_or("所选目录没有有效的 plugin.json")?; - let dst = self.plugin_dir(&man.id); - if self.is_running(&man.id) { - return Err("请先停用同名插件,再重新安装".into()); - } - // Picking the already-installed dir itself is a no-op, not a self-copy. - let same = src_dir.canonicalize().ok() == dst.canonicalize().ok(); - if same && dst.exists() { - return Ok(man.id); - } - if dst.exists() { - std::fs::remove_dir_all(&dst).map_err(|e| e.to_string())?; - } - copy_dir_all(&src_dir, &dst).map_err(|e| format!("拷贝失败: {}", e))?; - self.sync_providers(); // installing a plugin auto-adds its service - Ok(man.id) - } - - /// Uninstall a plugin: stop it, delete its directory, and drop its service. - pub fn uninstall(&self, id: &str) -> Result<(), String> { - let _ = self.stop(id); - let dir = self.plugin_dir(id); - if dir.exists() { - std::fs::remove_dir_all(&dir).map_err(|e| e.to_string())?; - } - self.sync_providers(); // removing a plugin auto-removes its service - Ok(()) - } - - /// Install (or update) a plugin from a git repository: shallow-clone, run the - /// manifest's build command, verify the binary exists, then install. Returns - /// the plugin id. - /// - /// SECURITY: this clones and *builds* code from a user-supplied URL — i.e. it - /// runs arbitrary code. The UI warns the user to import only trusted sources. - pub fn install_from_git(&self, url: &str) -> Result { - let url = url.trim(); - if url.is_empty() { - return Err("git 地址为空".into()); - } - let _ = std::fs::create_dir_all(plugins_root()); - let tmp = plugins_root().join(format!(".import-{}", unique_suffix())); - let _ = std::fs::remove_dir_all(&tmp); - - let out = Command::new("git") - .args(["clone", "--depth", "1", url]) - .arg(&tmp) - .output() - .map_err(|e| format!("git 不可用: {}", e))?; - if !out.status.success() { - let _ = std::fs::remove_dir_all(&tmp); - return Err(format!("git clone 失败: {}", String::from_utf8_lossy(&out.stderr).trim())); - } - - let man = match Manifest::load(tmp.clone()) { - Some(m) => m, - None => { - let _ = std::fs::remove_dir_all(&tmp); - return Err("仓库根目录没有有效的 plugin.json".into()); - } - }; - - // The shallow clone above fetched the repo's default branch. If the manifest - // pins a different source branch, switch to it so update() installs the code - // that check_update() compared against (both use source.branch). - let mut man = man; - let branch = man.source_branch.trim().to_string(); - if !branch.is_empty() && branch != "main" { - let fetched = Command::new("git") - .arg("-C").arg(&tmp) - .args(["fetch", "--depth", "1", "origin", &branch]) - .output() - .map(|o| o.status.success()) - .unwrap_or(false); - if fetched { - let _ = Command::new("git").arg("-C").arg(&tmp).args(["checkout", "FETCH_HEAD"]).output(); - if let Some(m) = Manifest::load(tmp.clone()) { - man = m; // re-read from the pinned branch - } - } - } - - if !man.source_build.trim().is_empty() { - let built = Command::new("sh") - .arg("-c") - .arg(man.source_build.trim()) - .current_dir(&tmp) - .env("PATH", build_env_path()) - .output(); - match built { - Ok(o) if o.status.success() => {} - Ok(o) => { - let _ = std::fs::remove_dir_all(&tmp); - return Err(format!( - "构建失败 (`{}`): {}", - man.source_build.trim(), - String::from_utf8_lossy(&o.stderr).trim() - )); - } - Err(e) => { - let _ = std::fs::remove_dir_all(&tmp); - return Err(format!("执行构建命令失败: {}", e)); - } - } - } - - match man.exec_path() { - Some(p) if p.exists() => {} - _ => { - let _ = std::fs::remove_dir_all(&tmp); - return Err(format!( - "构建后未找到当前平台二进制 ({});请检查 plugin.json 的 runtime.exec / source.build", - platform_key() - )); - } - } - - let _ = self.stop(&man.id); - let dst = self.plugin_dir(&man.id); - if dst.exists() { - if let Err(e) = std::fs::remove_dir_all(&dst) { - let _ = std::fs::remove_dir_all(&tmp); - return Err(format!("移除旧版本失败: {}", e)); - } - } - if let Err(e) = copy_dir_all(&tmp, &dst) { - let _ = std::fs::remove_dir_all(&tmp); - return Err(format!("安装失败: {}", e)); - } - let _ = std::fs::remove_dir_all(&tmp); - self.sync_providers(); // installing/updating from git auto-adds its service - Ok(man.id) - } - - /// Check the plugin's git source for a newer version by fetching the remote - /// plugin.json (GitHub raw) and comparing versions. - pub async fn check_update(&self, id: &str) -> Value { - let man = match self.manifest(id) { - Some(m) => m, - None => return json!({ "hasSource": false }), - }; - if man.source_git.trim().is_empty() { - return json!({ "hasSource": false, "current": man.version }); - } - let raw = match github_raw(&man.source_git, &man.source_branch, "plugin.json") { - Some(u) => u, - None => { - return json!({ "hasSource": true, "current": man.version, "error": "仅支持 github.com 来源的更新检查" }) - } - }; - let latest = match self.client.get(&raw).timeout(Duration::from_secs(10)).send().await { - Ok(r) if r.status().is_success() => match r.json::().await { - Ok(v) => v.get("version").and_then(|x| x.as_str()).unwrap_or("").to_string(), - Err(_) => String::new(), - }, - _ => String::new(), - }; - if latest.is_empty() { - return json!({ "hasSource": true, "current": man.version, "error": "无法获取远端版本" }); - } - json!({ - "hasSource": true, - "current": man.version, - "latest": latest, - "updateAvailable": version_gt(&latest, &man.version), - }) - } - - /// Update a plugin by re-installing from its recorded git source. - pub fn update(&self, id: &str) -> Result { - let man = self.manifest(id).ok_or_else(|| format!("插件 '{}' 未找到", id))?; - if man.source_git.trim().is_empty() { - return Err("该插件没有 git 来源,无法更新".into()); - } - let url = man.source_git.clone(); - self.install_from_git(&url) - } -} - -fn provider_id(plugin_id: &str) -> String { - format!("plugin:{}", plugin_id) -} - -/// `-` matching plugin.json's runtime.exec keys. -fn platform_key() -> &'static str { - match (std::env::consts::OS, std::env::consts::ARCH) { - ("macos", "aarch64") => "darwin-arm64", - ("macos", "x86_64") => "darwin-amd64", - ("linux", "x86_64") => "linux-amd64", - ("linux", "aarch64") => "linux-arm64", - ("windows", "x86_64") => "windows-amd64", - _ => "unknown", - } -} - -fn free_port() -> Option { - std::net::TcpListener::bind("127.0.0.1:0") - .ok() - .and_then(|l| l.local_addr().ok()) - .map(|a| a.port()) -} - -/// True if we can bind 127.0.0.1:port right now (i.e. nothing else is holding it). -fn port_is_free(port: u16) -> bool { - port != 0 && std::net::TcpListener::bind(("127.0.0.1", port)).is_ok() -} - -/// ccbud config home (~/.ccbud, overridable via CCBUD_HOME) — mirrors store.rs. -fn ccbud_home() -> PathBuf { - if let Ok(v) = std::env::var("CCBUD_HOME") { - if !v.trim().is_empty() { - return PathBuf::from(v); - } - } - let home = std::env::var("HOME") - .or_else(|_| std::env::var("USERPROFILE")) - .unwrap_or_else(|_| ".".into()); - PathBuf::from(home).join(".ccbud") -} - -/// ~/.ccbud/plugins — where plugins are installed. -pub fn plugins_root() -> PathBuf { - ccbud_home().join("plugins") -} - -/// Minimal standard base64 — used only to embed a small plugin icon as a data URI. -fn base64_encode(input: &[u8]) -> String { - const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; - let mut out = String::with_capacity((input.len() + 2) / 3 * 4); - for chunk in input.chunks(3) { - let b0 = chunk[0]; - let b1 = *chunk.get(1).unwrap_or(&0); - let b2 = *chunk.get(2).unwrap_or(&0); - out.push(T[(b0 >> 2) as usize] as char); - out.push(T[(((b0 & 0x03) << 4) | (b1 >> 4)) as usize] as char); - out.push(if chunk.len() > 1 { T[(((b1 & 0x0f) << 2) | (b2 >> 6)) as usize] as char } else { '=' }); - out.push(if chunk.len() > 2 { T[(b2 & 0x3f) as usize] as char } else { '=' }); - } - out -} - -/// Convert a github.com repo URL + branch into a raw file URL. -fn github_raw(git: &str, branch: &str, path: &str) -> Option { - let g = git.trim().trim_end_matches('/').trim_end_matches(".git"); - let rest = g - .strip_prefix("https://github.com/") - .or_else(|| g.strip_prefix("http://github.com/")) - .or_else(|| g.strip_prefix("git@github.com:"))?; - let br = if branch.trim().is_empty() { "main" } else { branch.trim() }; - Some(format!("https://raw.githubusercontent.com/{}/{}/{}", rest, br, path)) -} - -/// True if a git URL points at the official `ccbud` org on github. -fn is_official_source(git: &str) -> bool { - let g = git.trim().trim_end_matches('/').trim_end_matches(".git"); - g.strip_prefix("https://github.com/") - .or_else(|| g.strip_prefix("http://github.com/")) - .or_else(|| g.strip_prefix("git@github.com:")) - .and_then(|rest| rest.split('/').next()) - .map(|owner| owner.eq_ignore_ascii_case("ccbud")) - .unwrap_or(false) -} - -/// True if semver-ish `a` is strictly newer than `b` (e.g. "0.2.0" > "0.1.9"). -fn version_gt(a: &str, b: &str) -> bool { - parse_ver(a) > parse_ver(b) -} -fn parse_ver(v: &str) -> Vec { - v.trim() - .trim_start_matches('v') - .split(|c| c == '.' || c == '-' || c == '+') - .map(|s| s.parse::().unwrap_or(0)) - .collect() -} -/// PATH for `source.build` commands. A GUI app launched from Finder/the Dock -/// inherits launchd's minimal PATH (no Homebrew, no /usr/local/go/bin, …), so -/// builds die with e.g. "go: command not found" even though the toolchain works -/// fine in the user's terminal. Merge the inherited PATH with the login shell's -/// PATH plus well-known toolchain dirs. -fn build_env_path() -> std::ffi::OsString { - let mut dirs: Vec = std::env::var_os("PATH") - .map(|p| std::env::split_paths(&p).collect()) - .unwrap_or_default(); - if let Some(p) = login_shell_path() { - for d in std::env::split_paths(&p) { - if !dirs.contains(&d) { - dirs.push(d); - } - } - } - // Cover toolchains even when the login shell probe fails (or exports them - // only for interactive shells): Homebrew, the official Go installer, and - // per-user go/cargo/pip bin dirs. - let mut extras: Vec = - ["/usr/local/bin", "/opt/homebrew/bin", "/usr/local/go/bin"].iter().map(PathBuf::from).collect(); - if let Some(home) = std::env::var_os("HOME") { - let home = PathBuf::from(home); - extras.extend(["go/bin", ".cargo/bin", ".local/bin"].iter().map(|d| home.join(d))); - } - for d in extras { - if d.is_dir() && !dirs.contains(&d) { - dirs.push(d); - } - } - std::env::join_paths(dirs).unwrap_or_else(|_| std::env::var_os("PATH").unwrap_or_default()) -} - -/// Ask the user's login shell for its PATH (profiles are where Homebrew, Go, -/// cargo, … register themselves). Best-effort: any failure returns None. -fn login_shell_path() -> Option { - let shell = std::env::var("SHELL").ok().filter(|s| !s.trim().is_empty())?; - let out = Command::new(shell) - .args(["-l", "-c", "env"]) - .stdin(Stdio::null()) - .output() - .ok()?; - if !out.status.success() { - return None; - } - // Profile scripts may print noise before `env` runs; take the last PATH= line. - String::from_utf8_lossy(&out.stdout) - .lines() - .rev() - .find_map(|l| l.strip_prefix("PATH=").map(|v| v.to_string())) - .filter(|p| !p.trim().is_empty()) -} - -/// A process-unique-ish suffix for temporary import directories. -fn unique_suffix() -> String { - let nanos = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_nanos()) - .unwrap_or(0); - format!("{}-{}", std::process::id(), nanos) -} - -/// Recursively copy a directory tree (files + subdirs; symlinks skipped). -fn copy_dir_all(src: &std::path::Path, dst: &std::path::Path) -> std::io::Result<()> { - std::fs::create_dir_all(dst)?; - for entry in std::fs::read_dir(src)? { - let entry = entry?; - if entry.file_name() == ".git" { - continue; // never copy VCS metadata into an install dir - } - let ty = entry.file_type()?; - let from = entry.path(); - let to = dst.join(entry.file_name()); - if ty.is_dir() { - copy_dir_all(&from, &to)?; - } else if ty.is_file() { - std::fs::copy(&from, &to)?; - } - } - Ok(()) -} diff --git a/src-tauri/src/plugin/git.rs b/src-tauri/src/plugin/git.rs new file mode 100644 index 0000000..3b29a87 --- /dev/null +++ b/src-tauri/src/plugin/git.rs @@ -0,0 +1,163 @@ +// Git-sourced plugins: clone + build + install, remote version check, and update. Moved +// verbatim from plugin.rs. + +use serde_json::{json, Value}; +use std::process::Command; +use std::time::Duration; + +use super::manager::PluginManager; +use super::manifest::Manifest; +use super::util::{ + build_env_path, copy_dir_all, github_raw, platform_key, plugins_root, unique_suffix, version_gt, +}; + +impl PluginManager { + /// Install (or update) a plugin from a git repository: shallow-clone, run the + /// manifest's build command, verify the binary exists, then install. Returns + /// the plugin id. + /// + /// SECURITY: this clones and *builds* code from a user-supplied URL — i.e. it + /// runs arbitrary code. The UI warns the user to import only trusted sources. + pub fn install_from_git(&self, url: &str) -> Result { + let url = url.trim(); + if url.is_empty() { + return Err("git 地址为空".into()); + } + let _ = std::fs::create_dir_all(plugins_root()); + let tmp = plugins_root().join(format!(".import-{}", unique_suffix())); + let _ = std::fs::remove_dir_all(&tmp); + + let out = Command::new("git") + .args(["clone", "--depth", "1", url]) + .arg(&tmp) + .output() + .map_err(|e| format!("git 不可用: {}", e))?; + if !out.status.success() { + let _ = std::fs::remove_dir_all(&tmp); + return Err(format!("git clone 失败: {}", String::from_utf8_lossy(&out.stderr).trim())); + } + + let man = match Manifest::load(tmp.clone()) { + Some(m) => m, + None => { + let _ = std::fs::remove_dir_all(&tmp); + return Err("仓库根目录没有有效的 plugin.json".into()); + } + }; + + // The shallow clone above fetched the repo's default branch. If the manifest + // pins a different source branch, switch to it so update() installs the code + // that check_update() compared against (both use source.branch). + let mut man = man; + let branch = man.source_branch.trim().to_string(); + if !branch.is_empty() && branch != "main" { + let fetched = Command::new("git") + .arg("-C").arg(&tmp) + .args(["fetch", "--depth", "1", "origin", &branch]) + .output() + .map(|o| o.status.success()) + .unwrap_or(false); + if fetched { + let _ = Command::new("git").arg("-C").arg(&tmp).args(["checkout", "FETCH_HEAD"]).output(); + if let Some(m) = Manifest::load(tmp.clone()) { + man = m; // re-read from the pinned branch + } + } + } + + if !man.source_build.trim().is_empty() { + let built = Command::new("sh") + .arg("-c") + .arg(man.source_build.trim()) + .current_dir(&tmp) + .env("PATH", build_env_path()) + .output(); + match built { + Ok(o) if o.status.success() => {} + Ok(o) => { + let _ = std::fs::remove_dir_all(&tmp); + return Err(format!( + "构建失败 (`{}`): {}", + man.source_build.trim(), + String::from_utf8_lossy(&o.stderr).trim() + )); + } + Err(e) => { + let _ = std::fs::remove_dir_all(&tmp); + return Err(format!("执行构建命令失败: {}", e)); + } + } + } + + match man.exec_path() { + Some(p) if p.exists() => {} + _ => { + let _ = std::fs::remove_dir_all(&tmp); + return Err(format!( + "构建后未找到当前平台二进制 ({});请检查 plugin.json 的 runtime.exec / source.build", + platform_key() + )); + } + } + + let _ = self.stop(&man.id); + let dst = self.plugin_dir(&man.id); + if dst.exists() { + if let Err(e) = std::fs::remove_dir_all(&dst) { + let _ = std::fs::remove_dir_all(&tmp); + return Err(format!("移除旧版本失败: {}", e)); + } + } + if let Err(e) = copy_dir_all(&tmp, &dst) { + let _ = std::fs::remove_dir_all(&tmp); + return Err(format!("安装失败: {}", e)); + } + let _ = std::fs::remove_dir_all(&tmp); + self.sync_providers(); // installing/updating from git auto-adds its service + Ok(man.id) + } + + /// Check the plugin's git source for a newer version by fetching the remote + /// plugin.json (GitHub raw) and comparing versions. + pub async fn check_update(&self, id: &str) -> Value { + let man = match self.manifest(id) { + Some(m) => m, + None => return json!({ "hasSource": false }), + }; + if man.source_git.trim().is_empty() { + return json!({ "hasSource": false, "current": man.version }); + } + let raw = match github_raw(&man.source_git, &man.source_branch, "plugin.json") { + Some(u) => u, + None => { + return json!({ "hasSource": true, "current": man.version, "error": "仅支持 github.com 来源的更新检查" }) + } + }; + let latest = match self.client.get(&raw).timeout(Duration::from_secs(10)).send().await { + Ok(r) if r.status().is_success() => match r.json::().await { + Ok(v) => v.get("version").and_then(|x| x.as_str()).unwrap_or("").to_string(), + Err(_) => String::new(), + }, + _ => String::new(), + }; + if latest.is_empty() { + return json!({ "hasSource": true, "current": man.version, "error": "无法获取远端版本" }); + } + json!({ + "hasSource": true, + "current": man.version, + "latest": latest, + "updateAvailable": version_gt(&latest, &man.version), + }) + } + + /// Update a plugin by re-installing from its recorded git source. + pub fn update(&self, id: &str) -> Result { + let man = self.manifest(id).ok_or_else(|| format!("插件 '{}' 未找到", id))?; + if man.source_git.trim().is_empty() { + return Err("该插件没有 git 来源,无法更新".into()); + } + let url = man.source_git.clone(); + self.install_from_git(&url) + } +} diff --git a/src-tauri/src/plugin/install.rs b/src-tauri/src/plugin/install.rs new file mode 100644 index 0000000..d5a3bfe --- /dev/null +++ b/src-tauri/src/plugin/install.rs @@ -0,0 +1,45 @@ +// Local install / uninstall of a plugin directory. Moved verbatim from plugin.rs. + +use super::manager::PluginManager; +use super::manifest::Manifest; +use super::util::copy_dir_all; + +impl PluginManager { + /// Install a plugin from a local directory (must contain plugin.json) into + /// ~/.ccbud/plugins/. Reinstalling replaces the existing copy. Returns + /// the installed plugin id. + pub fn install(&self, src: &std::path::Path) -> Result { + let src_dir = if src.is_file() { + src.parent().map(|p| p.to_path_buf()).ok_or("无效的路径")? + } else { + src.to_path_buf() + }; + let man = Manifest::load(src_dir.clone()).ok_or("所选目录没有有效的 plugin.json")?; + let dst = self.plugin_dir(&man.id); + if self.is_running(&man.id) { + return Err("请先停用同名插件,再重新安装".into()); + } + // Picking the already-installed dir itself is a no-op, not a self-copy. + let same = src_dir.canonicalize().ok() == dst.canonicalize().ok(); + if same && dst.exists() { + return Ok(man.id); + } + if dst.exists() { + std::fs::remove_dir_all(&dst).map_err(|e| e.to_string())?; + } + copy_dir_all(&src_dir, &dst).map_err(|e| format!("拷贝失败: {}", e))?; + self.sync_providers(); // installing a plugin auto-adds its service + Ok(man.id) + } + + /// Uninstall a plugin: stop it, delete its directory, and drop its service. + pub fn uninstall(&self, id: &str) -> Result<(), String> { + let _ = self.stop(id); + let dir = self.plugin_dir(id); + if dir.exists() { + std::fs::remove_dir_all(&dir).map_err(|e| e.to_string())?; + } + self.sync_providers(); // removing a plugin auto-removes its service + Ok(()) + } +} diff --git a/src-tauri/src/plugin/lifecycle.rs b/src-tauri/src/plugin/lifecycle.rs new file mode 100644 index 0000000..f9801fa --- /dev/null +++ b/src-tauri/src/plugin/lifecycle.rs @@ -0,0 +1,122 @@ +// Plugin process lifecycle: spawn + health gate (start), kill + deregister (stop). Moved +// verbatim from plugin.rs. + +use serde_json::Value; +use std::process::{Command, Stdio}; +use std::time::{Duration, Instant}; + +use crate::store; + +use super::manager::{PluginManager, RunningPlugin}; +use super::util::{platform_key, provider_id}; + +impl PluginManager { + /// Enable a plugin: spawn it, health-gate, then upsert its provider. + pub async fn start(&self, id: &str) -> Result<(), String> { + let man = self.manifest(id).ok_or_else(|| format!("plugin '{}' not found", id))?; + + if self.is_running(id) { + self.ensure_provider(&man, self.running_port(id).unwrap_or_else(|| self.port_for(id))); + return Ok(()); + } + + let exec = man + .exec_path() + .ok_or_else(|| format!("no binary for this platform ({})", platform_key()))?; + if !exec.exists() { + return Err(format!("plugin binary missing: {}", exec.display())); + } + + // Use the remembered port, but if it's already taken (e.g. a stale sidecar from a + // previous run still holding it), grab a fresh free port instead — otherwise our + // child can't bind and we'd falsely health-gate against the squatter. + let port = self.bindable_port(id); + let dir = self.plugin_dir(id); + let _ = std::fs::create_dir_all(&dir); + let home = dir.to_string_lossy().to_string(); + let args = man.resolved_args(port, &home); + + // stderr → plugin.log for diagnosis; stdout is the plugin's ready channel + // (we already know the port, so we discard it). + let stderr = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(dir.join("plugin.log")) + .map(Stdio::from) + .unwrap_or_else(|_| Stdio::null()); + + let child = Command::new(&exec) + .args(&args) + .current_dir(&man.dir) + .stdout(Stdio::null()) + .stderr(stderr) + .spawn() + .map_err(|e| format!("spawn {}: {}", exec.display(), e))?; + + self.running.lock().unwrap().insert(id.to_string(), RunningPlugin { child, port }); + + if !self.wait_ready(port, &man.health_path, man.ready_timeout_ms).await { + let _ = self.stop(id); + return Err("plugin did not become ready (see plugin.log)".into()); + } + // Guard against a false positive: if our child died during startup (e.g. it still + // failed to bind) even though something answered /healthz, don't register a dead + // provider — surface the failure so the UI doesn't flash "enabled" then revert. + if !self.is_running(id) { + return Err("plugin exited during startup (see plugin.log)".into()); + } + + self.ensure_provider(&man, port); + Ok(()) + } + + /// Disable a plugin: kill the process and remove its provider. + pub fn stop(&self, id: &str) -> Result<(), String> { + if let Some(mut rp) = self.running.lock().unwrap().remove(id) { + let _ = rp.child.kill(); + let _ = rp.child.wait(); + } + // Keep the provider (the service mirrors install state, not running state), + // but if this stopped plugin was the active provider, switch away — it can no + // longer serve requests. Pick the first other provider, else clear. + let pid = provider_id(id); + let mut cfg = store::read_config(); + if cfg.get("activeProviderId").and_then(|v| v.as_str()) == Some(pid.as_str()) { + let next = cfg + .get("providers") + .and_then(|v| v.as_array()) + .and_then(|arr| { + arr.iter() + .find(|p| p.get("id").and_then(|v| v.as_str()) != Some(pid.as_str())) + .and_then(|p| p.get("id").and_then(|v| v.as_str())) + .map(|s| s.to_string()) + }); + cfg["activeProviderId"] = next.map(Value::String).unwrap_or(Value::Null); + store::write_config(cfg); + } + Ok(()) + } + + pub(super) async fn wait_ready(&self, port: u16, health_path: &str, timeout_ms: u64) -> bool { + let url = format!("http://127.0.0.1:{}{}", port, health_path); + let deadline = Instant::now() + Duration::from_millis(timeout_ms); + // Ramp the poll interval: a local sidecar usually starts in well under a + // second, so probe aggressively at first (catch "ready" the instant it + // happens) and back off toward 150ms to keep the tail cheap. Connection- + // refused before the server binds returns immediately, so early probes + // don't stall. + let mut delay = Duration::from_millis(20); + loop { + if let Ok(r) = self.client.get(&url).timeout(Duration::from_millis(1500)).send().await { + if r.status().is_success() { + return true; + } + } + if Instant::now() >= deadline { + return false; + } + tokio::time::sleep(delay).await; + delay = (delay * 2).min(Duration::from_millis(150)); + } + } +} diff --git a/src-tauri/src/plugin/manager.rs b/src-tauri/src/plugin/manager.rs new file mode 100644 index 0000000..15b92b2 --- /dev/null +++ b/src-tauri/src/plugin/manager.rs @@ -0,0 +1,114 @@ +// The manager type itself: running processes, plugin discovery and port assignment. Moved +// verbatim from plugin.rs. + +use serde_json::{json, Value}; +use std::collections::HashMap; +use std::path::PathBuf; +use std::process::Child; +use std::sync::{Arc, Mutex}; + +use super::manifest::Manifest; +use super::util::{free_port, plugins_root, port_is_free}; + +pub(super) struct RunningPlugin { + pub(super) child: Child, + pub(super) port: u16, +} + +/// Owns running plugin processes and their derived providers. +pub struct PluginManager { + pub(super) running: Mutex>, + pub(super) client: reqwest::Client, +} + +impl PluginManager { + pub fn new() -> Arc { + Arc::new(PluginManager { + running: Mutex::new(HashMap::new()), + client: reqwest::Client::new(), + }) + } + + pub(super) fn plugins_dir(&self) -> PathBuf { + plugins_root() + } + + pub(super) fn plugin_dir(&self, id: &str) -> PathBuf { + self.plugins_dir().join(id) + } + + pub(super) fn manifest(&self, id: &str) -> Option { + Manifest::load(self.plugin_dir(id)) + } + + pub(super) fn discover(&self) -> Vec { + let mut out = vec![]; + if let Ok(rd) = std::fs::read_dir(self.plugins_dir()) { + for e in rd.flatten() { + if e.path().is_dir() { + if let Some(m) = Manifest::load(e.path()) { + out.push(m); + } + } + } + } + out + } + + pub(super) fn running_port(&self, id: &str) -> Option { + self.running.lock().unwrap().get(id).map(|rp| rp.port) + } + + /// True if the plugin process is alive; reaps and forgets an exited one. + pub fn is_running(&self, id: &str) -> bool { + let mut g = self.running.lock().unwrap(); + if let Some(rp) = g.get_mut(id) { + match rp.child.try_wait() { + Ok(Some(_)) => { + g.remove(id); + false + } + _ => true, + } + } else { + false + } + } + + /// Port for a plugin: the live one if running, else a remembered one from + /// runtime.json, else a freshly assigned free port (persisted). + pub(super) fn port_for(&self, id: &str) -> u16 { + if let Some(p) = self.running_port(id) { + return p; + } + let rt = self.plugin_dir(id).join("runtime.json"); + if let Ok(raw) = std::fs::read(&rt) { + if let Ok(v) = serde_json::from_slice::(&raw) { + if let Some(p) = v.get("port").and_then(|x| x.as_u64()) { + if p > 0 { + return p as u16; + } + } + } + } + let p = free_port().unwrap_or(8899); + let _ = std::fs::create_dir_all(self.plugin_dir(id)); + let _ = std::fs::write(&rt, serde_json::to_vec(&json!({ "port": p })).unwrap_or_default()); + p + } + + /// A port we can actually bind for this plugin: the remembered one if it's free, + /// else a freshly assigned free port (persisted to runtime.json). Avoids colliding + /// with a stale sidecar squatting on the old port. + pub(super) fn bindable_port(&self, id: &str) -> u16 { + let port = self.port_for(id); + if port_is_free(port) { + return port; + } + let fresh = free_port().unwrap_or(port); + let _ = std::fs::create_dir_all(self.plugin_dir(id)); + let rt = self.plugin_dir(id).join("runtime.json"); + let _ = std::fs::write(&rt, serde_json::to_vec(&json!({ "port": fresh })).unwrap_or_default()); + fresh + } +} diff --git a/src-tauri/src/plugin/manifest.rs b/src-tauri/src/plugin/manifest.rs new file mode 100644 index 0000000..90eafd5 --- /dev/null +++ b/src-tauri/src/plugin/manifest.rs @@ -0,0 +1,127 @@ +// The parsed plugin.json manifest and its loader. Moved verbatim from plugin.rs. + +use serde_json::Value; +use std::path::PathBuf; + +/// A plugin's parsed manifest (plugin.json). Only the fields the host needs. +pub struct Manifest { + pub dir: PathBuf, + pub id: String, + pub name: String, + pub version: String, + pub description: String, + /// Optional icon file relative to the plugin dir, e.g. "icon.svg". + pub icon: String, + /// endpoint.protocol → provider wire protocol. + pub protocol: String, + /// endpoint.basePath, e.g. "/v1". + pub base_path: String, + /// endpoint.healthPath, e.g. "/healthz". + pub health_path: String, + /// endpoint.readyTimeoutMs. + pub ready_timeout_ms: u64, + /// runtime.exec: { "-": "bin/..." }. + pub(super) exec: Value, + /// runtime.args, with {port}/{home} placeholders. + pub(super) args: Vec, + /// (alias, upstream) model pairs. + pub models: Vec<(String, String)>, + pub primary: String, + pub light: String, + /// Control-plane auth status path (read-only; the plugin reuses a CLI login). + pub auth_status_path: String, + /// source.git — upstream git repo used for install/update (optional). + pub source_git: String, + pub source_branch: String, + /// source.build — shell command run in the clone to produce the binary. + pub source_build: String, + /// ui.actions — plugin-declared buttons/forms. Raw objects: the renderer draws + /// them (label/kind/fields/url), the host reads submitPath/loadPath to forward + /// a click to the plugin's control plane. See docs/plugin-system.md. + pub actions: Vec, +} +impl Manifest { + pub(super) fn load(dir: PathBuf) -> Option { + let raw = std::fs::read(dir.join("plugin.json")).ok()?; + let v: Value = serde_json::from_slice(&raw).ok()?; + let id = v.get("id")?.as_str()?.to_string(); + + let s = |path: &[&str], default: &str| -> String { + let mut cur = &v; + for k in path { + match cur.get(*k) { + Some(next) => cur = next, + None => return default.to_string(), + } + } + cur.as_str().unwrap_or(default).to_string() + }; + + let args = v + .get("runtime") + .and_then(|r| r.get("args")) + .and_then(|a| a.as_array()) + .map(|a| a.iter().filter_map(|x| x.as_str().map(String::from)).collect()) + .unwrap_or_else(|| vec!["serve".into(), "--port".into(), "{port}".into(), "--home".into(), "{home}".into()]); + + let mut models = vec![]; + if let Some(arr) = v.get("models").and_then(|m| m.as_array()) { + for m in arr { + let alias = m.get("alias").and_then(|x| x.as_str()).unwrap_or("").to_string(); + let upstream = m + .get("upstream") + .and_then(|x| x.as_str()) + .unwrap_or(alias.as_str()) + .to_string(); + if !alias.is_empty() { + models.push((alias, upstream)); + } + } + } + + let ready_timeout_ms = v + .get("endpoint") + .and_then(|e| e.get("readyTimeoutMs")) + .and_then(|x| x.as_u64()) + .unwrap_or(8000); + + // ui.actions: keep only well-formed objects that carry an id. + let actions = v + .get("ui") + .and_then(|u| u.get("actions")) + .and_then(|a| a.as_array()) + .map(|a| { + a.iter() + .filter(|x| x.get("id").and_then(|i| i.as_str()).map(|s| !s.is_empty()).unwrap_or(false)) + .cloned() + .collect() + }) + .unwrap_or_default(); + + Some(Manifest { + dir, + id, + name: s(&["name"], "Plugin"), + version: s(&["version"], "0.0.0"), + description: s(&["description"], ""), + icon: s(&["icon"], ""), + protocol: s(&["endpoint", "protocol"], "openai-responses"), + base_path: s(&["endpoint", "basePath"], "/v1"), + health_path: s(&["endpoint", "healthPath"], "/healthz"), + ready_timeout_ms, + exec: v.get("runtime").and_then(|r| r.get("exec")).cloned().unwrap_or(Value::Null), + args, + models, + primary: s(&["modelMapping", "primary"], ""), + light: s(&["modelMapping", "light"], ""), + auth_status_path: s(&["auth", "statusPath"], "/v1/plugin/auth"), + source_git: s(&["source", "git"], ""), + source_branch: { + let b = s(&["source", "branch"], ""); + if b.trim().is_empty() { "main".to_string() } else { b } + }, + source_build: s(&["source", "build"], ""), + actions, + }) + } +} diff --git a/src-tauri/src/plugin/manifest_meta.rs b/src-tauri/src/plugin/manifest_meta.rs new file mode 100644 index 0000000..869a536 --- /dev/null +++ b/src-tauri/src/plugin/manifest_meta.rs @@ -0,0 +1,94 @@ +// Manifest accessors the manager reads through: declared actions, the resolved executable and +// its argv, the plugin's base URL and its icon. Moved verbatim from plugin.rs. + +use serde_json::Value; +use std::path::PathBuf; + +use super::manifest::Manifest; +use super::util::{base64_encode, platform_key}; + +impl Manifest { + /// Find a declared action by id. + pub(super) fn action(&self, action_id: &str) -> Option<&Value> { + self.actions + .iter() + .find(|a| a.get("id").and_then(|x| x.as_str()) == Some(action_id)) + } + + /// Resolve (submitPath, loadPath) for an action, applying defaults. + /// submitPath defaults to `/v1/plugin/action/`; loadPath defaults to submitPath. + pub(super) fn action_paths(&self, action_id: &str) -> Option<(String, String)> { + let a = self.action(action_id)?; + let default_submit = format!("/v1/plugin/action/{}", action_id); + let submit = a + .get("submitPath") + .or_else(|| a.get("path")) + .and_then(|x| x.as_str()) + .unwrap_or(default_submit.as_str()) + .to_string(); + let load = a + .get("loadPath") + .and_then(|x| x.as_str()) + .unwrap_or(submit.as_str()) + .to_string(); + Some((submit, load)) + } + + /// Actions as sent to the renderer: host-internal wiring (submitPath/loadPath/ + /// path) stripped, display fields (label/kind/url/fields/…) kept. + pub(super) fn public_actions(&self) -> Vec { + self.actions + .iter() + .map(|a| { + let mut o = a.clone(); + if let Some(m) = o.as_object_mut() { + m.remove("submitPath"); + m.remove("loadPath"); + m.remove("path"); + } + o + }) + .collect() + } + + /// Absolute path to the executable for the current platform, if declared. + pub(super) fn exec_path(&self) -> Option { + let rel = self.exec.get(platform_key()).and_then(|x| x.as_str())?; + Some(self.dir.join(rel)) + } + + pub(super) fn resolved_args(&self, port: u16, home: &str) -> Vec { + self.args + .iter() + .map(|a| a.replace("{port}", &port.to_string()).replace("{home}", home)) + .collect() + } + + pub(super) fn base_url(&self, port: u16) -> String { + format!("http://127.0.0.1:{}{}", port, self.base_path) + } + + /// The plugin's icon as a data URI (data:image/...;base64,...), if declared + /// and readable — lets a plugin ship its own logo for the UI. + pub(super) fn icon_data_uri(&self) -> Option { + let rel = self.icon.trim(); + if rel.is_empty() { + return None; + } + let path = self.dir.join(rel); + let bytes = std::fs::read(&path).ok()?; + if bytes.is_empty() || bytes.len() > 512 * 1024 { + return None; + } + let ext = path.extension().and_then(|e| e.to_str()).map(|e| e.to_ascii_lowercase()); + let mime = match ext.as_deref() { + Some("svg") => "image/svg+xml", + Some("png") => "image/png", + Some("jpg") | Some("jpeg") => "image/jpeg", + Some("webp") => "image/webp", + Some("gif") => "image/gif", + _ => return None, + }; + Some(format!("data:{};base64,{}", mime, base64_encode(&bytes))) + } +} diff --git a/src-tauri/src/plugin/mod.rs b/src-tauri/src/plugin/mod.rs new file mode 100644 index 0000000..1e9a768 --- /dev/null +++ b/src-tauri/src/plugin/mod.rs @@ -0,0 +1,25 @@ +// Sidecar plugin manager. +// +// A ccbud plugin is a standalone local program that reuses some coding agent's +// subscription login (e.g. Grok) and exposes a standard inference endpoint on +// localhost. The host does not do protocol/vendor work for it — see +// docs/plugin-system.md. This module owns the piece the gateway can't: process +// lifecycle, port assignment, and health gating. +// +// Key design choice: a running plugin is surfaced as an ordinary provider whose +// baseUrl points at the plugin's localhost port. Enabling a plugin upserts a +// `backend:"plugin"` provider (id = `plugin:`); disabling only stops the process +// (the service stays, removed on uninstall). The +// gateway then routes to it with zero plugin-specific code. +mod git; +mod install; +mod lifecycle; +mod manager; +mod manifest; +mod manifest_meta; +mod providers; +mod status; +mod util; + +pub use manager::PluginManager; +pub use util::plugins_root; diff --git a/src-tauri/src/plugin/providers.rs b/src-tauri/src/plugin/providers.rs new file mode 100644 index 0000000..9e0cdaf --- /dev/null +++ b/src-tauri/src/plugin/providers.rs @@ -0,0 +1,79 @@ +// The derived `backend:"plugin"` providers: upsert for one plugin, reconcile for all. Moved +// verbatim from plugin.rs. + +use serde_json::json; + +use crate::store; + +use super::manager::PluginManager; +use super::manifest::Manifest; +use super::util::provider_id; + +impl PluginManager { + /// Upsert the `backend:"plugin"` provider that fronts this plugin. The provider is + /// fully derived from the manifest each time: primary/light from modelMapping and + /// NO custom aliases — the plugin advertises the rest via its own /v1/models, so an + /// identity-alias list would just be noise. + pub(super) fn ensure_provider(&self, man: &Manifest, port: u16) { + let pid = provider_id(&man.id); + let provider = json!({ + "id": pid, + "name": man.name, + "backend": "plugin", + "pluginId": man.id, + "baseUrl": man.base_url(port), + "authToken": "", + "protocol": man.protocol, + "defaultModel": man.primary, + "smallFastModel": man.light, + "mapDefaultModels": true, + "models": [], + "icon": man.icon_data_uri(), + }); + + let mut cfg = store::read_config(); + let arr = match cfg.get_mut("providers").and_then(|v| v.as_array_mut()) { + Some(a) => a, + None => { + cfg["providers"] = json!([]); + cfg["providers"].as_array_mut().unwrap() + } + }; + if let Some(i) = arr + .iter() + .position(|x| x.get("id").and_then(|v| v.as_str()) == Some(pid.as_str())) + { + arr[i] = provider; // keep position, refresh contents + } else { + arr.push(provider); + } + store::write_config(cfg); + } + + /// Reconcile the provider list with the set of installed plugins: add a provider + /// for every installed plugin (created even while stopped, so the service is + /// visible but — see provider_set_active — not switchable until running) and + /// prune plugin providers whose plugin is no longer installed. + pub fn sync_providers(&self) { + let discovered = self.discover(); + for man in &discovered { + let port = self.running_port(&man.id).unwrap_or_else(|| self.port_for(&man.id)); + self.ensure_provider(man, port); + } + let ids: std::collections::HashSet = discovered.iter().map(|m| m.id.clone()).collect(); + let mut cfg = store::read_config(); + if let Some(arr) = cfg.get_mut("providers").and_then(|v| v.as_array_mut()) { + arr.retain(|p| { + if p.get("backend").and_then(|v| v.as_str()) == Some("plugin") { + p.get("pluginId") + .and_then(|v| v.as_str()) + .map(|pid| ids.contains(pid)) + .unwrap_or(false) + } else { + true + } + }); + } + store::write_config(cfg); // normalize fixes activeProviderId if it was pruned + } +} diff --git a/src-tauri/src/plugin/status.rs b/src-tauri/src/plugin/status.rs new file mode 100644 index 0000000..2a974f9 --- /dev/null +++ b/src-tauri/src/plugin/status.rs @@ -0,0 +1,95 @@ +// UI-facing snapshots (status / list) and the declarative action calls. Moved verbatim from +// plugin.rs. + +use serde_json::{json, Value}; +use std::time::Duration; + +use super::manager::PluginManager; +use super::util::{is_official_source, provider_id}; + +impl PluginManager { + /// Full snapshot for the UI: install info + running + auth (queried live). + pub async fn status(&self, id: &str) -> Value { + let man = self.manifest(id); + let running = self.is_running(id); + let mut auth = Value::Null; + if running { + if let (Some(m), Some(port)) = (man.as_ref(), self.running_port(id)) { + let url = format!("http://127.0.0.1:{}{}", port, m.auth_status_path); + if let Ok(r) = self.client.get(&url).timeout(Duration::from_secs(3)).send().await { + if let Ok(v) = r.json::().await { + auth = v; + } + } + } + } + json!({ + "id": id, + "name": man.as_ref().map(|m| m.name.clone()).unwrap_or_default(), + "version": man.as_ref().map(|m| m.version.clone()).unwrap_or_default(), + "description": man.as_ref().map(|m| m.description.clone()).unwrap_or_default(), + "protocol": man.as_ref().map(|m| m.protocol.clone()).unwrap_or_default(), + "icon": man.as_ref().and_then(|m| m.icon_data_uri()), + "hasSource": man.as_ref().map(|m| !m.source_git.trim().is_empty()).unwrap_or(false), + "official": man.as_ref().map(|m| is_official_source(&m.source_git)).unwrap_or(false), + "providerId": provider_id(id), + "running": running, + "auth": auth, + "actions": man.as_ref().map(|m| m.public_actions()).unwrap_or_default(), + }) + } + + /// List all discovered plugins with their status. + pub async fn list(&self) -> Value { + let mut out = vec![]; + for m in self.discover() { + out.push(self.status(&m.id).await); + } + json!(out) + } + + /// Run a declarative UI action: POST the form `values` to the action's control + /// plane endpoint and return the plugin's JSON response ({ ok, message }). A + /// non-2xx status surfaces the plugin's `message` as an error to the UI. + pub async fn action(&self, id: &str, action_id: &str, values: Value) -> Result { + let man = self.manifest(id).ok_or("plugin not found")?; + let (submit, _) = man.action_paths(action_id).ok_or("action not found")?; + let port = self.running_port(id).ok_or("plugin not running")?; + let url = format!("http://127.0.0.1:{}{}", port, submit); + let r = self + .client + .post(&url) + .json(&values) + .timeout(Duration::from_secs(30)) + .send() + .await + .map_err(|e| e.to_string())?; + let ok = r.status().is_success(); + let body = r.json::().await.unwrap_or_else(|_| json!({})); + if !ok { + let msg = body + .get("message") + .and_then(|x| x.as_str()) + .unwrap_or("plugin returned an error"); + return Err(msg.to_string()); + } + Ok(body) + } + + /// Fetch current values to prefill a declarative form (GET the action's + /// loadPath). Returns the plugin's JSON, typically `{ values: { ... } }`. + pub async fn action_load(&self, id: &str, action_id: &str) -> Result { + let man = self.manifest(id).ok_or("plugin not found")?; + let (_, load) = man.action_paths(action_id).ok_or("action not found")?; + let port = self.running_port(id).ok_or("plugin not running")?; + let url = format!("http://127.0.0.1:{}{}", port, load); + let r = self + .client + .get(&url) + .timeout(Duration::from_secs(10)) + .send() + .await + .map_err(|e| e.to_string())?; + r.json::().await.map_err(|e| e.to_string()) + } +} diff --git a/src-tauri/src/plugin/util.rs b/src-tauri/src/plugin/util.rs new file mode 100644 index 0000000..6f015f4 --- /dev/null +++ b/src-tauri/src/plugin/util.rs @@ -0,0 +1,183 @@ +// Free helpers shared by the manager: ids, platform keys, ports, paths, base64, GitHub raw +// URLs, version comparison, the child-process PATH, and a recursive copy. Moved verbatim from +// plugin.rs. + +use std::path::PathBuf; +use std::process::{Command, Stdio}; + +pub(super) fn provider_id(plugin_id: &str) -> String { + format!("plugin:{}", plugin_id) +} + +/// `-` matching plugin.json's runtime.exec keys. +pub(super) fn platform_key() -> &'static str { + match (std::env::consts::OS, std::env::consts::ARCH) { + ("macos", "aarch64") => "darwin-arm64", + ("macos", "x86_64") => "darwin-amd64", + ("linux", "x86_64") => "linux-amd64", + ("linux", "aarch64") => "linux-arm64", + ("windows", "x86_64") => "windows-amd64", + _ => "unknown", + } +} + +pub(super) fn free_port() -> Option { + std::net::TcpListener::bind("127.0.0.1:0") + .ok() + .and_then(|l| l.local_addr().ok()) + .map(|a| a.port()) +} + +/// True if we can bind 127.0.0.1:port right now (i.e. nothing else is holding it). +pub(super) fn port_is_free(port: u16) -> bool { + port != 0 && std::net::TcpListener::bind(("127.0.0.1", port)).is_ok() +} + +/// ccbud config home (~/.ccbud, overridable via CCBUD_HOME) — mirrors store.rs. +pub(super) fn ccbud_home() -> PathBuf { + if let Ok(v) = std::env::var("CCBUD_HOME") { + if !v.trim().is_empty() { + return PathBuf::from(v); + } + } + let home = std::env::var("HOME") + .or_else(|_| std::env::var("USERPROFILE")) + .unwrap_or_else(|_| ".".into()); + PathBuf::from(home).join(".ccbud") +} + +/// ~/.ccbud/plugins — where plugins are installed. +pub fn plugins_root() -> PathBuf { + ccbud_home().join("plugins") +} + +/// Minimal standard base64 — used only to embed a small plugin icon as a data URI. +pub(super) fn base64_encode(input: &[u8]) -> String { + const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let mut out = String::with_capacity((input.len() + 2) / 3 * 4); + for chunk in input.chunks(3) { + let b0 = chunk[0]; + let b1 = *chunk.get(1).unwrap_or(&0); + let b2 = *chunk.get(2).unwrap_or(&0); + out.push(T[(b0 >> 2) as usize] as char); + out.push(T[(((b0 & 0x03) << 4) | (b1 >> 4)) as usize] as char); + out.push(if chunk.len() > 1 { T[(((b1 & 0x0f) << 2) | (b2 >> 6)) as usize] as char } else { '=' }); + out.push(if chunk.len() > 2 { T[(b2 & 0x3f) as usize] as char } else { '=' }); + } + out +} + +/// Convert a github.com repo URL + branch into a raw file URL. +pub(super) fn github_raw(git: &str, branch: &str, path: &str) -> Option { + let g = git.trim().trim_end_matches('/').trim_end_matches(".git"); + let rest = g + .strip_prefix("https://github.com/") + .or_else(|| g.strip_prefix("http://github.com/")) + .or_else(|| g.strip_prefix("git@github.com:"))?; + let br = if branch.trim().is_empty() { "main" } else { branch.trim() }; + Some(format!("https://raw.githubusercontent.com/{}/{}/{}", rest, br, path)) +} + +/// True if a git URL points at the official `ccbud` org on github. +pub(super) fn is_official_source(git: &str) -> bool { + let g = git.trim().trim_end_matches('/').trim_end_matches(".git"); + g.strip_prefix("https://github.com/") + .or_else(|| g.strip_prefix("http://github.com/")) + .or_else(|| g.strip_prefix("git@github.com:")) + .and_then(|rest| rest.split('/').next()) + .map(|owner| owner.eq_ignore_ascii_case("ccbud")) + .unwrap_or(false) +} + +/// True if semver-ish `a` is strictly newer than `b` (e.g. "0.2.0" > "0.1.9"). +pub(super) fn version_gt(a: &str, b: &str) -> bool { + parse_ver(a) > parse_ver(b) +} +pub(super) fn parse_ver(v: &str) -> Vec { + v.trim() + .trim_start_matches('v') + .split(|c| c == '.' || c == '-' || c == '+') + .map(|s| s.parse::().unwrap_or(0)) + .collect() +} +/// PATH for `source.build` commands. A GUI app launched from Finder/the Dock +/// inherits launchd's minimal PATH (no Homebrew, no /usr/local/go/bin, …), so +/// builds die with e.g. "go: command not found" even though the toolchain works +/// fine in the user's terminal. Merge the inherited PATH with the login shell's +/// PATH plus well-known toolchain dirs. +pub(super) fn build_env_path() -> std::ffi::OsString { + let mut dirs: Vec = std::env::var_os("PATH") + .map(|p| std::env::split_paths(&p).collect()) + .unwrap_or_default(); + if let Some(p) = login_shell_path() { + for d in std::env::split_paths(&p) { + if !dirs.contains(&d) { + dirs.push(d); + } + } + } + // Cover toolchains even when the login shell probe fails (or exports them + // only for interactive shells): Homebrew, the official Go installer, and + // per-user go/cargo/pip bin dirs. + let mut extras: Vec = + ["/usr/local/bin", "/opt/homebrew/bin", "/usr/local/go/bin"].iter().map(PathBuf::from).collect(); + if let Some(home) = std::env::var_os("HOME") { + let home = PathBuf::from(home); + extras.extend(["go/bin", ".cargo/bin", ".local/bin"].iter().map(|d| home.join(d))); + } + for d in extras { + if d.is_dir() && !dirs.contains(&d) { + dirs.push(d); + } + } + std::env::join_paths(dirs).unwrap_or_else(|_| std::env::var_os("PATH").unwrap_or_default()) +} + +/// Ask the user's login shell for its PATH (profiles are where Homebrew, Go, +/// cargo, … register themselves). Best-effort: any failure returns None. +pub(super) fn login_shell_path() -> Option { + let shell = std::env::var("SHELL").ok().filter(|s| !s.trim().is_empty())?; + let out = Command::new(shell) + .args(["-l", "-c", "env"]) + .stdin(Stdio::null()) + .output() + .ok()?; + if !out.status.success() { + return None; + } + // Profile scripts may print noise before `env` runs; take the last PATH= line. + String::from_utf8_lossy(&out.stdout) + .lines() + .rev() + .find_map(|l| l.strip_prefix("PATH=").map(|v| v.to_string())) + .filter(|p| !p.trim().is_empty()) +} + +/// A process-unique-ish suffix for temporary import directories. +pub(super) fn unique_suffix() -> String { + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0); + format!("{}-{}", std::process::id(), nanos) +} + +/// Recursively copy a directory tree (files + subdirs; symlinks skipped). +pub(super) fn copy_dir_all(src: &std::path::Path, dst: &std::path::Path) -> std::io::Result<()> { + std::fs::create_dir_all(dst)?; + for entry in std::fs::read_dir(src)? { + let entry = entry?; + if entry.file_name() == ".git" { + continue; // never copy VCS metadata into an install dir + } + let ty = entry.file_type()?; + let from = entry.path(); + let to = dst.join(entry.file_name()); + if ty.is_dir() { + copy_dir_all(&from, &to)?; + } else if ty.is_file() { + std::fs::copy(&from, &to)?; + } + } + Ok(()) +} diff --git a/src-tauri/src/popover.rs b/src-tauri/src/popover.rs new file mode 100644 index 0000000..981884c --- /dev/null +++ b/src-tauri/src/popover.rs @@ -0,0 +1,116 @@ +// Popover window wiring: the show/hide debounce timestamps, the macOS NSPanel conversion and +// the blur auto-hide hook, plus the main-window visibility hooks that drive the daily auto +// update and the tray usage title. +// +// The two setup fns are extracted verbatim from the `setup()` closure in lib.rs `run()` so both +// files stay under the split's size cap; each is called exactly once, from run()'s setup. + +use tauri::Manager; + +use crate::commands::auto_update_on_visible; +use crate::tray::update_tray_title; + +// Timestamp (ms since epoch) of the last popover hide — used to debounce the tray click, +// which would otherwise re-show the popover on the very click that blurred it shut. +pub(crate) static LAST_POPOVER_HIDE_MS: std::sync::atomic::AtomicI64 = std::sync::atomic::AtomicI64::new(0); +// Timestamp of the last popover show — a fullscreen app steals focus the instant the popover +// appears, so we ignore blur within a grace window after show (else it hides before being seen). +pub(crate) static LAST_POPOVER_SHOW_MS: std::sync::atomic::AtomicI64 = std::sync::atomic::AtomicI64::new(0); +pub(crate) fn now_ms() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as i64) + .unwrap_or(0) +} + +pub(crate) fn setup_popover(app: &tauri::App) { + // Popover behavior: (1) float on the current Space AND over fullscreen apps; + // (2) auto-hide when it loses focus — clicking anywhere else closes it. + if let Some(pop) = app.get_webview_window("popover") { + // macOS: convert the popover into a non-activating NSPanel. Unlike a plain window, + // a nonactivating panel can float on the CURRENT Space — including another app's + // fullscreen Space — and shows without activating ccbud or switching Spaces. + #[cfg(target_os = "macos")] + { + use tauri_nspanel::cocoa::appkit::NSWindowCollectionBehavior as CB; + use tauri_nspanel::WebviewWindowExt as _; + if let Ok(panel) = pop.to_panel() { + panel.set_style_mask((1 << 7) as i32); // NSWindowStyleMaskNonactivatingPanel + panel.set_collection_behaviour( + CB::NSWindowCollectionBehaviorCanJoinAllSpaces + | CB::NSWindowCollectionBehaviorFullScreenAuxiliary + | CB::NSWindowCollectionBehaviorStationary, + ); + panel.set_floating_panel(true); + panel.set_level(24); // ~NSMainMenuWindowLevel: above fullscreen content + panel.set_hides_on_deactivate(false); + panel.set_released_when_closed(false); + } + } + let pop2 = pop.clone(); + pop.on_window_event(move |event| { + // Bind + deref: `Focused(false)` as a literal pattern does NOT match against + // &WindowEvent here (match ergonomics), so the handler would never fire. + if let tauri::WindowEvent::Focused(focused) = event { + if !*focused { + // Grace period: a fullscreen app steals focus the instant the popover + // shows; ignore that blur so it isn't hidden before being seen. A real + // click-away blur arrives well after the show. + if now_ms() + - LAST_POPOVER_SHOW_MS.load(std::sync::atomic::Ordering::Relaxed) + >= 400 + { + let _ = pop2.hide(); + LAST_POPOVER_HIDE_MS + .store(now_ms(), std::sync::atomic::Ordering::Relaxed); + } + } + } + }); + } +} + +pub(crate) fn setup_window_hooks(app: &tauri::App) { + // Daily auto update, triggered by the app becoming visible (see auto_update_on_visible). + // Main-window focus covers launch, tray "open main", Dock/taskbar switches and the + // single-instance re-open; the popover-show branch of the tray click covers tray-only days. + if let Some(main) = app.get_webview_window("main") { + let h = app.handle().clone(); + main.on_window_event(move |event| { + if let tauri::WindowEvent::Focused(focused) = event { + if *focused { + auto_update_on_visible(&h); + } + } + }); + } + // Launch counts as today's first visibility even if no focus event fires (e.g. an + // autostarted login launch that opens unfocused). Delayed a few seconds so the + // network/gateway are up before the first check. + { + let h = app.handle().clone(); + tauri::async_runtime::spawn(async move { + tokio::time::sleep(std::time::Duration::from_secs(3)).await; + let visible = h + .get_webview_window("main") + .and_then(|w| w.is_visible().ok()) + .unwrap_or(false); + if visible { + auto_update_on_visible(&h); + } + }); + } + + // Tray usage title: show the configured token count next to the menu-bar icon + // (macOS), refreshed on a timer so it tracks new usage without any user action. + { + let h = app.handle().clone(); + std::thread::spawn(move || { + std::thread::sleep(std::time::Duration::from_millis(1500)); + loop { + update_tray_title(&h); + std::thread::sleep(std::time::Duration::from_secs(60)); + } + }); + } +} diff --git a/src-tauri/src/protocol/anthropic.rs b/src-tauri/src/protocol/anthropic.rs deleted file mode 100644 index e65c7c6..0000000 --- a/src-tauri/src/protocol/anthropic.rs +++ /dev/null @@ -1,479 +0,0 @@ -// The "Anthropic server-side" halves that llm-connector (a client library) doesn't provide: -// - decode_request: Anthropic Messages REQUEST json → llm-connector ChatRequest IR -// - encode_response: llm-connector ChatResponse IR → Anthropic Messages RESPONSE json -// -// Mapping follows the same shape LiteLLM / musistudio use: Anthropic content blocks are flattened -// into the OpenAI-style IR — `tool_use` blocks become assistant `Message.tool_calls`, `tool_result` -// blocks become separate `role:tool` messages, `system` becomes a leading system message. The IR is -// then encoded to OpenAI Chat (or Responses) by the crate. The reverse rebuilds Anthropic content -// blocks from the IR's tool_calls + text. -// -// Claude Code footguns handled explicitly (LiteLLM shipped bugs on these): user/system content -// blocks arrive as `{"type":"input_text"}` (not `text`) and MUST be recognized, else content is -// silently dropped → upstream 422. - -use llm_connector::types::{ - ChatRequest, ChatResponse, FunctionCall, Message, MessageBlock, Role, Tool, ToolCall, -}; -use serde_json::{json, Value}; - -/// Pull plain text out of an Anthropic content value (string, or array of text/input_text blocks). -fn blocks_text(content: &Value) -> String { - if let Some(s) = content.as_str() { - return s.to_string(); - } - let arr = match content.as_array() { - Some(a) => a, - None => return String::new(), - }; - let mut out: Vec = vec![]; - for b in arr { - match b.get("type").and_then(|t| t.as_str()) { - // Claude Code sends `input_text`; the Anthropic API also uses `text`. Accept both. - Some("text") | Some("input_text") => { - if let Some(t) = b.get("text").and_then(|t| t.as_str()) { - out.push(t.to_string()); - } - } - _ => {} - } - } - out.join("\n") -} - -/// Image blocks in an Anthropic content array → IR image blocks (base64 or url). -fn image_blocks(content: &Value) -> Vec { - let arr = match content.as_array() { - Some(a) => a, - None => return vec![], - }; - let mut out = vec![]; - for b in arr { - if b.get("type").and_then(|t| t.as_str()) != Some("image") { - continue; - } - let src = b.get("source").cloned().unwrap_or(Value::Null); - match src.get("type").and_then(|t| t.as_str()) { - Some("base64") => { - let mt = src.get("media_type").and_then(|v| v.as_str()).unwrap_or("image/png"); - let data = src.get("data").and_then(|v| v.as_str()).unwrap_or(""); - if !data.is_empty() { - out.push(MessageBlock::image_base64(mt, data)); - } - } - Some("url") => { - if let Some(u) = src.get("url").and_then(|v| v.as_str()) { - out.push(MessageBlock::image_url_anthropic(u)); - } - } - _ => {} - } - } - out -} - -/// tool_result blocks in a user turn → their own `role:tool` IR messages (OpenAI shape). Anthropic -/// nests tool results inside a user message; OpenAI wants each as a standalone tool message. -fn tool_result_messages(content: &Value) -> Vec { - let arr = match content.as_array() { - Some(a) => a, - None => return vec![], - }; - let mut out = vec![]; - for b in arr { - if b.get("type").and_then(|t| t.as_str()) != Some("tool_result") { - continue; - } - let id = b.get("tool_use_id").and_then(|v| v.as_str()).unwrap_or("").to_string(); - // tool_result content is a string or an array of text blocks. - let text = match b.get("content") { - Some(Value::String(s)) => s.clone(), - Some(c @ Value::Array(_)) => blocks_text(c), - _ => String::new(), - }; - out.push(Message::tool(text, id)); - } - out -} - -/// tool_use blocks in an assistant turn → IR ToolCalls (OpenAI function-call shape). -fn tool_use_calls(content: &Value) -> Vec { - let arr = match content.as_array() { - Some(a) => a, - None => return vec![], - }; - let mut out = vec![]; - for b in arr { - if b.get("type").and_then(|t| t.as_str()) != Some("tool_use") { - continue; - } - let id = b.get("id").and_then(|v| v.as_str()).unwrap_or("").to_string(); - let name = b.get("name").and_then(|v| v.as_str()).unwrap_or("").to_string(); - let args = b.get("input").cloned().unwrap_or_else(|| json!({})); - out.push(ToolCall { - id, - call_type: "function".to_string(), - function: FunctionCall { - name, - arguments: serde_json::to_string(&args).unwrap_or_else(|_| "{}".to_string()), - thought_signature: None, - }, - index: None, - thought_signature: None, - }); - } - out -} - -/// Anthropic `system` (string or array of text blocks) → a leading system Message. -fn system_message(req: &Value) -> Option { - let sys = req.get("system")?; - let text = if sys.is_string() { sys.as_str().unwrap_or("").to_string() } else { blocks_text(sys) }; - let text = text.trim(); - if text.is_empty() { - None - } else { - Some(Message::text(Role::System, text)) - } -} - -/// Anthropic `tools` → IR function tools. `input_schema` maps to the function `parameters`. -fn tools(req: &Value) -> Option> { - let arr = req.get("tools").and_then(|v| v.as_array())?; - let mut out = vec![]; - for t in arr { - let name = t.get("name").and_then(|v| v.as_str())?; - let desc = t.get("description").and_then(|v| v.as_str()).map(|s| s.to_string()); - let params = t.get("input_schema").cloned().unwrap_or_else(|| json!({ "type": "object" })); - out.push(Tool::function(name, desc, params)); - } - if out.is_empty() { - None - } else { - Some(out) - } -} - -/// Decode an Anthropic Messages REQUEST json into the llm-connector IR. `model` is left as the -/// request's model (gateway.rs already rewrote it to the provider's outgoing model before this). -pub fn decode_request(req: &Value) -> Result { - let model = req.get("model").and_then(|v| v.as_str()).unwrap_or("").to_string(); - let mut messages: Vec = vec![]; - - if let Some(sys) = system_message(req) { - messages.push(sys); - } - - let turns = req.get("messages").and_then(|v| v.as_array()).cloned().unwrap_or_default(); - for m in &turns { - let role = m.get("role").and_then(|v| v.as_str()).unwrap_or("user"); - let content = m.get("content").cloned().unwrap_or(Value::Null); - match role { - "assistant" => { - // assistant turn: text (+ optional thinking) + tool_use → tool_calls - let mut blocks: Vec = vec![]; - let text = blocks_text(&content); - if !text.is_empty() { - blocks.push(MessageBlock::text(text)); - } - let calls = tool_use_calls(&content); - let mut msg = Message::new(Role::Assistant, blocks); - if !calls.is_empty() { - msg.tool_calls = Some(calls); - } - messages.push(msg); - } - _ => { - // user turn: tool_result blocks split off into their own tool messages FIRST - // (they answer the prior assistant tool_calls), then any remaining text/images. - for tm in tool_result_messages(&content) { - messages.push(tm); - } - let mut blocks: Vec = vec![]; - let text = blocks_text(&content); - if !text.is_empty() { - blocks.push(MessageBlock::text(text)); - } - blocks.extend(image_blocks(&content)); - if !blocks.is_empty() { - messages.push(Message::new(Role::User, blocks)); - } - } - } - } - - let mut cr = ChatRequest::new(model).with_messages(messages); - if let Some(mt) = req.get("max_tokens").and_then(|v| v.as_u64()) { - cr = cr.with_max_tokens(mt as u32); - } - if let Some(t) = req.get("temperature").and_then(|v| v.as_f64()) { - cr = cr.with_temperature(t as f32); - } - if let Some(p) = req.get("top_p").and_then(|v| v.as_f64()) { - cr = cr.with_top_p(p as f32); - } - if req.get("stream").and_then(|v| v.as_bool()).unwrap_or(false) { - cr = cr.with_stream(true); - } - if let Some(stop) = req.get("stop_sequences").and_then(|v| v.as_array()) { - let v: Vec = stop.iter().filter_map(|s| s.as_str().map(|x| x.to_string())).collect(); - if !v.is_empty() { - cr = cr.with_stop(v); - } - } - if let Some(ts) = tools(req) { - cr = cr.with_tools(ts); - } - // Anthropic extended thinking → IR thinking budget (+ enable). Downstream OpenAI-chat drops it; - // Responses maps the budget to reasoning.effort (handled in the responses codec). - if let Some(th) = req.get("thinking") { - let enabled = th.get("type").and_then(|v| v.as_str()) == Some("enabled"); - if enabled { - cr = cr.with_enable_thinking(true); - if let Some(b) = th.get("budget_tokens").and_then(|v| v.as_u64()) { - cr = cr.with_thinking_budget(b as u32); - } - } - } - - Ok(cr) -} - -/// Map an OpenAI/IR finish_reason to an Anthropic stop_reason. -fn stop_reason(finish: Option<&str>, had_tool_calls: bool) -> &'static str { - match finish { - Some("length") => "max_tokens", - Some("tool_calls") | Some("function_call") => "tool_use", - Some("content_filter") => "end_turn", - _ if had_tool_calls => "tool_use", - _ => "end_turn", - } -} - -/// Encode the IR response back into an Anthropic Messages RESPONSE json. `client_model` is the name -/// the client asked for (so Claude Code sees its own model, not the upstream's). -pub fn encode_response(resp: &ChatResponse, client_model: &str) -> Value { - let choice = resp.choices.first(); - let msg = choice.map(|c| &c.message); - - let mut content: Vec = vec![]; - // assistant thinking (if the provider surfaced reasoning) → an Anthropic thinking block first. - if let Some(m) = msg { - if let Some(reasoning) = m.reasoning_any() { - if !reasoning.trim().is_empty() { - content.push(json!({ "type": "thinking", "thinking": reasoning })); - } - } - } - // assistant text. The crate parks text in choices[].message.content normally, but when a turn - // ALSO has tool_calls it keeps the text only in the top-level ChatResponse.content — so fall - // back to that (else assistant prose is dropped whenever a tool is called in the same turn). - let text = { - let t = msg.map(|m| m.content_as_text()).unwrap_or_default(); - if t.is_empty() { resp.content.clone() } else { t } - }; - if !text.is_empty() { - content.push(json!({ "type": "text", "text": text })); - } - // tool calls → tool_use blocks - let mut had_tool_calls = false; - if let Some(m) = msg { - if let Some(calls) = &m.tool_calls { - for tc in calls { - had_tool_calls = true; - let input: Value = tc.arguments_value().unwrap_or_else(|_| json!({})); - content.push(json!({ - "type": "tool_use", - "id": if tc.id.is_empty() { format!("toolu_{}", content.len()) } else { tc.id.clone() }, - "name": tc.function.name, - "input": input, - })); - } - } - } - if content.is_empty() { - content.push(json!({ "type": "text", "text": "" })); - } - - let finish = choice.and_then(|c| c.finish_reason.as_deref()); - let usage = resp.usage.as_ref(); - let input_tokens = usage.map(|u| u.prompt_tokens).unwrap_or(0); - let output_tokens = usage.map(|u| u.completion_tokens).unwrap_or(0); - - json!({ - // never a constant fallback — clients persist this id and usage de-dupes by it - "id": if resp.id.is_empty() { super::uid("msg_ccbud") } else { resp.id.clone() }, - "type": "message", - "role": "assistant", - "model": client_model, - "content": content, - "stop_reason": stop_reason(finish, had_tool_calls), - "stop_sequence": Value::Null, - "usage": { "input_tokens": input_tokens, "output_tokens": output_tokens }, - }) -} - -/// Synthesize a complete Anthropic Messages SSE event sequence from a finished IR response. Used -/// when the client (Claude Code) asked to stream but the upstream was translated buffered — the -/// client still gets a valid, ordered `message_start → content_block_* → message_delta → -/// message_stop` stream, just delivered at once. True token-by-token transcoding is P2. -pub fn encode_response_sse(resp: &ChatResponse, client_model: &str) -> String { - let full = encode_response(resp, client_model); - let content = full.get("content").and_then(|v| v.as_array()).cloned().unwrap_or_default(); - let stop_reason = full.get("stop_reason").cloned().unwrap_or(json!("end_turn")); - let usage = full.get("usage").cloned().unwrap_or(json!({ "input_tokens": 0, "output_tokens": 0 })); - let id = full.get("id").cloned().unwrap_or(json!("msg_ccbud")); - let input_tokens = usage.get("input_tokens").cloned().unwrap_or(json!(0)); - let output_tokens = usage.get("output_tokens").cloned().unwrap_or(json!(0)); - - let ev = |event: &str, data: Value| { - format!("event: {}\ndata: {}\n\n", event, serde_json::to_string(&data).unwrap_or_default()) - }; - let mut out = String::new(); - - // message_start (usage input tokens known up front; output filled at message_delta) - out.push_str(&ev( - "message_start", - json!({ "type": "message_start", "message": { - "id": id, "type": "message", "role": "assistant", "model": client_model, - "content": [], "stop_reason": Value::Null, "stop_sequence": Value::Null, - "usage": { "input_tokens": input_tokens, "output_tokens": 0 }, - }}), - )); - - for (i, block) in content.iter().enumerate() { - let bt = block.get("type").and_then(|v| v.as_str()).unwrap_or("text"); - match bt { - "text" => { - let text = block.get("text").and_then(|v| v.as_str()).unwrap_or(""); - out.push_str(&ev("content_block_start", json!({ "type": "content_block_start", "index": i, "content_block": { "type": "text", "text": "" } }))); - if !text.is_empty() { - out.push_str(&ev("content_block_delta", json!({ "type": "content_block_delta", "index": i, "delta": { "type": "text_delta", "text": text } }))); - } - out.push_str(&ev("content_block_stop", json!({ "type": "content_block_stop", "index": i }))); - } - "thinking" => { - let think = block.get("thinking").and_then(|v| v.as_str()).unwrap_or(""); - out.push_str(&ev("content_block_start", json!({ "type": "content_block_start", "index": i, "content_block": { "type": "thinking", "thinking": "" } }))); - if !think.is_empty() { - out.push_str(&ev("content_block_delta", json!({ "type": "content_block_delta", "index": i, "delta": { "type": "thinking_delta", "thinking": think } }))); - } - out.push_str(&ev("content_block_stop", json!({ "type": "content_block_stop", "index": i }))); - } - "tool_use" => { - let empty = json!({}); - let input = block.get("input").unwrap_or(&empty); - out.push_str(&ev("content_block_start", json!({ "type": "content_block_start", "index": i, "content_block": { "type": "tool_use", "id": block.get("id").cloned().unwrap_or(json!("")), "name": block.get("name").cloned().unwrap_or(json!("")), "input": {} } }))); - out.push_str(&ev("content_block_delta", json!({ "type": "content_block_delta", "index": i, "delta": { "type": "input_json_delta", "partial_json": serde_json::to_string(input).unwrap_or_else(|_| "{}".to_string()) } }))); - out.push_str(&ev("content_block_stop", json!({ "type": "content_block_stop", "index": i }))); - } - _ => {} - } - } - - out.push_str(&ev( - "message_delta", - json!({ "type": "message_delta", "delta": { "stop_reason": stop_reason, "stop_sequence": Value::Null }, "usage": { "output_tokens": output_tokens } }), - )); - out.push_str(&ev("message_stop", json!({ "type": "message_stop" }))); - out -} - -#[cfg(test)] -mod tests { - use super::*; - use llm_connector::core::Protocol; - use llm_connector::protocols::adapters::openai::OpenAIProtocol; - - // A representative Claude Code request: system + a user prose turn (input_text blocks), an - // assistant tool_use, and the user's tool_result — the shape the messages→chat path must map. - fn claude_request() -> Value { - json!({ - "model": "claude-sonnet-4-6", - "max_tokens": 1024, - "system": "You are a helpful coding assistant.", - "tools": [{ "name": "read_file", "description": "Read a file", - "input_schema": { "type": "object", "properties": { "path": { "type": "string" } } } }], - "messages": [ - { "role": "user", "content": [{ "type": "input_text", "text": "read a.txt" }] }, - { "role": "assistant", "content": [ - { "type": "text", "text": "Reading it." }, - { "type": "tool_use", "id": "toolu_1", "name": "read_file", "input": { "path": "a.txt" } } - ] }, - { "role": "user", "content": [ - { "type": "tool_result", "tool_use_id": "toolu_1", "content": "hello world" } - ] } - ] - }) - } - - #[test] - fn decodes_anthropic_request_to_openai_chat_body() { - let ir = decode_request(&claude_request()).unwrap(); - // system prepended, tool_result split into its own tool message, ordering preserved. - let roles: Vec<_> = ir.messages.iter().map(|m| format!("{:?}", m.role)).collect(); - assert_eq!(roles, vec!["System", "User", "Assistant", "Tool"]); - // input_text was recognized (not dropped → this is the Claude Code footgun). - assert_eq!(ir.messages[1].content_as_text(), "read a.txt"); - // assistant tool_use → tool_calls - let calls = ir.messages[2].tool_calls.as_ref().unwrap(); - assert_eq!(calls[0].function.name, "read_file"); - assert!(calls[0].function.arguments.contains("a.txt")); - // tool_result → tool message carrying the id + output - assert_eq!(ir.messages[3].tool_call_id.as_deref(), Some("toolu_1")); - assert_eq!(ir.messages[3].content_as_text(), "hello world"); - // tools carried through - assert_eq!(ir.tools.as_ref().unwrap()[0].function.name, "read_file"); - - // The crate encodes the IR to a real OpenAI Chat body — proves the reused half works. - let body = OpenAIProtocol::new("k").build_chat_request_body(&ir).unwrap(); - let msgs = body.get("messages").and_then(|v| v.as_array()).unwrap(); - assert_eq!(msgs[0]["role"], "system"); - assert!(body.get("tools").is_some()); - } - - #[test] - fn encodes_openai_chat_response_to_anthropic() { - // A real OpenAI Chat response with a tool call, decoded by the crate → IR → Anthropic. - let openai = r#"{ - "id":"chatcmpl-1","object":"chat.completion","created":1,"model":"gpt-4o", - "choices":[{"index":0,"finish_reason":"tool_calls","message":{ - "role":"assistant","content":"Sure.", - "tool_calls":[{"id":"call_9","type":"function", - "function":{"name":"read_file","arguments":"{\"path\":\"a.txt\"}"}}]}}], - "usage":{"prompt_tokens":11,"completion_tokens":7,"total_tokens":18} - }"#; - let ir = OpenAIProtocol::new("k").parse_response(openai).unwrap(); - let out = encode_response(&ir, "claude-sonnet-4-6"); - - assert_eq!(out["type"], "message"); - assert_eq!(out["role"], "assistant"); - assert_eq!(out["model"], "claude-sonnet-4-6"); // client-facing model, not gpt-4o - assert_eq!(out["stop_reason"], "tool_use"); - assert_eq!(out["usage"]["input_tokens"], 11); - assert_eq!(out["usage"]["output_tokens"], 7); - let content = out["content"].as_array().unwrap(); - assert!(content.iter().any(|b| b["type"] == "text" && b["text"] == "Sure.")); - let tu = content.iter().find(|b| b["type"] == "tool_use").unwrap(); - assert_eq!(tu["name"], "read_file"); - assert_eq!(tu["input"]["path"], "a.txt"); - assert_eq!(tu["id"], "call_9"); - } - - #[test] - fn plain_text_round_trip() { - let req = json!({ - "model": "claude-x", "max_tokens": 100, - "messages": [{ "role": "user", "content": "hi there" }] - }); - let ir = decode_request(&req).unwrap(); - assert_eq!(ir.messages.len(), 1); - assert_eq!(ir.messages[0].content_as_text(), "hi there"); - - let openai = r#"{"id":"c1","object":"chat.completion","created":1,"model":"gpt","choices":[{"index":0,"finish_reason":"stop","message":{"role":"assistant","content":"hello!"}}],"usage":{"prompt_tokens":2,"completion_tokens":1,"total_tokens":3}}"#; - let ir2 = OpenAIProtocol::new("k").parse_response(openai).unwrap(); - let out = encode_response(&ir2, "claude-x"); - assert_eq!(out["stop_reason"], "end_turn"); - assert_eq!(out["content"][0]["text"], "hello!"); - } -} diff --git a/src-tauri/src/protocol/anthropic/blocks.rs b/src-tauri/src/protocol/anthropic/blocks.rs new file mode 100644 index 0000000..c666664 --- /dev/null +++ b/src-tauri/src/protocol/anthropic/blocks.rs @@ -0,0 +1,143 @@ +// Anthropic content blocks → IR pieces. Claude Code sends user/system blocks as +// `{"type":"input_text"}` (not `text`) — recognizing that is load-bearing: missing it silently +// drops content and the upstream answers 422. + +use llm_connector::types::{FunctionCall, Message, MessageBlock, Role, Tool, ToolCall}; +use serde_json::{json, Value}; + +/// Pull plain text out of an Anthropic content value (string, or array of text/input_text blocks). +pub(super) fn blocks_text(content: &Value) -> String { + if let Some(s) = content.as_str() { + return s.to_string(); + } + let arr = match content.as_array() { + Some(a) => a, + None => return String::new(), + }; + let mut out: Vec = vec![]; + for b in arr { + match b.get("type").and_then(|t| t.as_str()) { + // Claude Code sends `input_text`; the Anthropic API also uses `text`. Accept both. + Some("text") | Some("input_text") => { + if let Some(t) = b.get("text").and_then(|t| t.as_str()) { + out.push(t.to_string()); + } + } + _ => {} + } + } + out.join("\n") +} + +/// Image blocks in an Anthropic content array → IR image blocks (base64 or url). +pub(super) fn image_blocks(content: &Value) -> Vec { + let arr = match content.as_array() { + Some(a) => a, + None => return vec![], + }; + let mut out = vec![]; + for b in arr { + if b.get("type").and_then(|t| t.as_str()) != Some("image") { + continue; + } + let src = b.get("source").cloned().unwrap_or(Value::Null); + match src.get("type").and_then(|t| t.as_str()) { + Some("base64") => { + let mt = src.get("media_type").and_then(|v| v.as_str()).unwrap_or("image/png"); + let data = src.get("data").and_then(|v| v.as_str()).unwrap_or(""); + if !data.is_empty() { + out.push(MessageBlock::image_base64(mt, data)); + } + } + Some("url") => { + if let Some(u) = src.get("url").and_then(|v| v.as_str()) { + out.push(MessageBlock::image_url_anthropic(u)); + } + } + _ => {} + } + } + out +} + +/// tool_result blocks in a user turn → their own `role:tool` IR messages (OpenAI shape). Anthropic +/// nests tool results inside a user message; OpenAI wants each as a standalone tool message. +pub(super) fn tool_result_messages(content: &Value) -> Vec { + let arr = match content.as_array() { + Some(a) => a, + None => return vec![], + }; + let mut out = vec![]; + for b in arr { + if b.get("type").and_then(|t| t.as_str()) != Some("tool_result") { + continue; + } + let id = b.get("tool_use_id").and_then(|v| v.as_str()).unwrap_or("").to_string(); + // tool_result content is a string or an array of text blocks. + let text = match b.get("content") { + Some(Value::String(s)) => s.clone(), + Some(c @ Value::Array(_)) => blocks_text(c), + _ => String::new(), + }; + out.push(Message::tool(text, id)); + } + out +} + +/// tool_use blocks in an assistant turn → IR ToolCalls (OpenAI function-call shape). +pub(super) fn tool_use_calls(content: &Value) -> Vec { + let arr = match content.as_array() { + Some(a) => a, + None => return vec![], + }; + let mut out = vec![]; + for b in arr { + if b.get("type").and_then(|t| t.as_str()) != Some("tool_use") { + continue; + } + let id = b.get("id").and_then(|v| v.as_str()).unwrap_or("").to_string(); + let name = b.get("name").and_then(|v| v.as_str()).unwrap_or("").to_string(); + let args = b.get("input").cloned().unwrap_or_else(|| json!({})); + out.push(ToolCall { + id, + call_type: "function".to_string(), + function: FunctionCall { + name, + arguments: serde_json::to_string(&args).unwrap_or_else(|_| "{}".to_string()), + thought_signature: None, + }, + index: None, + thought_signature: None, + }); + } + out +} + +/// Anthropic `system` (string or array of text blocks) → a leading system Message. +pub(super) fn system_message(req: &Value) -> Option { + let sys = req.get("system")?; + let text = if sys.is_string() { sys.as_str().unwrap_or("").to_string() } else { blocks_text(sys) }; + let text = text.trim(); + if text.is_empty() { + None + } else { + Some(Message::text(Role::System, text)) + } +} + +/// Anthropic `tools` → IR function tools. `input_schema` maps to the function `parameters`. +pub(super) fn tools(req: &Value) -> Option> { + let arr = req.get("tools").and_then(|v| v.as_array())?; + let mut out = vec![]; + for t in arr { + let name = t.get("name").and_then(|v| v.as_str())?; + let desc = t.get("description").and_then(|v| v.as_str()).map(|s| s.to_string()); + let params = t.get("input_schema").cloned().unwrap_or_else(|| json!({ "type": "object" })); + out.push(Tool::function(name, desc, params)); + } + if out.is_empty() { + None + } else { + Some(out) + } +} diff --git a/src-tauri/src/protocol/anthropic/decode.rs b/src-tauri/src/protocol/anthropic/decode.rs new file mode 100644 index 0000000..79a819c --- /dev/null +++ b/src-tauri/src/protocol/anthropic/decode.rs @@ -0,0 +1,90 @@ +// Anthropic Messages REQUEST json → llm-connector ChatRequest IR. + +use super::blocks::{blocks_text, image_blocks, system_message, tool_result_messages, tool_use_calls, tools}; +use llm_connector::types::{ChatRequest, Message, MessageBlock, Role}; +use serde_json::Value; + +/// Decode an Anthropic Messages REQUEST json into the llm-connector IR. `model` is left as the +/// request's model (gateway.rs already rewrote it to the provider's outgoing model before this). +pub fn decode_request(req: &Value) -> Result { + let model = req.get("model").and_then(|v| v.as_str()).unwrap_or("").to_string(); + let mut messages: Vec = vec![]; + + if let Some(sys) = system_message(req) { + messages.push(sys); + } + + let turns = req.get("messages").and_then(|v| v.as_array()).cloned().unwrap_or_default(); + for m in &turns { + let role = m.get("role").and_then(|v| v.as_str()).unwrap_or("user"); + let content = m.get("content").cloned().unwrap_or(Value::Null); + match role { + "assistant" => { + // assistant turn: text (+ optional thinking) + tool_use → tool_calls + let mut blocks: Vec = vec![]; + let text = blocks_text(&content); + if !text.is_empty() { + blocks.push(MessageBlock::text(text)); + } + let calls = tool_use_calls(&content); + let mut msg = Message::new(Role::Assistant, blocks); + if !calls.is_empty() { + msg.tool_calls = Some(calls); + } + messages.push(msg); + } + _ => { + // user turn: tool_result blocks split off into their own tool messages FIRST + // (they answer the prior assistant tool_calls), then any remaining text/images. + for tm in tool_result_messages(&content) { + messages.push(tm); + } + let mut blocks: Vec = vec![]; + let text = blocks_text(&content); + if !text.is_empty() { + blocks.push(MessageBlock::text(text)); + } + blocks.extend(image_blocks(&content)); + if !blocks.is_empty() { + messages.push(Message::new(Role::User, blocks)); + } + } + } + } + + let mut cr = ChatRequest::new(model).with_messages(messages); + if let Some(mt) = req.get("max_tokens").and_then(|v| v.as_u64()) { + cr = cr.with_max_tokens(mt as u32); + } + if let Some(t) = req.get("temperature").and_then(|v| v.as_f64()) { + cr = cr.with_temperature(t as f32); + } + if let Some(p) = req.get("top_p").and_then(|v| v.as_f64()) { + cr = cr.with_top_p(p as f32); + } + if req.get("stream").and_then(|v| v.as_bool()).unwrap_or(false) { + cr = cr.with_stream(true); + } + if let Some(stop) = req.get("stop_sequences").and_then(|v| v.as_array()) { + let v: Vec = stop.iter().filter_map(|s| s.as_str().map(|x| x.to_string())).collect(); + if !v.is_empty() { + cr = cr.with_stop(v); + } + } + if let Some(ts) = tools(req) { + cr = cr.with_tools(ts); + } + // Anthropic extended thinking → IR thinking budget (+ enable). Downstream OpenAI-chat drops it; + // Responses maps the budget to reasoning.effort (handled in the responses codec). + if let Some(th) = req.get("thinking") { + let enabled = th.get("type").and_then(|v| v.as_str()) == Some("enabled"); + if enabled { + cr = cr.with_enable_thinking(true); + if let Some(b) = th.get("budget_tokens").and_then(|v| v.as_u64()) { + cr = cr.with_thinking_budget(b as u32); + } + } + } + + Ok(cr) +} diff --git a/src-tauri/src/protocol/anthropic/encode.rs b/src-tauri/src/protocol/anthropic/encode.rs new file mode 100644 index 0000000..b4814a2 --- /dev/null +++ b/src-tauri/src/protocol/anthropic/encode.rs @@ -0,0 +1,144 @@ +// llm-connector ChatResponse IR → Anthropic Messages RESPONSE json, buffered and as SSE. + +use llm_connector::types::ChatResponse; +use serde_json::{json, Value}; + +/// Map an OpenAI/IR finish_reason to an Anthropic stop_reason. +fn stop_reason(finish: Option<&str>, had_tool_calls: bool) -> &'static str { + match finish { + Some("length") => "max_tokens", + Some("tool_calls") | Some("function_call") => "tool_use", + Some("content_filter") => "end_turn", + _ if had_tool_calls => "tool_use", + _ => "end_turn", + } +} + +/// Encode the IR response back into an Anthropic Messages RESPONSE json. `client_model` is the name +/// the client asked for (so Claude Code sees its own model, not the upstream's). +pub fn encode_response(resp: &ChatResponse, client_model: &str) -> Value { + let choice = resp.choices.first(); + let msg = choice.map(|c| &c.message); + + let mut content: Vec = vec![]; + // assistant thinking (if the provider surfaced reasoning) → an Anthropic thinking block first. + if let Some(m) = msg { + if let Some(reasoning) = m.reasoning_any() { + if !reasoning.trim().is_empty() { + content.push(json!({ "type": "thinking", "thinking": reasoning })); + } + } + } + // assistant text. The crate parks text in choices[].message.content normally, but when a turn + // ALSO has tool_calls it keeps the text only in the top-level ChatResponse.content — so fall + // back to that (else assistant prose is dropped whenever a tool is called in the same turn). + let text = { + let t = msg.map(|m| m.content_as_text()).unwrap_or_default(); + if t.is_empty() { resp.content.clone() } else { t } + }; + if !text.is_empty() { + content.push(json!({ "type": "text", "text": text })); + } + // tool calls → tool_use blocks + let mut had_tool_calls = false; + if let Some(m) = msg { + if let Some(calls) = &m.tool_calls { + for tc in calls { + had_tool_calls = true; + let input: Value = tc.arguments_value().unwrap_or_else(|_| json!({})); + content.push(json!({ + "type": "tool_use", + "id": if tc.id.is_empty() { format!("toolu_{}", content.len()) } else { tc.id.clone() }, + "name": tc.function.name, + "input": input, + })); + } + } + } + if content.is_empty() { + content.push(json!({ "type": "text", "text": "" })); + } + + let finish = choice.and_then(|c| c.finish_reason.as_deref()); + let usage = resp.usage.as_ref(); + let input_tokens = usage.map(|u| u.prompt_tokens).unwrap_or(0); + let output_tokens = usage.map(|u| u.completion_tokens).unwrap_or(0); + + json!({ + // never a constant fallback — clients persist this id and usage de-dupes by it + "id": if resp.id.is_empty() { crate::protocol::uid("msg_ccbud") } else { resp.id.clone() }, + "type": "message", + "role": "assistant", + "model": client_model, + "content": content, + "stop_reason": stop_reason(finish, had_tool_calls), + "stop_sequence": Value::Null, + "usage": { "input_tokens": input_tokens, "output_tokens": output_tokens }, + }) +} + +/// Synthesize a complete Anthropic Messages SSE event sequence from a finished IR response. Used +/// when the client (Claude Code) asked to stream but the upstream was translated buffered — the +/// client still gets a valid, ordered `message_start → content_block_* → message_delta → +/// message_stop` stream, just delivered at once. True token-by-token transcoding is P2. +pub fn encode_response_sse(resp: &ChatResponse, client_model: &str) -> String { + let full = encode_response(resp, client_model); + let content = full.get("content").and_then(|v| v.as_array()).cloned().unwrap_or_default(); + let stop_reason = full.get("stop_reason").cloned().unwrap_or(json!("end_turn")); + let usage = full.get("usage").cloned().unwrap_or(json!({ "input_tokens": 0, "output_tokens": 0 })); + let id = full.get("id").cloned().unwrap_or(json!("msg_ccbud")); + let input_tokens = usage.get("input_tokens").cloned().unwrap_or(json!(0)); + let output_tokens = usage.get("output_tokens").cloned().unwrap_or(json!(0)); + + let ev = |event: &str, data: Value| { + format!("event: {}\ndata: {}\n\n", event, serde_json::to_string(&data).unwrap_or_default()) + }; + let mut out = String::new(); + + // message_start (usage input tokens known up front; output filled at message_delta) + out.push_str(&ev( + "message_start", + json!({ "type": "message_start", "message": { + "id": id, "type": "message", "role": "assistant", "model": client_model, + "content": [], "stop_reason": Value::Null, "stop_sequence": Value::Null, + "usage": { "input_tokens": input_tokens, "output_tokens": 0 }, + }}), + )); + + for (i, block) in content.iter().enumerate() { + let bt = block.get("type").and_then(|v| v.as_str()).unwrap_or("text"); + match bt { + "text" => { + let text = block.get("text").and_then(|v| v.as_str()).unwrap_or(""); + out.push_str(&ev("content_block_start", json!({ "type": "content_block_start", "index": i, "content_block": { "type": "text", "text": "" } }))); + if !text.is_empty() { + out.push_str(&ev("content_block_delta", json!({ "type": "content_block_delta", "index": i, "delta": { "type": "text_delta", "text": text } }))); + } + out.push_str(&ev("content_block_stop", json!({ "type": "content_block_stop", "index": i }))); + } + "thinking" => { + let think = block.get("thinking").and_then(|v| v.as_str()).unwrap_or(""); + out.push_str(&ev("content_block_start", json!({ "type": "content_block_start", "index": i, "content_block": { "type": "thinking", "thinking": "" } }))); + if !think.is_empty() { + out.push_str(&ev("content_block_delta", json!({ "type": "content_block_delta", "index": i, "delta": { "type": "thinking_delta", "thinking": think } }))); + } + out.push_str(&ev("content_block_stop", json!({ "type": "content_block_stop", "index": i }))); + } + "tool_use" => { + let empty = json!({}); + let input = block.get("input").unwrap_or(&empty); + out.push_str(&ev("content_block_start", json!({ "type": "content_block_start", "index": i, "content_block": { "type": "tool_use", "id": block.get("id").cloned().unwrap_or(json!("")), "name": block.get("name").cloned().unwrap_or(json!("")), "input": {} } }))); + out.push_str(&ev("content_block_delta", json!({ "type": "content_block_delta", "index": i, "delta": { "type": "input_json_delta", "partial_json": serde_json::to_string(input).unwrap_or_else(|_| "{}".to_string()) } }))); + out.push_str(&ev("content_block_stop", json!({ "type": "content_block_stop", "index": i }))); + } + _ => {} + } + } + + out.push_str(&ev( + "message_delta", + json!({ "type": "message_delta", "delta": { "stop_reason": stop_reason, "stop_sequence": Value::Null }, "usage": { "output_tokens": output_tokens } }), + )); + out.push_str(&ev("message_stop", json!({ "type": "message_stop" }))); + out +} diff --git a/src-tauri/src/protocol/anthropic/mod.rs b/src-tauri/src/protocol/anthropic/mod.rs new file mode 100644 index 0000000..50d78e5 --- /dev/null +++ b/src-tauri/src/protocol/anthropic/mod.rs @@ -0,0 +1,22 @@ +// The "Anthropic server-side" halves that llm-connector (a client library) doesn't provide: +// - decode_request: Anthropic Messages REQUEST json → llm-connector ChatRequest IR +// - encode_response: llm-connector ChatResponse IR → Anthropic Messages RESPONSE json +// +// Mapping follows the same shape LiteLLM / musistudio use: Anthropic content blocks are flattened +// into the OpenAI-style IR — `tool_use` blocks become assistant `Message.tool_calls`, `tool_result` +// blocks become separate `role:tool` messages, `system` becomes a leading system message. The IR is +// then encoded to OpenAI Chat (or Responses) by the crate. The reverse rebuilds Anthropic content +// blocks from the IR's tool_calls + text. +// +// Claude Code footguns handled explicitly (LiteLLM shipped bugs on these): user/system content +// blocks arrive as `{"type":"input_text"}` (not `text`) and MUST be recognized, else content is +// silently dropped → upstream 422. + +mod blocks; +mod decode; +mod encode; +#[cfg(test)] +mod tests; + +pub use decode::decode_request; +pub use encode::{encode_response, encode_response_sse}; diff --git a/src-tauri/src/protocol/anthropic/tests.rs b/src-tauri/src/protocol/anthropic/tests.rs new file mode 100644 index 0000000..c979d8f --- /dev/null +++ b/src-tauri/src/protocol/anthropic/tests.rs @@ -0,0 +1,97 @@ +use super::decode::decode_request; +use super::encode::encode_response; +use serde_json::{json, Value}; +use llm_connector::core::Protocol; +use llm_connector::protocols::adapters::openai::OpenAIProtocol; + +// A representative Claude Code request: system + a user prose turn (input_text blocks), an +// assistant tool_use, and the user's tool_result — the shape the messages→chat path must map. +fn claude_request() -> Value { + json!({ + "model": "claude-sonnet-4-6", + "max_tokens": 1024, + "system": "You are a helpful coding assistant.", + "tools": [{ "name": "read_file", "description": "Read a file", + "input_schema": { "type": "object", "properties": { "path": { "type": "string" } } } }], + "messages": [ + { "role": "user", "content": [{ "type": "input_text", "text": "read a.txt" }] }, + { "role": "assistant", "content": [ + { "type": "text", "text": "Reading it." }, + { "type": "tool_use", "id": "toolu_1", "name": "read_file", "input": { "path": "a.txt" } } + ] }, + { "role": "user", "content": [ + { "type": "tool_result", "tool_use_id": "toolu_1", "content": "hello world" } + ] } + ] + }) +} + +#[test] +fn decodes_anthropic_request_to_openai_chat_body() { + let ir = decode_request(&claude_request()).unwrap(); + // system prepended, tool_result split into its own tool message, ordering preserved. + let roles: Vec<_> = ir.messages.iter().map(|m| format!("{:?}", m.role)).collect(); + assert_eq!(roles, vec!["System", "User", "Assistant", "Tool"]); + // input_text was recognized (not dropped → this is the Claude Code footgun). + assert_eq!(ir.messages[1].content_as_text(), "read a.txt"); + // assistant tool_use → tool_calls + let calls = ir.messages[2].tool_calls.as_ref().unwrap(); + assert_eq!(calls[0].function.name, "read_file"); + assert!(calls[0].function.arguments.contains("a.txt")); + // tool_result → tool message carrying the id + output + assert_eq!(ir.messages[3].tool_call_id.as_deref(), Some("toolu_1")); + assert_eq!(ir.messages[3].content_as_text(), "hello world"); + // tools carried through + assert_eq!(ir.tools.as_ref().unwrap()[0].function.name, "read_file"); + + // The crate encodes the IR to a real OpenAI Chat body — proves the reused half works. + let body = OpenAIProtocol::new("k").build_chat_request_body(&ir).unwrap(); + let msgs = body.get("messages").and_then(|v| v.as_array()).unwrap(); + assert_eq!(msgs[0]["role"], "system"); + assert!(body.get("tools").is_some()); +} + +#[test] +fn encodes_openai_chat_response_to_anthropic() { + // A real OpenAI Chat response with a tool call, decoded by the crate → IR → Anthropic. + let openai = r#"{ + "id":"chatcmpl-1","object":"chat.completion","created":1,"model":"gpt-4o", + "choices":[{"index":0,"finish_reason":"tool_calls","message":{ + "role":"assistant","content":"Sure.", + "tool_calls":[{"id":"call_9","type":"function", + "function":{"name":"read_file","arguments":"{\"path\":\"a.txt\"}"}}]}}], + "usage":{"prompt_tokens":11,"completion_tokens":7,"total_tokens":18} + }"#; + let ir = OpenAIProtocol::new("k").parse_response(openai).unwrap(); + let out = encode_response(&ir, "claude-sonnet-4-6"); + + assert_eq!(out["type"], "message"); + assert_eq!(out["role"], "assistant"); + assert_eq!(out["model"], "claude-sonnet-4-6"); // client-facing model, not gpt-4o + assert_eq!(out["stop_reason"], "tool_use"); + assert_eq!(out["usage"]["input_tokens"], 11); + assert_eq!(out["usage"]["output_tokens"], 7); + let content = out["content"].as_array().unwrap(); + assert!(content.iter().any(|b| b["type"] == "text" && b["text"] == "Sure.")); + let tu = content.iter().find(|b| b["type"] == "tool_use").unwrap(); + assert_eq!(tu["name"], "read_file"); + assert_eq!(tu["input"]["path"], "a.txt"); + assert_eq!(tu["id"], "call_9"); +} + +#[test] +fn plain_text_round_trip() { + let req = json!({ + "model": "claude-x", "max_tokens": 100, + "messages": [{ "role": "user", "content": "hi there" }] + }); + let ir = decode_request(&req).unwrap(); + assert_eq!(ir.messages.len(), 1); + assert_eq!(ir.messages[0].content_as_text(), "hi there"); + + let openai = r#"{"id":"c1","object":"chat.completion","created":1,"model":"gpt","choices":[{"index":0,"finish_reason":"stop","message":{"role":"assistant","content":"hello!"}}],"usage":{"prompt_tokens":2,"completion_tokens":1,"total_tokens":3}}"#; + let ir2 = OpenAIProtocol::new("k").parse_response(openai).unwrap(); + let out = encode_response(&ir2, "claude-x"); + assert_eq!(out["stop_reason"], "end_turn"); + assert_eq!(out["content"][0]["text"], "hello!"); +} diff --git a/src-tauri/src/protocol/codec.rs b/src-tauri/src/protocol/codec.rs new file mode 100644 index 0000000..ce0a7cf --- /dev/null +++ b/src-tauri/src/protocol/codec.rs @@ -0,0 +1,123 @@ +// The translation pipeline itself: decode(client wire) → IR → encode(provider wire) on the way +// out, and the mirror on the way back. The identity case (client and provider speak the same +// protocol) never enters here — the gateway keeps its verbatim passthrough fast path for it. + +use super::signatures::{ + ensure_chat_tool_call_reasoning_content, normalize_openai_request_thought_signatures, + normalize_openai_response_thought_signatures, +}; +use super::{anthropic, openai_chat_client, openai_responses, stream}; +use super::Wire; +use llm_connector::core::Protocol; +use llm_connector::protocols::adapters::anthropic::AnthropicProtocol; +use llm_connector::protocols::adapters::openai::OpenAIProtocol; +use llm_connector::types::{ChatRequest, ChatResponse}; +use serde_json::{json, Value}; + +/// Decode an inbound client request (in its wire format) into the unified IR. +pub fn decode_client_request(client: Wire, body: &Value) -> Result { + match client { + Wire::Anthropic => anthropic::decode_request(body), + Wire::OpenAiChat => openai_chat_client::decode_request(body), + // Hand-rolled (not the crate's responses_request_to_chat_request, which drops + // function_call / function_call_output / assistant items and rejects flattened tools — + // fatal for Codex). + Wire::OpenAiResponses => openai_responses::decode_request(body), + } +} + +/// Encode the IR into the upstream provider's request BODY. `outgoing_model` is the provider's real +/// model (gateway already resolved it); `stream` requests SSE from the upstream. For the first cut +/// we translate cross-protocol responses buffered, so callers pass stream=false here and synthesize +/// the client SSE from the full response (true incremental transcoding is P2). +pub fn encode_upstream_request( + provider: Wire, + ir: &ChatRequest, + outgoing_model: &str, + stream: bool, +) -> Result { + let mut ir = ir.clone(); + ir.model = outgoing_model.to_string(); + ir.stream = Some(stream); + match provider { + Wire::OpenAiChat => { + let mut body = OpenAIProtocol::new("") + .build_chat_request_body(&ir) + .map_err(|e| e.to_string())?; + let lower_model = outgoing_model.to_ascii_lowercase(); + if lower_model.contains("gemini") { + normalize_openai_request_thought_signatures(&mut body); + } + // GLM's OpenAI-compatible coding endpoint uses its native `thinking` switch rather + // than the OpenAI `reasoning_effort` field emitted by the generic connector. + if lower_model.contains("glm") || lower_model.contains("zhipu") || lower_model.contains("z-ai") { + if let Some(object) = body.as_object_mut() { + object.remove("reasoning_effort"); + } + if ir.enable_thinking == Some(true) { + body["thinking"] = json!({ "type": "enabled" }); + } + } + ensure_chat_tool_call_reasoning_content(&mut body); + Ok(body) + } + Wire::OpenAiResponses => Ok(openai_responses::encode_request(&ir, outgoing_model, stream)), + // Reverse direction: an OpenAI/Codex client → an Anthropic upstream. The crate encodes the + // IR into an Anthropic Messages request (tool_calls→tool_use blocks, etc.). Anthropic + // requires max_tokens; OpenAI-family clients (Codex) usually omit it and the crate's + // fallback (1024) truncates agent turns — default to a workable ceiling instead. + Wire::Anthropic => { + if ir.max_tokens.is_none() { + ir.max_tokens = Some(8192); + } + AnthropicProtocol::new("") + .build_chat_request_body(&ir) + .map_err(|e| e.to_string()) + } + } +} + +/// Decode an upstream provider RESPONSE (its wire format, buffered) into the IR. +pub fn decode_upstream_response(provider: Wire, text: &str) -> Result { + match provider { + Wire::OpenAiChat => { + let normalized = match serde_json::from_str::(text) { + Ok(mut body) => { + normalize_openai_response_thought_signatures(&mut body); + body.to_string() + } + Err(_) => text.to_string(), + }; + OpenAIProtocol::new("").parse_response(&normalized).map_err(|e| e.to_string()) + } + Wire::OpenAiResponses => openai_responses::decode_response(text), + Wire::Anthropic => AnthropicProtocol::new("").parse_response(text).map_err(|e| e.to_string()), + } +} + +/// Encode the IR response back to the client's wire format as a buffered JSON body. +pub fn encode_client_response(client: Wire, ir: &ChatResponse, client_model: &str) -> Result { + match client { + Wire::Anthropic => Ok(anthropic::encode_response(ir, client_model)), + Wire::OpenAiChat => Ok(openai_chat_client::encode_response(ir, client_model)), + // Hand-rolled (not the crate's chat_response_to_responses_response, which drops + // tool_calls from the output — Codex would never see a function call). + Wire::OpenAiResponses => Ok(openai_responses::encode_response(ir, client_model)), + } +} + +/// Whether we have an incremental (event-by-event) stream transcoder from `provider` to `client`. +/// When false, cross-protocol streaming falls back to buffer-upstream + synthesize-client-SSE. +pub fn can_transcode_stream(provider: Wire, client: Wire) -> bool { + stream::Transcoder::supports(provider, client) +} + +/// Encode the IR response to the client's wire format as a full SSE stream body (used when the +/// client asked to stream but we translated the upstream buffered — synthesize the event sequence). +pub fn encode_client_response_sse(client: Wire, ir: &ChatResponse, client_model: &str) -> Result { + match client { + Wire::Anthropic => Ok(anthropic::encode_response_sse(ir, client_model)), + Wire::OpenAiChat => Ok(openai_chat_client::encode_response_sse(ir, client_model)), + Wire::OpenAiResponses => Ok(openai_responses::encode_response_sse(ir, client_model)), + } +} diff --git a/src-tauri/src/protocol/codex_history.rs b/src-tauri/src/protocol/codex_history.rs deleted file mode 100644 index ac147e8..0000000 --- a/src-tauri/src/protocol/codex_history.rs +++ /dev/null @@ -1,2148 +0,0 @@ -//! Cross-request history for bridging Codex Responses requests to chat-style upstreams. -//! -//! Responses clients may continue a tool turn with only -//! `previous_response_id + new input`. Chat-style protocols do not implement that -//! server-side continuation, so they need the previous request input and assistant -//! output restored recursively into the next request. Tool outputs additionally need -//! the original assistant call, including its name, arguments, and reasoning metadata. -//! This store records that model-visible context and restores it before conversion. - -use serde_json::Value; -use std::collections::{HashMap, HashSet, VecDeque}; -use std::io::{self, Write}; -use tokio::sync::RwLock; - -const MAX_CACHED_RESPONSES: usize = 512; -// Count-bounding alone is not enough once every entry carries the cumulative transcript: a long -// conversation would otherwise make the cache grow quadratically. This is a logical serialized -// size ceiling (including the duplicated call lookup values), which keeps resident memory in the -// same order of magnitude while still leaving ample room for large model contexts. -const MAX_CACHED_HISTORY_BYTES: usize = 32 * 1024 * 1024; - -type ScopedResponseId = (String, String); -type ScopedCallId = (String, String); - -#[derive(Debug, Clone, Default, PartialEq, Eq)] -pub enum ResponseOrigin { - #[default] - Local, - Native(String), -} - -#[derive(Debug, Clone, Default, PartialEq, Eq)] -pub struct ResponseMetadata { - pub origin: ResponseOrigin, - pub materializable: bool, -} - -#[derive(Debug, Clone, Default, PartialEq, Eq)] -pub struct HistoryResolution { - pub changed: usize, - pub had_previous_response_id: bool, - pub previous_found: bool, - pub previous_materialized: bool, - pub previous_origin: Option, -} - -#[derive(Debug, Clone, Default)] -struct CachedResponse { - /// Full model-visible input used to create this response. For an incremental - /// `previous_response_id` request this already includes every earlier request and response. - request_input: Vec, - output: Vec, - calls_by_id: HashMap, - call_order: Vec, - serialized_bytes: usize, - origin: ResponseOrigin, - materializable: bool, -} - -#[derive(Debug, Default)] -struct HistoryInner { - responses: HashMap, - response_order: VecDeque, - /// Reverse index used only when `previous_response_id` is absent or stale. - /// A fallback is safe only when a call id resolves to exactly one response. - call_index: HashMap>, - cached_bytes: usize, -} - -#[derive(Debug, Clone, Default)] -struct CachedLookup { - previous: Option, - fallback: CachedResponse, -} - -/// Thread-safe, bounded Responses conversation-history store. -#[derive(Debug, Default)] -pub struct CodexHistoryStore { - inner: RwLock, -} - -impl CodexHistoryStore { - /// Record the full translated request input plus supported assistant-output items from a - /// resumable terminal Responses response (`completed` or `incomplete`). - /// - /// Returns the number of cached output items. Responses without an id are ignored; an otherwise - /// empty response is still retained so provider ownership remains known. - pub async fn record_response(&self, request: &Value, response: &Value) -> usize { - self.record_response_scoped("", request, response).await - } - - /// Scoped variant used by the gateway so response/call ids from different client sessions can - /// never satisfy one another while the same conversation can survive a provider switch. - pub async fn record_response_scoped( - &self, - scope: &str, - request: &Value, - response: &Value, - ) -> usize { - // Preserve the original store API for internal callers/tests that predate Responses - // terminal statuses. Gateway-owned/native recording uses the metadata variant below, - // which requires an explicit resumable terminal status. - let mut legacy_terminal; - let response = if response.get("status").is_none() { - legacy_terminal = response.clone(); - legacy_terminal["status"] = Value::String("completed".to_string()); - &legacy_terminal - } else { - response - }; - self.record_response_scoped_with_metadata( - scope, - ResponseOrigin::Local, - true, - request, - response, - ) - .await - } - - pub async fn record_response_scoped_with_metadata( - &self, - scope: &str, - origin: ResponseOrigin, - materializable: bool, - request: &Value, - response: &Value, - ) -> usize { - if response - .get("object") - .and_then(Value::as_str) - .is_some_and(|object| object != "response") - { - return 0; - } - if !matches!( - response.get("status").and_then(Value::as_str), - Some("completed" | "incomplete") - ) { - return 0; - } - - let Some(response_id) = response - .get("id") - .and_then(Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()) - else { - return 0; - }; - - let request_input = request_input_items(request); - let request_input_is_complete = request_input_is_materializable(request); - let (output, output_is_complete) = match response.get("output").and_then(Value::as_array) { - Some(items) => { - let output = items - .iter() - .filter_map(cached_output_item) - .collect::>(); - let output_is_complete = - output.len() == items.len() && items.iter().all(history_item_is_materializable); - (output, output_is_complete) - } - None => (Vec::new(), false), - }; - // Preserve ownership for a response containing an item this bridge cannot replay, but - // never advertise that partial transcript as safe to move to another provider. - let materializable = materializable && request_input_is_complete && output_is_complete; - - self.inner.write().await.insert_response_with_metadata( - scope, - response_id, - request_input, - output, - origin, - materializable, - ) - } - - pub async fn response_metadata( - &self, - scope: &str, - response_id: &str, - ) -> Option { - let response_id = response_id.trim(); - if response_id.is_empty() { - return None; - } - self.inner - .read() - .await - .responses - .get(&(scope.to_string(), response_id.to_string())) - .map(|response| ResponseMetadata { - origin: response.origin.clone(), - materializable: response.materializable, - }) - } - - /// Restore or enrich call items required by a follow-up Responses request. - /// - /// Missing calls are inserted immediately before the first matching output. - /// Parallel calls from the same response are restored as one ordered group. - /// Existing call items are enriched when fields such as `name`, `arguments`, - /// or `reasoning_content` are missing. - /// - /// The primary lookup uses `previous_response_id`. If that id is absent or - /// stale, a call-id fallback is used only when the caller supplied a safe scope and the call id - /// is unique inside that client session. Returns the number of restored or enriched items. - pub async fn enrich_request(&self, body: &mut Value) -> usize { - self.enrich_request_scoped("", false, body).await - } - - /// Scoped variant. Missing/stale-`previous_response_id` call-id recovery is allowed only when - /// the caller can provide a client-session scope; otherwise orphan validation must fail. - pub async fn enrich_request_scoped( - &self, - scope: &str, - allow_call_id_fallback: bool, - body: &mut Value, - ) -> usize { - self.resolve_request_scoped(scope, allow_call_id_fallback, false, body) - .await - .changed - } - - pub async fn materialize_request_scoped( - &self, - scope: &str, - allow_call_id_fallback: bool, - body: &mut Value, - ) -> HistoryResolution { - self.resolve_request_scoped(scope, allow_call_id_fallback, true, body) - .await - } - - async fn resolve_request_scoped( - &self, - scope: &str, - allow_call_id_fallback: bool, - strip_materialized_previous_id: bool, - body: &mut Value, - ) -> HistoryResolution { - let previous_response_id = body - .get("previous_response_id") - .and_then(Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(ToString::to_string); - let had_previous_response_id = previous_response_id.is_some(); - - let input_was_missing = body.get("input").is_none(); - let original_input = body.get_mut("input").map(std::mem::take); - let original_was_object = original_input.as_ref().is_some_and(Value::is_object); - let mut original_string = None; - let mut unsupported_input = None; - let items = match original_input { - Some(Value::Array(items)) => items, - Some(Value::Object(object)) => vec![Value::Object(object)], - Some(Value::String(value)) => { - original_string = Some(value.clone()); - vec![serde_json::json!({ - "type": "message", - "role": "user", - "content": value, - })] - } - Some(other) => { - unsupported_input = Some(other); - Vec::new() - } - None => Vec::new(), - }; - - let output_call_ids = items - .iter() - .filter(|item| { - item.get("type") - .and_then(Value::as_str) - .is_some_and(is_call_output_item_type) - }) - .filter_map(response_item_call_id) - .collect::>(); - let existing_call_ids = items - .iter() - .filter(|item| { - item.get("type") - .and_then(Value::as_str) - .is_some_and(is_call_item_type) - }) - .filter_map(response_item_call_id) - .collect::>(); - let requested_call_ids = output_call_ids - .union(&existing_call_ids) - .cloned() - .collect::>(); - - let lookup = self - .lookup( - scope, - previous_response_id.as_deref(), - &requested_call_ids, - allow_call_id_fallback, - ) - .await; - let previous_found = lookup.previous.is_some(); - let previous_origin = lookup - .previous - .as_ref() - .map(|response| response.origin.clone()); - let previous_materialized = lookup - .previous - .as_ref() - .is_some_and(|response| response.materializable); - - if let Some(original_input) = unsupported_input { - if let Some(object) = body.as_object_mut() { - object.insert("input".to_string(), original_input); - } - return HistoryResolution { - changed: 0, - had_previous_response_id, - previous_found, - previous_materialized: false, - previous_origin, - }; - } - - // A native provider may still own a continuation that the gateway observed only after a - // restart. Keep that request byte-for-byte intact for same-provider passthrough; callers - // must reject it before cross-wire/provider-switch forwarding because its prefix is absent. - if previous_found && !previous_materialized { - if !input_was_missing { - let restored_input = if original_string.is_some() && items.len() == 1 { - Value::String(original_string.unwrap_or_default()) - } else if original_was_object && items.len() == 1 { - items.into_iter().next().unwrap_or(Value::Null) - } else { - Value::Array(items) - }; - if let Some(object) = body.as_object_mut() { - object.insert("input".to_string(), restored_input); - } - } - return HistoryResolution { - changed: 0, - had_previous_response_id, - previous_found, - previous_materialized, - previous_origin, - }; - } - let replay_context = lookup - .previous - .as_ref() - .or_else(|| lookup.fallback.materializable.then_some(&lookup.fallback)); - let (items, restored) = merge_previous_context(items, replay_context); - let mut enriched = 0usize; - let mut new_items = Vec::with_capacity(items.len()); - - for mut item in items { - if item - .get("type") - .and_then(Value::as_str) - .is_some_and(is_call_item_type) - { - if let Some(call_id) = response_item_call_id(&item) { - if let Some(cached) = lookup.call(&call_id) { - if enrich_call_item_from_cache(&mut item, cached) { - enriched += 1; - } - } - } - } - new_items.push(item); - } - - let changed = restored + enriched; - let resolved_input = if changed == 0 && original_string.is_some() && new_items.len() == 1 { - Some(Value::String(original_string.unwrap_or_default())) - } else if changed == 0 && original_was_object && new_items.len() == 1 { - Some(new_items.into_iter().next().unwrap_or(Value::Null)) - } else if input_was_missing && changed == 0 { - None - } else { - Some(Value::Array(new_items)) - }; - if let (Some(object), Some(resolved_input)) = (body.as_object_mut(), resolved_input) { - object.insert("input".to_string(), resolved_input); - } - if strip_materialized_previous_id && previous_materialized { - if let Some(object) = body.as_object_mut() { - object.remove("previous_response_id"); - } - } - HistoryResolution { - changed, - had_previous_response_id, - previous_found, - previous_materialized, - previous_origin, - } - } - - async fn lookup( - &self, - scope: &str, - previous_response_id: Option<&str>, - requested_call_ids: &HashSet, - allow_call_id_fallback: bool, - ) -> CachedLookup { - let inner = self.inner.read().await; - let previous = previous_response_id.and_then(|id| { - inner - .responses - .get(&(scope.to_string(), id.to_string())) - .cloned() - }); - let fallback = if allow_call_id_fallback { - inner.unique_fallback_response(scope, requested_call_ids, previous.as_ref()) - } else { - CachedResponse::default() - }; - CachedLookup { previous, fallback } - } -} - -impl HistoryInner { - fn insert_response( - &mut self, - scope: &str, - response_id: &str, - request_input: Vec, - output: Vec, - ) -> usize { - self.insert_response_with_metadata( - scope, - response_id, - request_input, - output, - ResponseOrigin::Local, - true, - ) - } - - fn insert_response_with_metadata( - &mut self, - scope: &str, - response_id: &str, - request_input: Vec, - output: Vec, - origin: ResponseOrigin, - materializable: bool, - ) -> usize { - self.insert_response_with_metadata_and_limits( - scope, - response_id, - request_input, - output, - origin, - materializable, - MAX_CACHED_RESPONSES, - MAX_CACHED_HISTORY_BYTES, - ) - } - - fn insert_response_with_limits( - &mut self, - scope: &str, - response_id: &str, - request_input: Vec, - output: Vec, - max_responses: usize, - max_bytes: usize, - ) -> usize { - self.insert_response_with_metadata_and_limits( - scope, - response_id, - request_input, - output, - ResponseOrigin::Local, - true, - max_responses, - max_bytes, - ) - } - - #[allow(clippy::too_many_arguments)] - fn insert_response_with_metadata_and_limits( - &mut self, - scope: &str, - response_id: &str, - request_input: Vec, - output: Vec, - origin: ResponseOrigin, - materializable: bool, - max_responses: usize, - max_bytes: usize, - ) -> usize { - let mut cached_response = CachedResponse { - request_input, - output, - origin, - materializable, - ..CachedResponse::default() - }; - for item in &cached_response.output { - if let Some((call_id, item)) = cached_call_item(item) { - if !cached_response.calls_by_id.contains_key(&call_id) { - cached_response.call_order.push(call_id.clone()); - } - cached_response.calls_by_id.insert(call_id, item); - } - } - cached_response.serialized_bytes = - cached_response_size(scope, response_id, &cached_response); - let cached_count = cached_response.output.len(); - let response_key = (scope.to_string(), response_id.to_string()); - - // Never let one pathological request flush every useful older entry before being evicted - // itself. A same-id response is authoritative, though, so an oversized replacement drops - // only its stale predecessor rather than leaving old history addressable under that id. - if cached_response.serialized_bytes > max_bytes { - if self.remove_response(&response_key) { - self.response_order - .retain(|cached_id| cached_id != &response_key); - } - return 0; - } - - let replacing = self.responses.contains_key(&response_key); - if !replacing { - self.response_order.push_back(response_key.clone()); - } - - // A completed response is authoritative. Replacing an already-seen id keeps - // retry/replay recording idempotent and prevents stale call-index entries. - self.remove_response(&response_key); - - for call_id in &cached_response.call_order { - self.index_call(scope, &call_id, &response_key); - } - self.cached_bytes = self - .cached_bytes - .checked_add(cached_response.serialized_bytes) - .unwrap_or(usize::MAX); - self.responses.insert(response_key.clone(), cached_response); - self.prune_to_limits(max_responses, max_bytes); - if self.responses.contains_key(&response_key) { - cached_count - } else { - 0 - } - } - - fn prune_to_limits(&mut self, max_responses: usize, max_bytes: usize) { - while self.response_order.len() > max_responses || self.cached_bytes > max_bytes { - let Some(response_id) = self.response_order.pop_front() else { - break; - }; - self.remove_response(&response_id); - } - } - - fn remove_response(&mut self, response_id: &ScopedResponseId) -> bool { - self.remove_response_from_call_index(response_id); - let Some(response) = self.responses.remove(response_id) else { - return false; - }; - self.cached_bytes = self.cached_bytes.saturating_sub(response.serialized_bytes); - true - } - - fn index_call(&mut self, scope: &str, call_id: &str, response_id: &ScopedResponseId) { - let response_ids = self - .call_index - .entry((scope.to_string(), call_id.to_string())) - .or_default(); - if !response_ids - .iter() - .any(|cached_id| cached_id == response_id) - { - response_ids.push_back(response_id.clone()); - } - } - - fn remove_response_from_call_index(&mut self, response_id: &ScopedResponseId) { - for response_ids in self.call_index.values_mut() { - response_ids.retain(|cached_id| cached_id != response_id); - } - self.call_index - .retain(|_, response_ids| !response_ids.is_empty()); - } - - fn unique_fallback_response( - &self, - scope: &str, - requested_call_ids: &HashSet, - previous: Option<&CachedResponse>, - ) -> CachedResponse { - // A resolved previous_response_id is authoritative. Grafting calls from another cached - // branch onto it would create history that no provider ever observed. - if previous.is_some() || requested_call_ids.is_empty() { - return CachedResponse::default(); - } - - let mut source_response_id: Option = None; - for call_id in requested_call_ids { - let Some(response_id) = self.unique_response_for_call(scope, call_id) else { - return CachedResponse::default(); - }; - if source_response_id - .as_ref() - .is_some_and(|source| source != &response_id) - { - return CachedResponse::default(); - } - source_response_id = Some(response_id); - } - - source_response_id - .and_then(|response_id| self.responses.get(&response_id).cloned()) - .unwrap_or_default() - } - - fn unique_response_for_call(&self, scope: &str, call_id: &str) -> Option { - let response_ids = self - .call_index - .get(&(scope.to_string(), call_id.to_string()))?; - let mut found: Option<&ScopedResponseId> = None; - for response_id in response_ids { - let Some(response) = self.responses.get(response_id) else { - continue; - }; - // An owner-only native response may contain just a delta after an unavailable prefix. - // Its calls are useful to that provider via previous_response_id, but are never a safe - // source for session fallback because doing so would manufacture a truncated chain. - if !response.materializable { - continue; - } - if !response.calls_by_id.contains_key(call_id) { - continue; - } - if found.is_some() { - return None; - } - found = Some(response_id); - } - found.cloned() - } - - fn unique_call(&self, scope: &str, call_id: &str) -> Option<&Value> { - let response_id = self.unique_response_for_call(scope, call_id)?; - self.responses - .get(&response_id) - .and_then(|response| response.calls_by_id.get(call_id)) - } -} - -#[derive(Default)] -struct ByteCounter { - bytes: usize, -} - -impl Write for ByteCounter { - fn write(&mut self, buf: &[u8]) -> io::Result { - self.bytes = self.bytes.saturating_add(buf.len()); - Ok(buf.len()) - } - - fn flush(&mut self) -> io::Result<()> { - Ok(()) - } -} - -fn serialized_size(value: &T) -> usize { - let mut counter = ByteCounter::default(); - if serde_json::to_writer(&mut counter, value).is_err() { - usize::MAX - } else { - counter.bytes - } -} - -fn cached_response_size(scope: &str, response_id: &str, response: &CachedResponse) -> usize { - let origin_bytes = match &response.origin { - ResponseOrigin::Local => 1, - ResponseOrigin::Native(provider_id) => 1usize.saturating_add(serialized_size(provider_id)), - }; - serialized_size(scope) - .saturating_add(serialized_size(response_id)) - .saturating_add(serialized_size(&response.request_input)) - .saturating_add(serialized_size(&response.output)) - .saturating_add(serialized_size(&response.calls_by_id)) - .saturating_add(serialized_size(&response.call_order)) - .saturating_add(origin_bytes) - .saturating_add(1) -} - -impl CachedLookup { - fn call(&self, call_id: &str) -> Option<&Value> { - self.previous - .as_ref() - .and_then(|previous| previous.calls_by_id.get(call_id)) - .or_else(|| self.fallback.calls_by_id.get(call_id)) - } -} - -/// Merge the directly referenced response's complete model-visible context into the new input. -/// -/// The cached request prefix is always restored before the previous response output. A client that -/// already sent the full prefix is detected by a matching request prefix plus at least one prior -/// output anchor, while a coincidentally repeated new user message is still treated as a delta. -/// Every supported previous output item is restored. Filtering unmatched calls would no longer be -/// equivalent to provider-side `previous_response_id` continuation and could silently turn an -/// invalid continuation into a different, truncated conversation. -fn merge_previous_context( - items: Vec, - previous: Option<&CachedResponse>, -) -> (Vec, usize) { - let Some(previous) = previous else { - return (items, 0); - }; - - let eligible_output = previous.output.clone(); - let request_prefix_len = previous.request_input.len(); - let has_request_prefix = request_prefix_len <= items.len() - && previous - .request_input - .iter() - .zip(&items) - .all(|(cached, input)| cached_item_matches_input(cached, input)); - let has_output_anchor = has_request_prefix - && !previous.output.is_empty() - && previous.output.iter().any(|cached| { - items[request_prefix_len..] - .iter() - .any(|input| cached_item_matches_input(cached, input)) - }); - - let (prefix, tail, restored_input) = if has_request_prefix && has_output_anchor { - ( - items[..request_prefix_len].to_vec(), - items[request_prefix_len..].to_vec(), - 0, - ) - } else { - ( - previous.request_input.clone(), - items, - previous.request_input.len(), - ) - }; - let (tail, restored_output) = merge_cached_output(tail, &eligible_output); - let mut merged = Vec::with_capacity(prefix.len() + tail.len()); - merged.extend(prefix); - merged.extend(tail); - (merged, restored_input + restored_output) -} - -fn merge_cached_output(items: Vec, eligible: &[Value]) -> (Vec, usize) { - if eligible.is_empty() { - return (items, 0); - } - - // Match explicit prior-output items monotonically. Legal explicit history keeps - // response order, and monotonic matching avoids treating a coincidentally reused - // text value later in the request as the prior item. - let mut matches = HashMap::::new(); - let mut next_input = 0usize; - for (cached_index, cached) in eligible.iter().enumerate() { - let Some(relative_index) = items[next_input..] - .iter() - .position(|item| cached_item_matches_input(cached, item)) - else { - continue; - }; - let input_index = next_input + relative_index; - matches.insert(input_index, cached_index); - next_input = input_index + 1; - } - - if matches.is_empty() { - let restored = eligible.len(); - let mut merged = Vec::with_capacity(restored + items.len()); - merged.extend(eligible.iter().cloned()); - merged.extend(items); - return (merged, restored); - } - - let last_match = matches.keys().copied().max().unwrap_or(0); - let mut merged = Vec::with_capacity(eligible.len() + items.len()); - let mut cached_cursor = 0usize; - let mut restored = 0usize; - for (input_index, item) in items.into_iter().enumerate() { - if let Some(&cached_index) = matches.get(&input_index) { - while cached_cursor < cached_index { - merged.push(eligible[cached_cursor].clone()); - cached_cursor += 1; - restored += 1; - } - // The explicit item wins (and may intentionally contain richer content). - merged.push(item); - cached_cursor = cached_index + 1; - if input_index == last_match { - while cached_cursor < eligible.len() { - merged.push(eligible[cached_cursor].clone()); - cached_cursor += 1; - restored += 1; - } - } - } else { - merged.push(item); - } - } - (merged, restored) -} - -fn request_input_items(request: &Value) -> Vec { - match request.get("input") { - Some(Value::Array(items)) => items.clone(), - Some(Value::Object(object)) => vec![Value::Object(object.clone())], - Some(Value::String(value)) => vec![serde_json::json!({ - "type": "message", - "role": "user", - "content": value, - })], - _ => Vec::new(), - } -} - -fn request_input_is_materializable(request: &Value) -> bool { - match request.get("input") { - None | Some(Value::String(_)) => true, - Some(Value::Array(items)) => items.iter().all(history_item_is_materializable), - Some(item @ Value::Object(_)) => history_item_is_materializable(item), - _ => false, - } -} - -fn history_item_is_materializable(item: &Value) -> bool { - let Some(object) = item.as_object() else { - return false; - }; - let item_type = object - .get("type") - .and_then(Value::as_str) - .unwrap_or_else(|| { - if object.get("role").is_some() { - "message" - } else { - "" - } - }); - match item_type { - // Responses Lite carries request-scoped tool declarations as a developer input item. - // They are ordinary JSON tool definitions and can be replayed when CC Buddy has to - // materialize a previous response across providers. - "additional_tools" => object.get("tools").is_some_and(Value::is_array), - "message" => object - .get("content") - .map_or(true, history_content_is_materializable), - "reasoning" => { - let has_opaque_reasoning = object - .get("encrypted_content") - .is_some_and(|value| !is_empty_value(value)); - !has_opaque_reasoning - && object - .get("summary") - .map_or(true, history_content_is_materializable) - && object - .get("content") - .map_or(true, history_content_is_materializable) - } - item_type if is_call_item_type(item_type) || is_call_output_item_type(item_type) => true, - _ => false, - } -} - -fn history_content_is_materializable(content: &Value) -> bool { - match content { - Value::Null | Value::String(_) => true, - Value::Array(parts) => parts.iter().all(|part| { - let Some(part_type) = part.get("type").and_then(Value::as_str) else { - return false; - }; - match part_type { - "input_text" | "output_text" | "text" | "summary_text" => { - part.get("text").is_some_and(Value::is_string) - } - "input_image" => part - .get("image_url") - .and_then(|value| { - value - .as_str() - .or_else(|| value.get("url").and_then(Value::as_str)) - }) - .is_some(), - _ => false, - } - }), - _ => false, - } -} - -fn cached_item_matches_input(cached: &Value, input: &Value) -> bool { - let cached_type = cached.get("type").and_then(Value::as_str); - let input_type = input.get("type").and_then(Value::as_str); - if cached_type.is_some_and(is_call_item_type) { - return input_type.is_some_and(is_call_item_type) - && response_item_call_id(cached).is_some_and(|call_id| { - response_item_call_id(input).as_deref() == Some(call_id.as_str()) - }); - } - - if let Some(id) = cached - .get("id") - .and_then(Value::as_str) - .map(str::trim) - .filter(|id| !id.is_empty()) - { - return cached_type == input_type - && input - .get("id") - .and_then(Value::as_str) - .is_some_and(|input_id| input_id.trim() == id); - } - cached == input -} - -fn cached_call_item(item: &Value) -> Option<(String, Value)> { - if !item - .get("type") - .and_then(Value::as_str) - .is_some_and(is_call_item_type) - { - return None; - } - let call_id = response_item_call_id(item)?; - Some((call_id, item.clone())) -} - -fn cached_output_item(item: &Value) -> Option { - match item.get("type").and_then(Value::as_str) { - Some("reasoning") => Some(item.clone()), - Some("message") - if item - .get("role") - .and_then(Value::as_str) - .map_or(true, |role| role == "assistant") => - { - Some(item.clone()) - } - Some(item_type) if is_call_item_type(item_type) => Some(item.clone()), - _ => None, - } -} - -fn response_item_call_id(item: &Value) -> Option { - item.get("call_id") - .or_else(|| item.get("id")) - .and_then(Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(ToString::to_string) -} - -fn is_call_item_type(item_type: &str) -> bool { - matches!( - item_type, - "function_call" | "custom_tool_call" | "tool_search_call" - ) -} - -fn is_call_output_item_type(item_type: &str) -> bool { - matches!( - item_type, - "function_call_output" | "custom_tool_call_output" | "tool_search_output" - ) -} - -fn is_empty_value(value: &Value) -> bool { - match value { - Value::Null => true, - Value::String(value) => value.trim().is_empty(), - Value::Array(value) => value.is_empty(), - Value::Object(value) => value.is_empty(), - _ => false, - } -} - -fn enrich_call_item_from_cache(item: &mut Value, cached: &Value) -> bool { - let mut changed = false; - for key in [ - "name", - "namespace", - "arguments", - "input", - "status", - "execution", - "reasoning_content", - "reasoning", - ] { - if item.get(key).is_some_and(|value| !is_empty_value(value)) { - continue; - } - let Some(value) = cached.get(key).filter(|value| !is_empty_value(value)) else { - continue; - }; - if let Some(object) = item.as_object_mut() { - object.insert(key.to_string(), value.clone()); - changed = true; - } - } - changed -} - -#[cfg(test)] -mod tests { - use super::*; - use serde_json::json; - use std::sync::Arc; - - #[test] - fn responses_lite_additional_tools_are_materializable() { - let request = json!({ - "input": [{ - "type": "additional_tools", - "role": "developer", - "tools": [ - { "type": "custom", "name": "exec" }, - { - "type": "namespace", - "name": "collaboration", - "tools": [{ "type": "function", "name": "spawn_agent" }] - } - ] - }] - }); - assert!(request_input_is_materializable(&request)); - } - - #[tokio::test] - async fn restores_call_before_output_from_previous_response() { - let history = CodexHistoryStore::default(); - assert_eq!( - history - .record_response( - &json!({ "input": [] }), - &json!({ - "id": "resp_1", - "output": [{ - "type": "function_call", - "call_id": "call_1", - "name": "read_file", - "arguments": "{\"path\":\"README.md\"}", - "reasoning_content": "Need to inspect the file." - }] - }) - ) - .await, - 1 - ); - - let mut request = json!({ - "previous_response_id": "resp_1", - "input": [{ - "type": "function_call_output", - "call_id": "call_1", - "output": "ok" - }] - }); - - assert_eq!(history.enrich_request(&mut request).await, 1); - let input = request["input"].as_array().unwrap(); - assert_eq!(input[0]["type"], "function_call"); - assert_eq!(input[0]["name"], "read_file"); - assert_eq!(input[0]["reasoning_content"], "Need to inspect the file."); - assert_eq!(input[1]["type"], "function_call_output"); - - // The restored item-level reasoning must survive the JSON → chat IR half, - // not merely remain present in the enriched request body. - let decoded = crate::protocol::openai_responses::decode_request(&request).unwrap(); - assert_eq!( - decoded.messages[0].reasoning_content.as_deref(), - Some("Need to inspect the file.") - ); - assert_eq!( - decoded.messages[0].tool_calls.as_ref().unwrap()[0].id, - "call_1" - ); - } - - #[tokio::test] - async fn restores_text_continuation_and_deduplicates_explicit_prior_output() { - let history = CodexHistoryStore::default(); - let reasoning = json!({ - "type":"reasoning", - "id":"rs_text", - "summary":[{"type":"summary_text","text":"continue the thought"}] - }); - let assistant = json!({ - "type":"message", - "id":"msg_text", - "role":"assistant", - "content":[{"type":"output_text","text":"First answer."}] - }); - assert_eq!( - history - .record_response( - &json!({ "input": [] }), - &json!({ - "id":"resp_text", - "output":[reasoning.clone(), assistant.clone()] - }) - ) - .await, - 2 - ); - - let mut continuation = json!({ - "previous_response_id":"resp_text", - "input":"Continue." - }); - assert_eq!(history.enrich_request(&mut continuation).await, 2); - let input = continuation["input"].as_array().unwrap(); - assert_eq!(input.len(), 3); - assert_eq!(input[0]["id"], "rs_text"); - assert_eq!(input[1]["id"], "msg_text"); - assert_eq!(input[2]["role"], "user"); - - let decoded = crate::protocol::openai_responses::decode_request(&continuation).unwrap(); - assert_eq!(decoded.messages.len(), 2); - assert_eq!(decoded.messages[0].content_as_text(), "First answer."); - assert_eq!( - decoded.messages[0].reasoning_content.as_deref(), - Some("continue the thought") - ); - assert_eq!(decoded.messages[1].content_as_text(), "Continue."); - - // A client may send explicit history even while retaining previous_response_id. - // Stable item ids anchor that history, so the cache must not duplicate it. - let mut explicit = json!({ - "previous_response_id":"resp_text", - "input":[ - reasoning, - assistant, - {"type":"message","role":"user","content":"Continue."} - ] - }); - assert_eq!(history.enrich_request(&mut explicit).await, 0); - assert_eq!(explicit["input"].as_array().unwrap().len(), 3); - } - - #[tokio::test] - async fn restores_request_and_response_context_across_multiple_hops() { - let history = CodexHistoryStore::default(); - let first_request = json!({ - "input":[{"type":"message","role":"user","content":"First question."}] - }); - let first_response = json!({ - "id":"resp_first", - "output":[{ - "type":"message","id":"msg_first","role":"assistant", - "content":[{"type":"output_text","text":"First answer."}] - }] - }); - assert_eq!( - history - .record_response(&first_request, &first_response) - .await, - 1 - ); - - let mut second_request = json!({ - "previous_response_id":"resp_first", - "input":[{"type":"message","role":"user","content":"Second question."}] - }); - assert_eq!(history.enrich_request(&mut second_request).await, 2); - let second_response = json!({ - "id":"resp_second", - "output":[{ - "type":"message","id":"msg_second","role":"assistant", - "content":[{"type":"output_text","text":"Second answer."}] - }] - }); - assert_eq!( - history - .record_response(&second_request, &second_response) - .await, - 1 - ); - - let mut third_request = json!({ - "previous_response_id":"resp_second", - "input":[{"type":"message","role":"user","content":"Third question."}] - }); - assert_eq!(history.enrich_request(&mut third_request).await, 4); - let decoded = crate::protocol::openai_responses::decode_request(&third_request).unwrap(); - let transcript = decoded - .messages - .iter() - .map(|message| message.content_as_text()) - .collect::>(); - assert_eq!( - transcript, - vec![ - "First question.", - "First answer.", - "Second question.", - "Second answer.", - "Third question.", - ] - ); - - // Full explicit history plus previous_response_id must remain idempotent. - let before = third_request.clone(); - assert_eq!(history.enrich_request(&mut third_request).await, 0); - assert_eq!(third_request, before); - } - - #[tokio::test] - async fn restores_parallel_calls_as_one_ordered_group() { - let history = CodexHistoryStore::default(); - history - .record_response( - &json!({ "input": [] }), - &json!({ - "id": "resp_parallel", - "output": [ - {"type":"function_call","call_id":"call_a","name":"first","arguments":"{}"}, - {"type":"function_call","call_id":"call_b","name":"second","arguments":"{}"} - ] - }), - ) - .await; - - // Outputs may arrive in a different order. The assistant call group must - // retain the order in which the response originally emitted the calls. - let mut request = json!({ - "previous_response_id": "resp_parallel", - "input": [ - {"type":"function_call_output","call_id":"call_b","output":"two"}, - {"type":"function_call_output","call_id":"call_a","output":"one"} - ] - }); - - assert_eq!(history.enrich_request(&mut request).await, 2); - let input = request["input"].as_array().unwrap(); - assert_eq!(input[0]["call_id"], "call_a"); - assert_eq!(input[1]["call_id"], "call_b"); - assert_eq!(input[2]["type"], "function_call_output"); - assert_eq!(input[3]["type"], "function_call_output"); - } - - #[tokio::test] - async fn same_client_session_recovers_across_provider_switches() { - let history = CodexHistoryStore::default(); - // The scope deliberately contains no provider identity: switching the active provider - // must not sever the client's previous_response_id chain. - let scope = "session-1"; - history - .record_response_scoped( - scope, - &json!({ "input": [] }), - &json!({ - "id": "resp_1", - "output": [{ - "type":"function_call", - "call_id":"unique_call", - "name":"lookup", - "arguments":"{}" - }] - }), - ) - .await; - - for previous in [None, Some("stale_response"), Some("resp_1")] { - let mut request = json!({ - "input": [{ - "type":"function_call_output", - "call_id":"unique_call", - "output":"ok" - }] - }); - if let Some(previous) = previous { - request["previous_response_id"] = json!(previous); - } - - assert_eq!( - history - .enrich_request_scoped(scope, true, &mut request) - .await, - 1 - ); - assert_eq!(request["input"][0]["type"], "function_call"); - assert_eq!(request["input"][0]["name"], "lookup"); - } - } - - #[tokio::test] - async fn missing_previous_response_fallback_requires_a_safe_scope() { - let history = CodexHistoryStore::default(); - history - .record_response( - &json!({ "input": [] }), - &json!({ - "id":"resp_1", - "output":[{ - "type":"function_call","call_id":"call_1", - "name":"lookup","arguments":"{}" - }] - }), - ) - .await; - let mut request = json!({ - "input":[{ - "type":"function_call_output","call_id":"call_1","output":"ok" - }] - }); - - assert_eq!(history.enrich_request(&mut request).await, 0); - assert!(crate::protocol::openai_responses::decode_request(&request).is_err()); - } - - #[tokio::test] - async fn call_id_fallback_never_crosses_client_session_scope() { - let history = CodexHistoryStore::default(); - history - .record_response_scoped( - "session-a", - &json!({"input":[]}), - &json!({ - "id":"resp_a", - "output":[{ - "type":"function_call","call_id":"call_a", - "name":"lookup","arguments":"{}" - }] - }), - ) - .await; - let mut request = json!({ - "previous_response_id":"stale", - "input":[{ - "type":"function_call_output","call_id":"call_a","output":"ok" - }] - }); - - assert_eq!( - history - .enrich_request_scoped("session-b", true, &mut request) - .await, - 0 - ); - assert!(crate::protocol::openai_responses::decode_request(&request).is_err()); - } - - #[tokio::test] - async fn ambiguous_call_id_does_not_use_fallback() { - let history = CodexHistoryStore::default(); - let scope = "session-1"; - for response_id in ["resp_1", "resp_2"] { - history - .record_response_scoped( - scope, - &json!({ "input": [] }), - &json!({ - "id": response_id, - "output": [{ - "type":"function_call", - "call_id":"shared_call", - "name":"lookup", - "arguments":"{}" - }] - }), - ) - .await; - } - - let mut request = json!({ - "input": [{ - "type":"function_call_output", - "call_id":"shared_call", - "output":"ok" - }] - }); - - assert_eq!( - history - .enrich_request_scoped(scope, true, &mut request) - .await, - 0 - ); - assert_eq!(request["input"].as_array().unwrap().len(), 1); - assert_eq!(request["input"][0]["type"], "function_call_output"); - let error = crate::protocol::openai_responses::decode_request(&request).unwrap_err(); - assert!(error.contains("shared_call")); - } - - #[tokio::test] - async fn enriches_existing_call_without_duplicating_it() { - let history = CodexHistoryStore::default(); - history - .record_response( - &json!({ "input": [] }), - &json!({ - "id": "resp_1", - "output": [{ - "type":"function_call", - "call_id":"call_1", - "name":"read_file", - "arguments":"{\"path\":\"README.md\"}", - "reasoning_content":"Need the file." - }] - }), - ) - .await; - - let mut request = json!({ - "previous_response_id":"resp_1", - "input":[ - {"type":"function_call","call_id":"call_1"}, - {"type":"function_call_output","call_id":"call_1","output":"ok"} - ] - }); - - assert_eq!(history.enrich_request(&mut request).await, 1); - let input = request["input"].as_array().unwrap(); - assert_eq!(input.len(), 2); - assert_eq!(input[0]["name"], "read_file"); - assert_eq!(input[0]["arguments"], "{\"path\":\"README.md\"}"); - assert_eq!(input[0]["reasoning_content"], "Need the file."); - } - - #[tokio::test] - async fn restores_custom_and_tool_search_calls() { - let history = CodexHistoryStore::default(); - assert_eq!( - history - .record_response( - &json!({ "input": [] }), - &json!({ - "id":"resp_tools", - "output":[ - { - "type":"custom_tool_call", - "call_id":"call_patch", - "name":"apply_patch", - "input":"*** Begin Patch\n*** End Patch" - }, - { - "type":"tool_search_call", - "call_id":"call_search", - "status":"completed", - "execution":"client", - "arguments":{"query":"mail tools"} - } - ] - }) - ) - .await, - 2 - ); - - let mut request = json!({ - "previous_response_id":"resp_tools", - "input":[ - {"type":"custom_tool_call_output","call_id":"call_patch","output":"patched"}, - {"type":"tool_search_output","call_id":"call_search","tools":[]} - ] - }); - - assert_eq!(history.enrich_request(&mut request).await, 2); - let input = request["input"].as_array().unwrap(); - assert_eq!(input[0]["type"], "custom_tool_call"); - assert_eq!(input[0]["input"], "*** Begin Patch\n*** End Patch"); - assert_eq!(input[1]["type"], "tool_search_call"); - assert_eq!(input[2]["type"], "custom_tool_call_output"); - assert_eq!(input[3]["type"], "tool_search_output"); - } - - #[tokio::test] - async fn preserves_scalar_and_single_object_input_when_no_change_is_needed() { - let history = CodexHistoryStore::default(); - let mut scalar_request = json!({"input":"hello"}); - assert_eq!(history.enrich_request(&mut scalar_request).await, 0); - assert_eq!(scalar_request["input"], "hello"); - - let mut request = json!({ - "input": {"type":"message","role":"user","content":"hello"} - }); - - assert_eq!(history.enrich_request(&mut request).await, 0); - assert!(request["input"].is_object()); - assert_eq!(request["input"]["content"], "hello"); - } - - #[tokio::test] - async fn concurrent_recording_is_safe_and_searchable() { - let history = Arc::new(CodexHistoryStore::default()); - let scope = "session-1"; - let mut tasks = Vec::new(); - for index in 0..16 { - let history = history.clone(); - tasks.push(tokio::spawn(async move { - history - .record_response_scoped( - scope, - &json!({ "input": [] }), - &json!({ - "id": format!("resp_{index}"), - "output": [{ - "type":"function_call", - "call_id":format!("call_{index}"), - "name":"work", - "arguments":"{}" - }] - }), - ) - .await - })); - } - for task in tasks { - assert_eq!(task.await.unwrap(), 1); - } - - let mut request = json!({ - "input":[{ - "type":"function_call_output", - "call_id":"call_9", - "output":"done" - }] - }); - assert_eq!( - history - .enrich_request_scoped(scope, true, &mut request) - .await, - 1 - ); - assert_eq!(request["input"][0]["call_id"], "call_9"); - assert_eq!(request["input"][0]["name"], "work"); - } - - #[test] - fn byte_budget_evicts_oldest_responses_and_cleans_call_index() { - let old_request = vec![json!({ - "type":"message","role":"user","content":"old request" - })]; - let old_output = vec![json!({ - "type":"function_call", - "call_id":"shared_call", - "name":"old_tool", - "arguments":"{\"value\":\"old\"}" - })]; - let new_request = vec![json!({ - "type":"message","role":"user","content":"new request" - })]; - let new_output = vec![json!({ - "type":"function_call", - "call_id":"shared_call", - "name":"new_tool", - "arguments":"{\"value\":\"new\"}" - })]; - let mut probe = HistoryInner::default(); - let scope = "session-1"; - let new_key = (scope.to_string(), "resp_new".to_string()); - let old_key = (scope.to_string(), "resp_old".to_string()); - probe.insert_response(scope, "resp_new", new_request.clone(), new_output.clone()); - let newest_size = probe.responses[&new_key].serialized_bytes; - - let mut inner = HistoryInner::default(); - assert_eq!( - inner.insert_response_with_limits( - scope, - "resp_old", - old_request, - old_output, - MAX_CACHED_RESPONSES, - usize::MAX, - ), - 1 - ); - assert_eq!( - inner.insert_response_with_limits( - scope, - "resp_new", - new_request, - new_output, - MAX_CACHED_RESPONSES, - newest_size, - ), - 1 - ); - - assert_eq!(inner.cached_bytes, newest_size); - assert!(!inner.responses.contains_key(&old_key)); - assert!(inner.responses.contains_key(&new_key)); - assert_eq!( - inner - .unique_call(scope, "shared_call") - .and_then(|item| item.get("name")) - .and_then(Value::as_str), - Some("new_tool") - ); - } - - #[test] - fn same_id_replacement_keeps_exact_accounting_and_no_stale_call_index() { - let mut inner = HistoryInner::default(); - let scope = "session-1"; - let response_key = (scope.to_string(), "resp_same".to_string()); - let call_key = (scope.to_string(), "call_new".to_string()); - assert_eq!( - inner.insert_response( - scope, - "resp_same", - vec![json!({"type":"message","role":"user","content":"short"})], - vec![json!({ - "type":"function_call","call_id":"call_old", - "name":"old_tool","arguments":"{}" - })], - ), - 1 - ); - assert_eq!( - inner.insert_response( - scope, - "resp_same", - vec![json!({ - "type":"message","role":"user", - "content":"a longer authoritative replacement" - })], - vec![json!({ - "type":"function_call","call_id":"call_new", - "name":"new_tool","arguments":"{\"ok\":true}" - })], - ), - 1 - ); - - let replacement_bytes = inner.responses[&response_key].serialized_bytes; - assert_eq!(inner.response_order.len(), 1); - assert_eq!(inner.cached_bytes, replacement_bytes); - assert!(inner.unique_call(scope, "call_old").is_none()); - assert_eq!( - inner - .unique_call(scope, "call_new") - .and_then(|item| item.get("name")) - .and_then(Value::as_str), - Some("new_tool") - ); - - // Replaying the same completed response must not duplicate order/index entries or bytes. - let request = inner.responses[&response_key].request_input.clone(); - let output = inner.responses[&response_key].output.clone(); - assert_eq!( - inner.insert_response(scope, "resp_same", request, output), - 1 - ); - assert_eq!(inner.response_order.len(), 1); - assert_eq!(inner.cached_bytes, replacement_bytes); - assert_eq!(inner.call_index[&call_key].len(), 1); - } - - #[test] - fn oversized_insert_preserves_unrelated_entries_and_drops_stale_replacement() { - let keep_request = vec![json!({ - "type":"message","role":"user","content":"keep" - })]; - let keep_output = vec![json!({ - "type":"function_call","call_id":"call_keep", - "name":"keep_tool","arguments":"{}" - })]; - let oversized_request = vec![json!({ - "type":"message","role":"user","content":"x".repeat(2048) - })]; - let oversized_output = vec![json!({ - "type":"function_call","call_id":"call_huge", - "name":"huge_tool","arguments":"y".repeat(2048) - })]; - - let scope = "session-1"; - let keep_key = (scope.to_string(), "resp_keep".to_string()); - let mut probe = HistoryInner::default(); - probe.insert_response( - scope, - "resp_keep", - keep_request.clone(), - keep_output.clone(), - ); - let budget = probe.responses[&keep_key].serialized_bytes; - - let mut inner = HistoryInner::default(); - assert_eq!( - inner.insert_response_with_limits( - scope, - "resp_keep", - keep_request, - keep_output, - MAX_CACHED_RESPONSES, - budget, - ), - 1 - ); - assert_eq!( - inner.insert_response_with_limits( - scope, - "resp_huge", - oversized_request.clone(), - oversized_output.clone(), - MAX_CACHED_RESPONSES, - budget, - ), - 0 - ); - assert!(inner.responses.contains_key(&keep_key)); - assert_eq!(inner.cached_bytes, budget); - assert!(inner.unique_call(scope, "call_huge").is_none()); - - assert_eq!( - inner.insert_response_with_limits( - scope, - "resp_keep", - oversized_request, - oversized_output, - MAX_CACHED_RESPONSES, - budget, - ), - 0 - ); - assert!(inner.responses.is_empty()); - assert!(inner.response_order.is_empty()); - assert!(inner.call_index.is_empty()); - assert_eq!(inner.cached_bytes, 0); - } - - #[tokio::test] - async fn native_history_materializes_across_provider_boundaries_and_strips_previous_id() { - let history = CodexHistoryStore::default(); - let scope = "session-native"; - history - .record_response_scoped_with_metadata( - scope, - ResponseOrigin::Native("provider-a".to_string()), - true, - &json!({ - "input":[{"type":"message","role":"user","content":"first"}] - }), - &json!({ - "id":"resp_native_a","status":"completed", - "output":[{ - "type":"message","id":"msg_native_a","role":"assistant", - "content":[{"type":"output_text","text":"answer"}] - }] - }), - ) - .await; - let mut next = json!({ - "previous_response_id":"resp_native_a", - "input":[{"type":"message","role":"user","content":"second"}] - }); - - let resolution = history - .materialize_request_scoped(scope, true, &mut next) - .await; - assert!(resolution.previous_found); - assert!(resolution.previous_materialized); - assert_eq!( - resolution.previous_origin, - Some(ResponseOrigin::Native("provider-a".to_string())) - ); - assert!(next.get("previous_response_id").is_none()); - let decoded = crate::protocol::openai_responses::decode_request(&next).unwrap(); - assert_eq!( - decoded - .messages - .iter() - .map(|message| message.content_as_text()) - .collect::>(), - vec!["first", "answer", "second"] - ); - } - - #[tokio::test] - async fn owner_only_native_history_is_reported_but_never_materialized() { - let history = CodexHistoryStore::default(); - let scope = "session-owner-only"; - history - .record_response_scoped_with_metadata( - scope, - ResponseOrigin::Native("provider-a".to_string()), - false, - &json!({ - "previous_response_id":"unknown-before-restart", - "input":[{"type":"message","role":"user","content":"delta"}] - }), - &json!({ - "id":"resp_owner_only","status":"completed", - "output":[{ - "type":"message","id":"msg_owner_only","role":"assistant", - "content":[{"type":"output_text","text":"answer"}] - }] - }), - ) - .await; - let mut next = json!({ - "previous_response_id":"resp_owner_only", - "input":[{"type":"message","role":"user","content":"next"}] - }); - let before = next.clone(); - - let resolution = history - .materialize_request_scoped(scope, true, &mut next) - .await; - assert!(resolution.previous_found); - assert!(!resolution.previous_materialized); - assert_eq!( - resolution.previous_origin, - Some(ResponseOrigin::Native("provider-a".to_string())) - ); - assert_eq!(next, before); - } - - #[tokio::test] - async fn incomplete_response_remains_resumable_history() { - let history = CodexHistoryStore::default(); - let scope = "session-incomplete"; - history - .record_response_scoped_with_metadata( - scope, - ResponseOrigin::Local, - true, - &json!({ - "input":[{"type":"message","role":"user","content":"write a lot"}] - }), - &json!({ - "id":"resp_incomplete","status":"incomplete", - "incomplete_details":{"reason":"max_output_tokens"}, - "output":[{ - "type":"message","id":"msg_partial","role":"assistant", - "content":[{"type":"output_text","text":"partial"}] - }] - }), - ) - .await; - let mut next = json!({ - "previous_response_id":"resp_incomplete", - "input":[{"type":"message","role":"user","content":"continue"}] - }); - - let resolution = history - .materialize_request_scoped(scope, true, &mut next) - .await; - assert!(resolution.previous_materialized); - assert!(next.get("previous_response_id").is_none()); - let decoded = crate::protocol::openai_responses::decode_request(&next).unwrap(); - assert_eq!( - decoded - .messages - .iter() - .map(|message| message.content_as_text()) - .collect::>(), - vec!["write a lot", "partial", "continue"] - ); - } - - #[tokio::test] - async fn metadata_recording_rejects_non_resumable_terminals() { - let history = CodexHistoryStore::default(); - let request = json!({ - "input":[{"type":"message","role":"user","content":"hello"}] - }); - for response in [ - json!({"id":"resp_failed","status":"failed","output":[]}), - json!({"id":"resp_partial","output":[]}), - json!({ - "id":"resp_compaction","object":"response.compaction","status":"completed", - "output":[{"type":"compaction","encrypted_content":"opaque"}] - }), - ] { - assert_eq!( - history - .record_response_scoped_with_metadata( - "session-terminal", - ResponseOrigin::Native("provider-a".to_string()), - true, - &request, - &response, - ) - .await, - 0 - ); - assert!(history - .response_metadata("session-terminal", response["id"].as_str().unwrap()) - .await - .is_none()); - } - } - - #[tokio::test] - async fn unsupported_output_and_owner_only_calls_never_become_portable_fallback() { - let history = CodexHistoryStore::default(); - let scope = "session-partial"; - history - .record_response_scoped_with_metadata( - scope, - ResponseOrigin::Native("provider-a".to_string()), - true, - &json!({"input":[{"type":"message","role":"user","content":"look"}]}), - &json!({ - "id":"resp_unsupported","object":"response","status":"completed", - "output":[{"type":"computer_call","id":"computer_1"}] - }), - ) - .await; - assert_eq!( - history - .response_metadata(scope, "resp_unsupported") - .await - .unwrap(), - ResponseMetadata { - origin: ResponseOrigin::Native("provider-a".to_string()), - materializable: false, - } - ); - - history - .record_response_scoped_with_metadata( - scope, - ResponseOrigin::Native("provider-a".to_string()), - false, - &json!({ - "previous_response_id":"missing-prefix", - "input":[{"type":"message","role":"user","content":"run"}] - }), - &json!({ - "id":"resp_owner_call","status":"completed", - "output":[{ - "type":"function_call","call_id":"call_owner_only", - "name":"shell","arguments":"{}" - }] - }), - ) - .await; - let mut fallback = json!({ - "input":[{ - "type":"function_call_output","call_id":"call_owner_only","output":"ok" - }] - }); - assert_eq!( - history - .enrich_request_scoped(scope, true, &mut fallback) - .await, - 0 - ); - assert_eq!(fallback["input"].as_array().unwrap().len(), 1); - - history - .record_response_scoped_with_metadata( - scope, - ResponseOrigin::Native("provider-a".to_string()), - true, - &json!({ - "input":[{"type":"compaction","encrypted_content":"opaque-prefix"}] - }), - &json!({ - "id":"resp_compacted_input","status":"completed", - "output":[{ - "type":"message","role":"assistant", - "content":[{"type":"output_text","text":"answer"}] - }] - }), - ) - .await; - assert!( - !history - .response_metadata(scope, "resp_compacted_input") - .await - .unwrap() - .materializable - ); - } - - #[tokio::test] - async fn empty_output_does_not_collapse_an_identical_follow_up_input() { - let history = CodexHistoryStore::default(); - history - .record_response_scoped_with_metadata( - "session-empty-output", - ResponseOrigin::Local, - true, - &json!({"input":"ping"}), - &json!({"id":"resp_empty","status":"completed","output":[]}), - ) - .await; - let mut next = json!({ - "previous_response_id":"resp_empty", - "input":"ping" - }); - - let resolution = history - .materialize_request_scoped("session-empty-output", true, &mut next) - .await; - assert_eq!(resolution.changed, 1); - assert!(next.get("previous_response_id").is_none()); - let input = next["input"].as_array().unwrap(); - assert_eq!(input.len(), 2); - assert_eq!(input[0]["content"], "ping"); - assert_eq!(input[1]["content"], "ping"); - } - - #[tokio::test] - async fn call_fallback_uses_one_complete_branch_and_never_grafts_onto_previous() { - let history = CodexHistoryStore::default(); - let scope = "session-fallback-branch"; - for (response_id, call_id, prompt) in [ - ("resp_a", "call_a", "branch a"), - ("resp_b", "call_b", "branch b"), - ] { - history - .record_response_scoped_with_metadata( - scope, - ResponseOrigin::Local, - true, - &json!({ - "input":[{"type":"message","role":"user","content":prompt}] - }), - &json!({ - "id":response_id,"status":"completed", - "output":[{ - "type":"function_call","call_id":call_id, - "name":"lookup","arguments":"{}" - }] - }), - ) - .await; - } - - let mut one_branch = json!({ - "input":[{ - "type":"function_call_output","call_id":"call_a","output":"a" - }] - }); - let resolution = history - .materialize_request_scoped(scope, true, &mut one_branch) - .await; - assert!(!resolution.had_previous_response_id); - assert_eq!(resolution.changed, 2); - assert_eq!(one_branch["input"][0]["content"], "branch a"); - assert_eq!(one_branch["input"][1]["call_id"], "call_a"); - assert_eq!(one_branch["input"][2]["call_id"], "call_a"); - history - .record_response_scoped_with_metadata( - scope, - ResponseOrigin::Local, - true, - &one_branch, - &json!({ - "id":"resp_after_fallback","status":"completed", - "output":[{ - "type":"message","role":"assistant", - "content":[{"type":"output_text","text":"done"}] - }] - }), - ) - .await; - let mut switched_provider = json!({ - "previous_response_id":"resp_after_fallback", - "input":"next" - }); - let switched = history - .materialize_request_scoped(scope, true, &mut switched_provider) - .await; - assert!(switched.previous_materialized); - assert!(switched_provider.get("previous_response_id").is_none()); - assert_eq!(switched_provider["input"][0]["content"], "branch a"); - - let mut mixed_branches = json!({ - "input":[ - {"type":"function_call_output","call_id":"call_a","output":"a"}, - {"type":"function_call_output","call_id":"call_b","output":"b"} - ] - }); - assert_eq!( - history - .enrich_request_scoped(scope, true, &mut mixed_branches) - .await, - 0 - ); - assert_eq!(mixed_branches["input"].as_array().unwrap().len(), 2); - - let mut unrelated_to_previous = json!({ - "previous_response_id":"resp_a", - "input":[{ - "type":"function_call_output","call_id":"call_b","output":"b" - }] - }); - history - .materialize_request_scoped(scope, true, &mut unrelated_to_previous) - .await; - let input = unrelated_to_previous["input"].as_array().unwrap(); - assert!(input.iter().any(|item| item["call_id"] == "call_a")); - assert!(!input - .iter() - .any(|item| { item["type"] == "function_call" && item["call_id"] == "call_b" })); - assert!(crate::protocol::openai_responses::decode_request(&unrelated_to_previous).is_err()); - } - - #[tokio::test] - async fn previous_response_is_resolved_and_materialized_without_new_input() { - let history = CodexHistoryStore::default(); - let scope = "session-no-input"; - history - .record_response_scoped_with_metadata( - scope, - ResponseOrigin::Native("provider-a".to_string()), - true, - &json!({ - "input":[{"type":"message","role":"user","content":"first"}] - }), - &json!({ - "id":"resp_no_input","status":"completed", - "output":[{ - "type":"message","id":"msg_no_input","role":"assistant", - "content":[{"type":"output_text","text":"answer"}] - }] - }), - ) - .await; - let mut next = json!({"previous_response_id":"resp_no_input"}); - - let resolution = history - .materialize_request_scoped(scope, true, &mut next) - .await; - assert!(resolution.had_previous_response_id); - assert!(resolution.previous_found); - assert!(resolution.previous_materialized); - assert_eq!( - resolution.previous_origin, - Some(ResponseOrigin::Native("provider-a".to_string())) - ); - assert!(next.get("previous_response_id").is_none()); - let input = next["input"].as_array().unwrap(); - assert_eq!(input.len(), 2); - assert_eq!(input[0]["content"], "first"); - assert_eq!(input[1]["content"][0]["text"], "answer"); - } -} diff --git a/src-tauri/src/protocol/codex_history/cache_items.rs b/src-tauri/src/protocol/codex_history/cache_items.rs new file mode 100644 index 0000000..8fec1ad --- /dev/null +++ b/src-tauri/src/protocol/codex_history/cache_items.rs @@ -0,0 +1,91 @@ +// Reading call / call-output items out of a cached response, and grafting cached metadata back +// onto a client-supplied call item. + +use serde_json::Value; + +pub(super) fn cached_call_item(item: &Value) -> Option<(String, Value)> { + if !item + .get("type") + .and_then(Value::as_str) + .is_some_and(is_call_item_type) + { + return None; + } + let call_id = response_item_call_id(item)?; + Some((call_id, item.clone())) +} + +pub(super) fn cached_output_item(item: &Value) -> Option { + match item.get("type").and_then(Value::as_str) { + Some("reasoning") => Some(item.clone()), + Some("message") + if item + .get("role") + .and_then(Value::as_str) + .map_or(true, |role| role == "assistant") => + { + Some(item.clone()) + } + Some(item_type) if is_call_item_type(item_type) => Some(item.clone()), + _ => None, + } +} + +pub(super) fn response_item_call_id(item: &Value) -> Option { + item.get("call_id") + .or_else(|| item.get("id")) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToString::to_string) +} + +pub(super) fn is_call_item_type(item_type: &str) -> bool { + matches!( + item_type, + "function_call" | "custom_tool_call" | "tool_search_call" + ) +} + +pub(super) fn is_call_output_item_type(item_type: &str) -> bool { + matches!( + item_type, + "function_call_output" | "custom_tool_call_output" | "tool_search_output" + ) +} + +pub(super) fn is_empty_value(value: &Value) -> bool { + match value { + Value::Null => true, + Value::String(value) => value.trim().is_empty(), + Value::Array(value) => value.is_empty(), + Value::Object(value) => value.is_empty(), + _ => false, + } +} + +pub(super) fn enrich_call_item_from_cache(item: &mut Value, cached: &Value) -> bool { + let mut changed = false; + for key in [ + "name", + "namespace", + "arguments", + "input", + "status", + "execution", + "reasoning_content", + "reasoning", + ] { + if item.get(key).is_some_and(|value| !is_empty_value(value)) { + continue; + } + let Some(value) = cached.get(key).filter(|value| !is_empty_value(value)) else { + continue; + }; + if let Some(object) = item.as_object_mut() { + object.insert(key.to_string(), value.clone()); + changed = true; + } + } + changed +} diff --git a/src-tauri/src/protocol/codex_history/inner_index.rs b/src-tauri/src/protocol/codex_history/inner_index.rs new file mode 100644 index 0000000..6ba70b7 --- /dev/null +++ b/src-tauri/src/protocol/codex_history/inner_index.rs @@ -0,0 +1,110 @@ +// Eviction, the call-id reverse index, and the unique-call fallback lookups. + +use super::types::{CachedResponse, HistoryInner, ScopedResponseId}; +use serde_json::Value; +use std::collections::HashSet; + +impl HistoryInner { + pub(super) fn prune_to_limits(&mut self, max_responses: usize, max_bytes: usize) { + while self.response_order.len() > max_responses || self.cached_bytes > max_bytes { + let Some(response_id) = self.response_order.pop_front() else { + break; + }; + self.remove_response(&response_id); + } + } + + pub(super) fn remove_response(&mut self, response_id: &ScopedResponseId) -> bool { + self.remove_response_from_call_index(response_id); + let Some(response) = self.responses.remove(response_id) else { + return false; + }; + self.cached_bytes = self.cached_bytes.saturating_sub(response.serialized_bytes); + true + } + + pub(super) fn index_call(&mut self, scope: &str, call_id: &str, response_id: &ScopedResponseId) { + let response_ids = self + .call_index + .entry((scope.to_string(), call_id.to_string())) + .or_default(); + if !response_ids + .iter() + .any(|cached_id| cached_id == response_id) + { + response_ids.push_back(response_id.clone()); + } + } + + pub(super) fn remove_response_from_call_index(&mut self, response_id: &ScopedResponseId) { + for response_ids in self.call_index.values_mut() { + response_ids.retain(|cached_id| cached_id != response_id); + } + self.call_index + .retain(|_, response_ids| !response_ids.is_empty()); + } + + pub(super) fn unique_fallback_response( + &self, + scope: &str, + requested_call_ids: &HashSet, + previous: Option<&CachedResponse>, + ) -> CachedResponse { + // A resolved previous_response_id is authoritative. Grafting calls from another cached + // branch onto it would create history that no provider ever observed. + if previous.is_some() || requested_call_ids.is_empty() { + return CachedResponse::default(); + } + + let mut source_response_id: Option = None; + for call_id in requested_call_ids { + let Some(response_id) = self.unique_response_for_call(scope, call_id) else { + return CachedResponse::default(); + }; + if source_response_id + .as_ref() + .is_some_and(|source| source != &response_id) + { + return CachedResponse::default(); + } + source_response_id = Some(response_id); + } + + source_response_id + .and_then(|response_id| self.responses.get(&response_id).cloned()) + .unwrap_or_default() + } + + pub(super) fn unique_response_for_call(&self, scope: &str, call_id: &str) -> Option { + let response_ids = self + .call_index + .get(&(scope.to_string(), call_id.to_string()))?; + let mut found: Option<&ScopedResponseId> = None; + for response_id in response_ids { + let Some(response) = self.responses.get(response_id) else { + continue; + }; + // An owner-only native response may contain just a delta after an unavailable prefix. + // Its calls are useful to that provider via previous_response_id, but are never a safe + // source for session fallback because doing so would manufacture a truncated chain. + if !response.materializable { + continue; + } + if !response.calls_by_id.contains_key(call_id) { + continue; + } + if found.is_some() { + return None; + } + found = Some(response_id); + } + found.cloned() + } + + pub(super) fn unique_call(&self, scope: &str, call_id: &str) -> Option<&Value> { + let response_id = self.unique_response_for_call(scope, call_id)?; + self.responses + .get(&response_id) + .and_then(|response| response.calls_by_id.get(call_id)) + } +} diff --git a/src-tauri/src/protocol/codex_history/inner_insert.rs b/src-tauri/src/protocol/codex_history/inner_insert.rs new file mode 100644 index 0000000..8b862d0 --- /dev/null +++ b/src-tauri/src/protocol/codex_history/inner_insert.rs @@ -0,0 +1,138 @@ +// Inserting a recorded response into the bounded cache, with the byte/count accounting that keeps +// a long conversation from growing the cache quadratically. + +use super::cache_items::cached_call_item; +use super::sizing::cached_response_size; +use super::types::{ + CachedResponse, HistoryInner, ResponseOrigin, MAX_CACHED_HISTORY_BYTES, MAX_CACHED_RESPONSES, +}; +use serde_json::Value; + +impl HistoryInner { + pub(super) fn insert_response( + &mut self, + scope: &str, + response_id: &str, + request_input: Vec, + output: Vec, + ) -> usize { + self.insert_response_with_metadata( + scope, + response_id, + request_input, + output, + ResponseOrigin::Local, + true, + ) + } + + pub(super) fn insert_response_with_metadata( + &mut self, + scope: &str, + response_id: &str, + request_input: Vec, + output: Vec, + origin: ResponseOrigin, + materializable: bool, + ) -> usize { + self.insert_response_with_metadata_and_limits( + scope, + response_id, + request_input, + output, + origin, + materializable, + MAX_CACHED_RESPONSES, + MAX_CACHED_HISTORY_BYTES, + ) + } + + pub(super) fn insert_response_with_limits( + &mut self, + scope: &str, + response_id: &str, + request_input: Vec, + output: Vec, + max_responses: usize, + max_bytes: usize, + ) -> usize { + self.insert_response_with_metadata_and_limits( + scope, + response_id, + request_input, + output, + ResponseOrigin::Local, + true, + max_responses, + max_bytes, + ) + } + + #[allow(clippy::too_many_arguments)] + pub(super) fn insert_response_with_metadata_and_limits( + &mut self, + scope: &str, + response_id: &str, + request_input: Vec, + output: Vec, + origin: ResponseOrigin, + materializable: bool, + max_responses: usize, + max_bytes: usize, + ) -> usize { + let mut cached_response = CachedResponse { + request_input, + output, + origin, + materializable, + ..CachedResponse::default() + }; + for item in &cached_response.output { + if let Some((call_id, item)) = cached_call_item(item) { + if !cached_response.calls_by_id.contains_key(&call_id) { + cached_response.call_order.push(call_id.clone()); + } + cached_response.calls_by_id.insert(call_id, item); + } + } + cached_response.serialized_bytes = + cached_response_size(scope, response_id, &cached_response); + let cached_count = cached_response.output.len(); + let response_key = (scope.to_string(), response_id.to_string()); + + // Never let one pathological request flush every useful older entry before being evicted + // itself. A same-id response is authoritative, though, so an oversized replacement drops + // only its stale predecessor rather than leaving old history addressable under that id. + if cached_response.serialized_bytes > max_bytes { + if self.remove_response(&response_key) { + self.response_order + .retain(|cached_id| cached_id != &response_key); + } + return 0; + } + + let replacing = self.responses.contains_key(&response_key); + if !replacing { + self.response_order.push_back(response_key.clone()); + } + + // A completed response is authoritative. Replacing an already-seen id keeps + // retry/replay recording idempotent and prevents stale call-index entries. + self.remove_response(&response_key); + + for call_id in &cached_response.call_order { + self.index_call(scope, &call_id, &response_key); + } + self.cached_bytes = self + .cached_bytes + .checked_add(cached_response.serialized_bytes) + .unwrap_or(usize::MAX); + self.responses.insert(response_key.clone(), cached_response); + self.prune_to_limits(max_responses, max_bytes); + if self.responses.contains_key(&response_key) { + cached_count + } else { + 0 + } + } +} diff --git a/src-tauri/src/protocol/codex_history/materialize.rs b/src-tauri/src/protocol/codex_history/materialize.rs new file mode 100644 index 0000000..10dffaa --- /dev/null +++ b/src-tauri/src/protocol/codex_history/materialize.rs @@ -0,0 +1,119 @@ +// Deciding whether a request's input (or a single history item) can be materialized verbatim for +// a different provider. + +use super::cache_items::{ + is_call_item_type, is_call_output_item_type, is_empty_value, response_item_call_id, +}; +use serde_json::Value; + +pub(super) fn request_input_items(request: &Value) -> Vec { + match request.get("input") { + Some(Value::Array(items)) => items.clone(), + Some(Value::Object(object)) => vec![Value::Object(object.clone())], + Some(Value::String(value)) => vec![serde_json::json!({ + "type": "message", + "role": "user", + "content": value, + })], + _ => Vec::new(), + } +} + +pub(super) fn request_input_is_materializable(request: &Value) -> bool { + match request.get("input") { + None | Some(Value::String(_)) => true, + Some(Value::Array(items)) => items.iter().all(history_item_is_materializable), + Some(item @ Value::Object(_)) => history_item_is_materializable(item), + _ => false, + } +} + +pub(super) fn history_item_is_materializable(item: &Value) -> bool { + let Some(object) = item.as_object() else { + return false; + }; + let item_type = object + .get("type") + .and_then(Value::as_str) + .unwrap_or_else(|| { + if object.get("role").is_some() { + "message" + } else { + "" + } + }); + match item_type { + // Responses Lite carries request-scoped tool declarations as a developer input item. + // They are ordinary JSON tool definitions and can be replayed when CC Buddy has to + // materialize a previous response across providers. + "additional_tools" => object.get("tools").is_some_and(Value::is_array), + "message" => object + .get("content") + .map_or(true, history_content_is_materializable), + "reasoning" => { + let has_opaque_reasoning = object + .get("encrypted_content") + .is_some_and(|value| !is_empty_value(value)); + !has_opaque_reasoning + && object + .get("summary") + .map_or(true, history_content_is_materializable) + && object + .get("content") + .map_or(true, history_content_is_materializable) + } + item_type if is_call_item_type(item_type) || is_call_output_item_type(item_type) => true, + _ => false, + } +} + +pub(super) fn history_content_is_materializable(content: &Value) -> bool { + match content { + Value::Null | Value::String(_) => true, + Value::Array(parts) => parts.iter().all(|part| { + let Some(part_type) = part.get("type").and_then(Value::as_str) else { + return false; + }; + match part_type { + "input_text" | "output_text" | "text" | "summary_text" => { + part.get("text").is_some_and(Value::is_string) + } + "input_image" => part + .get("image_url") + .and_then(|value| { + value + .as_str() + .or_else(|| value.get("url").and_then(Value::as_str)) + }) + .is_some(), + _ => false, + } + }), + _ => false, + } +} + +pub(super) fn cached_item_matches_input(cached: &Value, input: &Value) -> bool { + let cached_type = cached.get("type").and_then(Value::as_str); + let input_type = input.get("type").and_then(Value::as_str); + if cached_type.is_some_and(is_call_item_type) { + return input_type.is_some_and(is_call_item_type) + && response_item_call_id(cached).is_some_and(|call_id| { + response_item_call_id(input).as_deref() == Some(call_id.as_str()) + }); + } + + if let Some(id) = cached + .get("id") + .and_then(Value::as_str) + .map(str::trim) + .filter(|id| !id.is_empty()) + { + return cached_type == input_type + && input + .get("id") + .and_then(Value::as_str) + .is_some_and(|input_id| input_id.trim() == id); + } + cached == input +} diff --git a/src-tauri/src/protocol/codex_history/merge.rs b/src-tauri/src/protocol/codex_history/merge.rs new file mode 100644 index 0000000..a82c217 --- /dev/null +++ b/src-tauri/src/protocol/codex_history/merge.rs @@ -0,0 +1,116 @@ +// Merging a referenced response's model-visible context into a continuation request's input. + +use super::materialize::cached_item_matches_input; +use super::types::CachedResponse; +use serde_json::Value; +use std::collections::HashMap; + +/// Merge the directly referenced response's complete model-visible context into the new input. +/// +/// The cached request prefix is always restored before the previous response output. A client that +/// already sent the full prefix is detected by a matching request prefix plus at least one prior +/// output anchor, while a coincidentally repeated new user message is still treated as a delta. +/// Every supported previous output item is restored. Filtering unmatched calls would no longer be +/// equivalent to provider-side `previous_response_id` continuation and could silently turn an +/// invalid continuation into a different, truncated conversation. +pub(super) fn merge_previous_context( + items: Vec, + previous: Option<&CachedResponse>, +) -> (Vec, usize) { + let Some(previous) = previous else { + return (items, 0); + }; + + let eligible_output = previous.output.clone(); + let request_prefix_len = previous.request_input.len(); + let has_request_prefix = request_prefix_len <= items.len() + && previous + .request_input + .iter() + .zip(&items) + .all(|(cached, input)| cached_item_matches_input(cached, input)); + let has_output_anchor = has_request_prefix + && !previous.output.is_empty() + && previous.output.iter().any(|cached| { + items[request_prefix_len..] + .iter() + .any(|input| cached_item_matches_input(cached, input)) + }); + + let (prefix, tail, restored_input) = if has_request_prefix && has_output_anchor { + ( + items[..request_prefix_len].to_vec(), + items[request_prefix_len..].to_vec(), + 0, + ) + } else { + ( + previous.request_input.clone(), + items, + previous.request_input.len(), + ) + }; + let (tail, restored_output) = merge_cached_output(tail, &eligible_output); + let mut merged = Vec::with_capacity(prefix.len() + tail.len()); + merged.extend(prefix); + merged.extend(tail); + (merged, restored_input + restored_output) +} + +pub(super) fn merge_cached_output(items: Vec, eligible: &[Value]) -> (Vec, usize) { + if eligible.is_empty() { + return (items, 0); + } + + // Match explicit prior-output items monotonically. Legal explicit history keeps + // response order, and monotonic matching avoids treating a coincidentally reused + // text value later in the request as the prior item. + let mut matches = HashMap::::new(); + let mut next_input = 0usize; + for (cached_index, cached) in eligible.iter().enumerate() { + let Some(relative_index) = items[next_input..] + .iter() + .position(|item| cached_item_matches_input(cached, item)) + else { + continue; + }; + let input_index = next_input + relative_index; + matches.insert(input_index, cached_index); + next_input = input_index + 1; + } + + if matches.is_empty() { + let restored = eligible.len(); + let mut merged = Vec::with_capacity(restored + items.len()); + merged.extend(eligible.iter().cloned()); + merged.extend(items); + return (merged, restored); + } + + let last_match = matches.keys().copied().max().unwrap_or(0); + let mut merged = Vec::with_capacity(eligible.len() + items.len()); + let mut cached_cursor = 0usize; + let mut restored = 0usize; + for (input_index, item) in items.into_iter().enumerate() { + if let Some(&cached_index) = matches.get(&input_index) { + while cached_cursor < cached_index { + merged.push(eligible[cached_cursor].clone()); + cached_cursor += 1; + restored += 1; + } + // The explicit item wins (and may intentionally contain richer content). + merged.push(item); + cached_cursor = cached_index + 1; + if input_index == last_match { + while cached_cursor < eligible.len() { + merged.push(eligible[cached_cursor].clone()); + cached_cursor += 1; + restored += 1; + } + } + } else { + merged.push(item); + } + } + (merged, restored) +} diff --git a/src-tauri/src/protocol/codex_history/mod.rs b/src-tauri/src/protocol/codex_history/mod.rs new file mode 100644 index 0000000..1521ead --- /dev/null +++ b/src-tauri/src/protocol/codex_history/mod.rs @@ -0,0 +1,38 @@ +//! Cross-request history for bridging Codex Responses requests to chat-style upstreams. +//! +//! Responses clients may continue a tool turn with only +//! `previous_response_id + new input`. Chat-style protocols do not implement that +//! server-side continuation, so they need the previous request input and assistant +//! output restored recursively into the next request. Tool outputs additionally need +//! the original assistant call, including its name, arguments, and reasoning metadata. +//! This store records that model-visible context and restores it before conversion. + +mod cache_items; +mod inner_index; +mod inner_insert; +mod materialize; +mod merge; +mod resolve; +mod sizing; +mod store; +mod types; +#[cfg(test)] +mod tests_call_fallback; +#[cfg(test)] +mod tests_continuation; +#[cfg(test)] +mod tests_eviction; +#[cfg(test)] +mod tests_hops; +#[cfg(test)] +mod tests_limits; +#[cfg(test)] +mod tests_materialize; +#[cfg(test)] +mod tests_native; + +pub use types::{CodexHistoryStore, HistoryResolution, ResponseOrigin}; +// ResponseMetadata is this module's public return type for `response_metadata`; keep it resolving +// at crate::protocol::codex_history::ResponseMetadata even though callers destructure it today. +#[allow(unused_imports)] +pub use types::ResponseMetadata; diff --git a/src-tauri/src/protocol/codex_history/resolve.rs b/src-tauri/src/protocol/codex_history/resolve.rs new file mode 100644 index 0000000..b28b32c --- /dev/null +++ b/src-tauri/src/protocol/codex_history/resolve.rs @@ -0,0 +1,203 @@ +// Resolving a Responses request against the cache: restoring the previous turn's context, then +// enriching or materializing the request input. + +use super::cache_items::{ + enrich_call_item_from_cache, is_call_item_type, is_call_output_item_type, + response_item_call_id, +}; +use super::merge::merge_previous_context; +use super::types::{CachedLookup, CachedResponse, CodexHistoryStore, HistoryResolution}; +use serde_json::Value; +use std::collections::HashSet; + +impl CodexHistoryStore { + pub(super) async fn resolve_request_scoped( + &self, + scope: &str, + allow_call_id_fallback: bool, + strip_materialized_previous_id: bool, + body: &mut Value, + ) -> HistoryResolution { + let previous_response_id = body + .get("previous_response_id") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToString::to_string); + let had_previous_response_id = previous_response_id.is_some(); + + let input_was_missing = body.get("input").is_none(); + let original_input = body.get_mut("input").map(std::mem::take); + let original_was_object = original_input.as_ref().is_some_and(Value::is_object); + let mut original_string = None; + let mut unsupported_input = None; + let items = match original_input { + Some(Value::Array(items)) => items, + Some(Value::Object(object)) => vec![Value::Object(object)], + Some(Value::String(value)) => { + original_string = Some(value.clone()); + vec![serde_json::json!({ + "type": "message", + "role": "user", + "content": value, + })] + } + Some(other) => { + unsupported_input = Some(other); + Vec::new() + } + None => Vec::new(), + }; + + let output_call_ids = items + .iter() + .filter(|item| { + item.get("type") + .and_then(Value::as_str) + .is_some_and(is_call_output_item_type) + }) + .filter_map(response_item_call_id) + .collect::>(); + let existing_call_ids = items + .iter() + .filter(|item| { + item.get("type") + .and_then(Value::as_str) + .is_some_and(is_call_item_type) + }) + .filter_map(response_item_call_id) + .collect::>(); + let requested_call_ids = output_call_ids + .union(&existing_call_ids) + .cloned() + .collect::>(); + + let lookup = self + .lookup( + scope, + previous_response_id.as_deref(), + &requested_call_ids, + allow_call_id_fallback, + ) + .await; + let previous_found = lookup.previous.is_some(); + let previous_origin = lookup + .previous + .as_ref() + .map(|response| response.origin.clone()); + let previous_materialized = lookup + .previous + .as_ref() + .is_some_and(|response| response.materializable); + + if let Some(original_input) = unsupported_input { + if let Some(object) = body.as_object_mut() { + object.insert("input".to_string(), original_input); + } + return HistoryResolution { + changed: 0, + had_previous_response_id, + previous_found, + previous_materialized: false, + previous_origin, + }; + } + + // A native provider may still own a continuation that the gateway observed only after a + // restart. Keep that request byte-for-byte intact for same-provider passthrough; callers + // must reject it before cross-wire/provider-switch forwarding because its prefix is absent. + if previous_found && !previous_materialized { + if !input_was_missing { + let restored_input = if original_string.is_some() && items.len() == 1 { + Value::String(original_string.unwrap_or_default()) + } else if original_was_object && items.len() == 1 { + items.into_iter().next().unwrap_or(Value::Null) + } else { + Value::Array(items) + }; + if let Some(object) = body.as_object_mut() { + object.insert("input".to_string(), restored_input); + } + } + return HistoryResolution { + changed: 0, + had_previous_response_id, + previous_found, + previous_materialized, + previous_origin, + }; + } + let replay_context = lookup + .previous + .as_ref() + .or_else(|| lookup.fallback.materializable.then_some(&lookup.fallback)); + let (items, restored) = merge_previous_context(items, replay_context); + let mut enriched = 0usize; + let mut new_items = Vec::with_capacity(items.len()); + + for mut item in items { + if item + .get("type") + .and_then(Value::as_str) + .is_some_and(is_call_item_type) + { + if let Some(call_id) = response_item_call_id(&item) { + if let Some(cached) = lookup.call(&call_id) { + if enrich_call_item_from_cache(&mut item, cached) { + enriched += 1; + } + } + } + } + new_items.push(item); + } + + let changed = restored + enriched; + let resolved_input = if changed == 0 && original_string.is_some() && new_items.len() == 1 { + Some(Value::String(original_string.unwrap_or_default())) + } else if changed == 0 && original_was_object && new_items.len() == 1 { + Some(new_items.into_iter().next().unwrap_or(Value::Null)) + } else if input_was_missing && changed == 0 { + None + } else { + Some(Value::Array(new_items)) + }; + if let (Some(object), Some(resolved_input)) = (body.as_object_mut(), resolved_input) { + object.insert("input".to_string(), resolved_input); + } + if strip_materialized_previous_id && previous_materialized { + if let Some(object) = body.as_object_mut() { + object.remove("previous_response_id"); + } + } + HistoryResolution { + changed, + had_previous_response_id, + previous_found, + previous_materialized, + previous_origin, + } + } + + pub(super) async fn lookup( + &self, + scope: &str, + previous_response_id: Option<&str>, + requested_call_ids: &HashSet, + allow_call_id_fallback: bool, + ) -> CachedLookup { + let inner = self.inner.read().await; + let previous = previous_response_id.and_then(|id| { + inner + .responses + .get(&(scope.to_string(), id.to_string())) + .cloned() + }); + let fallback = if allow_call_id_fallback { + inner.unique_fallback_response(scope, requested_call_ids, previous.as_ref()) + } else { + CachedResponse::default() + }; + CachedLookup { previous, fallback } + } +} diff --git a/src-tauri/src/protocol/codex_history/sizing.rs b/src-tauri/src/protocol/codex_history/sizing.rs new file mode 100644 index 0000000..2375224 --- /dev/null +++ b/src-tauri/src/protocol/codex_history/sizing.rs @@ -0,0 +1,44 @@ +// Serialized-size accounting for the cache's byte budget. + +use super::types::{CachedResponse, ResponseOrigin}; +use std::io::{self, Write}; + +#[derive(Default)] +struct ByteCounter { + bytes: usize, +} + +impl Write for ByteCounter { + fn write(&mut self, buf: &[u8]) -> io::Result { + self.bytes = self.bytes.saturating_add(buf.len()); + Ok(buf.len()) + } + + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } +} + +pub(super) fn serialized_size(value: &T) -> usize { + let mut counter = ByteCounter::default(); + if serde_json::to_writer(&mut counter, value).is_err() { + usize::MAX + } else { + counter.bytes + } +} + +pub(super) fn cached_response_size(scope: &str, response_id: &str, response: &CachedResponse) -> usize { + let origin_bytes = match &response.origin { + ResponseOrigin::Local => 1, + ResponseOrigin::Native(provider_id) => 1usize.saturating_add(serialized_size(provider_id)), + }; + serialized_size(scope) + .saturating_add(serialized_size(response_id)) + .saturating_add(serialized_size(&response.request_input)) + .saturating_add(serialized_size(&response.output)) + .saturating_add(serialized_size(&response.calls_by_id)) + .saturating_add(serialized_size(&response.call_order)) + .saturating_add(origin_bytes) + .saturating_add(1) +} diff --git a/src-tauri/src/protocol/codex_history/store.rs b/src-tauri/src/protocol/codex_history/store.rs new file mode 100644 index 0000000..86ca2f9 --- /dev/null +++ b/src-tauri/src/protocol/codex_history/store.rs @@ -0,0 +1,164 @@ +// Recording a translated Responses turn and the public request-enrichment entry points. + +use super::cache_items::cached_output_item; +use super::materialize::{ + history_item_is_materializable, request_input_is_materializable, request_input_items, +}; +use super::types::{CodexHistoryStore, HistoryResolution, ResponseMetadata, ResponseOrigin}; +use serde_json::Value; + +impl CodexHistoryStore { + /// Record the full translated request input plus supported assistant-output items from a + /// resumable terminal Responses response (`completed` or `incomplete`). + /// + /// Returns the number of cached output items. Responses without an id are ignored; an otherwise + /// empty response is still retained so provider ownership remains known. + pub async fn record_response(&self, request: &Value, response: &Value) -> usize { + self.record_response_scoped("", request, response).await + } + + /// Scoped variant used by the gateway so response/call ids from different client sessions can + /// never satisfy one another while the same conversation can survive a provider switch. + pub async fn record_response_scoped( + &self, + scope: &str, + request: &Value, + response: &Value, + ) -> usize { + // Preserve the original store API for internal callers/tests that predate Responses + // terminal statuses. Gateway-owned/native recording uses the metadata variant below, + // which requires an explicit resumable terminal status. + let mut legacy_terminal; + let response = if response.get("status").is_none() { + legacy_terminal = response.clone(); + legacy_terminal["status"] = Value::String("completed".to_string()); + &legacy_terminal + } else { + response + }; + self.record_response_scoped_with_metadata( + scope, + ResponseOrigin::Local, + true, + request, + response, + ) + .await + } + + pub async fn record_response_scoped_with_metadata( + &self, + scope: &str, + origin: ResponseOrigin, + materializable: bool, + request: &Value, + response: &Value, + ) -> usize { + if response + .get("object") + .and_then(Value::as_str) + .is_some_and(|object| object != "response") + { + return 0; + } + if !matches!( + response.get("status").and_then(Value::as_str), + Some("completed" | "incomplete") + ) { + return 0; + } + + let Some(response_id) = response + .get("id") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + else { + return 0; + }; + + let request_input = request_input_items(request); + let request_input_is_complete = request_input_is_materializable(request); + let (output, output_is_complete) = match response.get("output").and_then(Value::as_array) { + Some(items) => { + let output = items + .iter() + .filter_map(cached_output_item) + .collect::>(); + let output_is_complete = + output.len() == items.len() && items.iter().all(history_item_is_materializable); + (output, output_is_complete) + } + None => (Vec::new(), false), + }; + // Preserve ownership for a response containing an item this bridge cannot replay, but + // never advertise that partial transcript as safe to move to another provider. + let materializable = materializable && request_input_is_complete && output_is_complete; + + self.inner.write().await.insert_response_with_metadata( + scope, + response_id, + request_input, + output, + origin, + materializable, + ) + } + + pub async fn response_metadata( + &self, + scope: &str, + response_id: &str, + ) -> Option { + let response_id = response_id.trim(); + if response_id.is_empty() { + return None; + } + self.inner + .read() + .await + .responses + .get(&(scope.to_string(), response_id.to_string())) + .map(|response| ResponseMetadata { + origin: response.origin.clone(), + materializable: response.materializable, + }) + } + + /// Restore or enrich call items required by a follow-up Responses request. + /// + /// Missing calls are inserted immediately before the first matching output. + /// Parallel calls from the same response are restored as one ordered group. + /// Existing call items are enriched when fields such as `name`, `arguments`, + /// or `reasoning_content` are missing. + /// + /// The primary lookup uses `previous_response_id`. If that id is absent or + /// stale, a call-id fallback is used only when the caller supplied a safe scope and the call id + /// is unique inside that client session. Returns the number of restored or enriched items. + pub async fn enrich_request(&self, body: &mut Value) -> usize { + self.enrich_request_scoped("", false, body).await + } + + /// Scoped variant. Missing/stale-`previous_response_id` call-id recovery is allowed only when + /// the caller can provide a client-session scope; otherwise orphan validation must fail. + pub async fn enrich_request_scoped( + &self, + scope: &str, + allow_call_id_fallback: bool, + body: &mut Value, + ) -> usize { + self.resolve_request_scoped(scope, allow_call_id_fallback, false, body) + .await + .changed + } + + pub async fn materialize_request_scoped( + &self, + scope: &str, + allow_call_id_fallback: bool, + body: &mut Value, + ) -> HistoryResolution { + self.resolve_request_scoped(scope, allow_call_id_fallback, true, body) + .await + } +} diff --git a/src-tauri/src/protocol/codex_history/tests_call_fallback.rs b/src-tauri/src/protocol/codex_history/tests_call_fallback.rs new file mode 100644 index 0000000..ea441d0 --- /dev/null +++ b/src-tauri/src/protocol/codex_history/tests_call_fallback.rs @@ -0,0 +1,175 @@ +use super::*; +use serde_json::json; + +#[tokio::test] +async fn call_id_fallback_never_crosses_client_session_scope() { + let history = CodexHistoryStore::default(); + history + .record_response_scoped( + "session-a", + &json!({"input":[]}), + &json!({ + "id":"resp_a", + "output":[{ + "type":"function_call","call_id":"call_a", + "name":"lookup","arguments":"{}" + }] + }), + ) + .await; + let mut request = json!({ + "previous_response_id":"stale", + "input":[{ + "type":"function_call_output","call_id":"call_a","output":"ok" + }] + }); + + assert_eq!( + history + .enrich_request_scoped("session-b", true, &mut request) + .await, + 0 + ); + assert!(crate::protocol::openai_responses::decode_request(&request).is_err()); +} + +#[tokio::test] +async fn ambiguous_call_id_does_not_use_fallback() { + let history = CodexHistoryStore::default(); + let scope = "session-1"; + for response_id in ["resp_1", "resp_2"] { + history + .record_response_scoped( + scope, + &json!({ "input": [] }), + &json!({ + "id": response_id, + "output": [{ + "type":"function_call", + "call_id":"shared_call", + "name":"lookup", + "arguments":"{}" + }] + }), + ) + .await; + } + + let mut request = json!({ + "input": [{ + "type":"function_call_output", + "call_id":"shared_call", + "output":"ok" + }] + }); + + assert_eq!( + history + .enrich_request_scoped(scope, true, &mut request) + .await, + 0 + ); + assert_eq!(request["input"].as_array().unwrap().len(), 1); + assert_eq!(request["input"][0]["type"], "function_call_output"); + let error = crate::protocol::openai_responses::decode_request(&request).unwrap_err(); + assert!(error.contains("shared_call")); +} + +#[tokio::test] +async fn enriches_existing_call_without_duplicating_it() { + let history = CodexHistoryStore::default(); + history + .record_response( + &json!({ "input": [] }), + &json!({ + "id": "resp_1", + "output": [{ + "type":"function_call", + "call_id":"call_1", + "name":"read_file", + "arguments":"{\"path\":\"README.md\"}", + "reasoning_content":"Need the file." + }] + }), + ) + .await; + + let mut request = json!({ + "previous_response_id":"resp_1", + "input":[ + {"type":"function_call","call_id":"call_1"}, + {"type":"function_call_output","call_id":"call_1","output":"ok"} + ] + }); + + assert_eq!(history.enrich_request(&mut request).await, 1); + let input = request["input"].as_array().unwrap(); + assert_eq!(input.len(), 2); + assert_eq!(input[0]["name"], "read_file"); + assert_eq!(input[0]["arguments"], "{\"path\":\"README.md\"}"); + assert_eq!(input[0]["reasoning_content"], "Need the file."); +} + +#[tokio::test] +async fn restores_custom_and_tool_search_calls() { + let history = CodexHistoryStore::default(); + assert_eq!( + history + .record_response( + &json!({ "input": [] }), + &json!({ + "id":"resp_tools", + "output":[ + { + "type":"custom_tool_call", + "call_id":"call_patch", + "name":"apply_patch", + "input":"*** Begin Patch\n*** End Patch" + }, + { + "type":"tool_search_call", + "call_id":"call_search", + "status":"completed", + "execution":"client", + "arguments":{"query":"mail tools"} + } + ] + }) + ) + .await, + 2 + ); + + let mut request = json!({ + "previous_response_id":"resp_tools", + "input":[ + {"type":"custom_tool_call_output","call_id":"call_patch","output":"patched"}, + {"type":"tool_search_output","call_id":"call_search","tools":[]} + ] + }); + + assert_eq!(history.enrich_request(&mut request).await, 2); + let input = request["input"].as_array().unwrap(); + assert_eq!(input[0]["type"], "custom_tool_call"); + assert_eq!(input[0]["input"], "*** Begin Patch\n*** End Patch"); + assert_eq!(input[1]["type"], "tool_search_call"); + assert_eq!(input[2]["type"], "custom_tool_call_output"); + assert_eq!(input[3]["type"], "tool_search_output"); +} + +#[tokio::test] +async fn preserves_scalar_and_single_object_input_when_no_change_is_needed() { + let history = CodexHistoryStore::default(); + let mut scalar_request = json!({"input":"hello"}); + assert_eq!(history.enrich_request(&mut scalar_request).await, 0); + assert_eq!(scalar_request["input"], "hello"); + + let mut request = json!({ + "input": {"type":"message","role":"user","content":"hello"} + }); + + assert_eq!(history.enrich_request(&mut request).await, 0); + assert!(request["input"].is_object()); + assert_eq!(request["input"]["content"], "hello"); +} + diff --git a/src-tauri/src/protocol/codex_history/tests_continuation.rs b/src-tauri/src/protocol/codex_history/tests_continuation.rs new file mode 100644 index 0000000..be6d733 --- /dev/null +++ b/src-tauri/src/protocol/codex_history/tests_continuation.rs @@ -0,0 +1,135 @@ +use super::*; +use super::materialize::request_input_is_materializable; +use serde_json::json; + +#[test] +fn responses_lite_additional_tools_are_materializable() { + let request = json!({ + "input": [{ + "type": "additional_tools", + "role": "developer", + "tools": [ + { "type": "custom", "name": "exec" }, + { + "type": "namespace", + "name": "collaboration", + "tools": [{ "type": "function", "name": "spawn_agent" }] + } + ] + }] + }); + assert!(request_input_is_materializable(&request)); +} + +#[tokio::test] +async fn restores_call_before_output_from_previous_response() { + let history = CodexHistoryStore::default(); + assert_eq!( + history + .record_response( + &json!({ "input": [] }), + &json!({ + "id": "resp_1", + "output": [{ + "type": "function_call", + "call_id": "call_1", + "name": "read_file", + "arguments": "{\"path\":\"README.md\"}", + "reasoning_content": "Need to inspect the file." + }] + }) + ) + .await, + 1 + ); + + let mut request = json!({ + "previous_response_id": "resp_1", + "input": [{ + "type": "function_call_output", + "call_id": "call_1", + "output": "ok" + }] + }); + + assert_eq!(history.enrich_request(&mut request).await, 1); + let input = request["input"].as_array().unwrap(); + assert_eq!(input[0]["type"], "function_call"); + assert_eq!(input[0]["name"], "read_file"); + assert_eq!(input[0]["reasoning_content"], "Need to inspect the file."); + assert_eq!(input[1]["type"], "function_call_output"); + + // The restored item-level reasoning must survive the JSON → chat IR half, + // not merely remain present in the enriched request body. + let decoded = crate::protocol::openai_responses::decode_request(&request).unwrap(); + assert_eq!( + decoded.messages[0].reasoning_content.as_deref(), + Some("Need to inspect the file.") + ); + assert_eq!( + decoded.messages[0].tool_calls.as_ref().unwrap()[0].id, + "call_1" + ); +} + +#[tokio::test] +async fn restores_text_continuation_and_deduplicates_explicit_prior_output() { + let history = CodexHistoryStore::default(); + let reasoning = json!({ + "type":"reasoning", + "id":"rs_text", + "summary":[{"type":"summary_text","text":"continue the thought"}] + }); + let assistant = json!({ + "type":"message", + "id":"msg_text", + "role":"assistant", + "content":[{"type":"output_text","text":"First answer."}] + }); + assert_eq!( + history + .record_response( + &json!({ "input": [] }), + &json!({ + "id":"resp_text", + "output":[reasoning.clone(), assistant.clone()] + }) + ) + .await, + 2 + ); + + let mut continuation = json!({ + "previous_response_id":"resp_text", + "input":"Continue." + }); + assert_eq!(history.enrich_request(&mut continuation).await, 2); + let input = continuation["input"].as_array().unwrap(); + assert_eq!(input.len(), 3); + assert_eq!(input[0]["id"], "rs_text"); + assert_eq!(input[1]["id"], "msg_text"); + assert_eq!(input[2]["role"], "user"); + + let decoded = crate::protocol::openai_responses::decode_request(&continuation).unwrap(); + assert_eq!(decoded.messages.len(), 2); + assert_eq!(decoded.messages[0].content_as_text(), "First answer."); + assert_eq!( + decoded.messages[0].reasoning_content.as_deref(), + Some("continue the thought") + ); + assert_eq!(decoded.messages[1].content_as_text(), "Continue."); + + // A client may send explicit history even while retaining previous_response_id. + // Stable item ids anchor that history, so the cache must not duplicate it. + let mut explicit = json!({ + "previous_response_id":"resp_text", + "input":[ + reasoning, + assistant, + {"type":"message","role":"user","content":"Continue."} + ] + }); + assert_eq!(history.enrich_request(&mut explicit).await, 0); + assert_eq!(explicit["input"].as_array().unwrap().len(), 3); +} + diff --git a/src-tauri/src/protocol/codex_history/tests_eviction.rs b/src-tauri/src/protocol/codex_history/tests_eviction.rs new file mode 100644 index 0000000..a284371 --- /dev/null +++ b/src-tauri/src/protocol/codex_history/tests_eviction.rs @@ -0,0 +1,163 @@ +use super::*; +use super::types::{HistoryInner, MAX_CACHED_RESPONSES}; +use serde_json::json; + +#[test] +fn oversized_insert_preserves_unrelated_entries_and_drops_stale_replacement() { + let keep_request = vec![json!({ + "type":"message","role":"user","content":"keep" + })]; + let keep_output = vec![json!({ + "type":"function_call","call_id":"call_keep", + "name":"keep_tool","arguments":"{}" + })]; + let oversized_request = vec![json!({ + "type":"message","role":"user","content":"x".repeat(2048) + })]; + let oversized_output = vec![json!({ + "type":"function_call","call_id":"call_huge", + "name":"huge_tool","arguments":"y".repeat(2048) + })]; + + let scope = "session-1"; + let keep_key = (scope.to_string(), "resp_keep".to_string()); + let mut probe = HistoryInner::default(); + probe.insert_response( + scope, + "resp_keep", + keep_request.clone(), + keep_output.clone(), + ); + let budget = probe.responses[&keep_key].serialized_bytes; + + let mut inner = HistoryInner::default(); + assert_eq!( + inner.insert_response_with_limits( + scope, + "resp_keep", + keep_request, + keep_output, + MAX_CACHED_RESPONSES, + budget, + ), + 1 + ); + assert_eq!( + inner.insert_response_with_limits( + scope, + "resp_huge", + oversized_request.clone(), + oversized_output.clone(), + MAX_CACHED_RESPONSES, + budget, + ), + 0 + ); + assert!(inner.responses.contains_key(&keep_key)); + assert_eq!(inner.cached_bytes, budget); + assert!(inner.unique_call(scope, "call_huge").is_none()); + + assert_eq!( + inner.insert_response_with_limits( + scope, + "resp_keep", + oversized_request, + oversized_output, + MAX_CACHED_RESPONSES, + budget, + ), + 0 + ); + assert!(inner.responses.is_empty()); + assert!(inner.response_order.is_empty()); + assert!(inner.call_index.is_empty()); + assert_eq!(inner.cached_bytes, 0); +} + +#[tokio::test] +async fn native_history_materializes_across_provider_boundaries_and_strips_previous_id() { + let history = CodexHistoryStore::default(); + let scope = "session-native"; + history + .record_response_scoped_with_metadata( + scope, + ResponseOrigin::Native("provider-a".to_string()), + true, + &json!({ + "input":[{"type":"message","role":"user","content":"first"}] + }), + &json!({ + "id":"resp_native_a","status":"completed", + "output":[{ + "type":"message","id":"msg_native_a","role":"assistant", + "content":[{"type":"output_text","text":"answer"}] + }] + }), + ) + .await; + let mut next = json!({ + "previous_response_id":"resp_native_a", + "input":[{"type":"message","role":"user","content":"second"}] + }); + + let resolution = history + .materialize_request_scoped(scope, true, &mut next) + .await; + assert!(resolution.previous_found); + assert!(resolution.previous_materialized); + assert_eq!( + resolution.previous_origin, + Some(ResponseOrigin::Native("provider-a".to_string())) + ); + assert!(next.get("previous_response_id").is_none()); + let decoded = crate::protocol::openai_responses::decode_request(&next).unwrap(); + assert_eq!( + decoded + .messages + .iter() + .map(|message| message.content_as_text()) + .collect::>(), + vec!["first", "answer", "second"] + ); +} + +#[tokio::test] +async fn owner_only_native_history_is_reported_but_never_materialized() { + let history = CodexHistoryStore::default(); + let scope = "session-owner-only"; + history + .record_response_scoped_with_metadata( + scope, + ResponseOrigin::Native("provider-a".to_string()), + false, + &json!({ + "previous_response_id":"unknown-before-restart", + "input":[{"type":"message","role":"user","content":"delta"}] + }), + &json!({ + "id":"resp_owner_only","status":"completed", + "output":[{ + "type":"message","id":"msg_owner_only","role":"assistant", + "content":[{"type":"output_text","text":"answer"}] + }] + }), + ) + .await; + let mut next = json!({ + "previous_response_id":"resp_owner_only", + "input":[{"type":"message","role":"user","content":"next"}] + }); + let before = next.clone(); + + let resolution = history + .materialize_request_scoped(scope, true, &mut next) + .await; + assert!(resolution.previous_found); + assert!(!resolution.previous_materialized); + assert_eq!( + resolution.previous_origin, + Some(ResponseOrigin::Native("provider-a".to_string())) + ); + assert_eq!(next, before); +} + diff --git a/src-tauri/src/protocol/codex_history/tests_hops.rs b/src-tauri/src/protocol/codex_history/tests_hops.rs new file mode 100644 index 0000000..ceed8ad --- /dev/null +++ b/src-tauri/src/protocol/codex_history/tests_hops.rs @@ -0,0 +1,174 @@ +use super::*; +use serde_json::json; + +#[tokio::test] +async fn restores_request_and_response_context_across_multiple_hops() { + let history = CodexHistoryStore::default(); + let first_request = json!({ + "input":[{"type":"message","role":"user","content":"First question."}] + }); + let first_response = json!({ + "id":"resp_first", + "output":[{ + "type":"message","id":"msg_first","role":"assistant", + "content":[{"type":"output_text","text":"First answer."}] + }] + }); + assert_eq!( + history + .record_response(&first_request, &first_response) + .await, + 1 + ); + + let mut second_request = json!({ + "previous_response_id":"resp_first", + "input":[{"type":"message","role":"user","content":"Second question."}] + }); + assert_eq!(history.enrich_request(&mut second_request).await, 2); + let second_response = json!({ + "id":"resp_second", + "output":[{ + "type":"message","id":"msg_second","role":"assistant", + "content":[{"type":"output_text","text":"Second answer."}] + }] + }); + assert_eq!( + history + .record_response(&second_request, &second_response) + .await, + 1 + ); + + let mut third_request = json!({ + "previous_response_id":"resp_second", + "input":[{"type":"message","role":"user","content":"Third question."}] + }); + assert_eq!(history.enrich_request(&mut third_request).await, 4); + let decoded = crate::protocol::openai_responses::decode_request(&third_request).unwrap(); + let transcript = decoded + .messages + .iter() + .map(|message| message.content_as_text()) + .collect::>(); + assert_eq!( + transcript, + vec![ + "First question.", + "First answer.", + "Second question.", + "Second answer.", + "Third question.", + ] + ); + + // Full explicit history plus previous_response_id must remain idempotent. + let before = third_request.clone(); + assert_eq!(history.enrich_request(&mut third_request).await, 0); + assert_eq!(third_request, before); +} + +#[tokio::test] +async fn restores_parallel_calls_as_one_ordered_group() { + let history = CodexHistoryStore::default(); + history + .record_response( + &json!({ "input": [] }), + &json!({ + "id": "resp_parallel", + "output": [ + {"type":"function_call","call_id":"call_a","name":"first","arguments":"{}"}, + {"type":"function_call","call_id":"call_b","name":"second","arguments":"{}"} + ] + }), + ) + .await; + + // Outputs may arrive in a different order. The assistant call group must + // retain the order in which the response originally emitted the calls. + let mut request = json!({ + "previous_response_id": "resp_parallel", + "input": [ + {"type":"function_call_output","call_id":"call_b","output":"two"}, + {"type":"function_call_output","call_id":"call_a","output":"one"} + ] + }); + + assert_eq!(history.enrich_request(&mut request).await, 2); + let input = request["input"].as_array().unwrap(); + assert_eq!(input[0]["call_id"], "call_a"); + assert_eq!(input[1]["call_id"], "call_b"); + assert_eq!(input[2]["type"], "function_call_output"); + assert_eq!(input[3]["type"], "function_call_output"); +} + +#[tokio::test] +async fn same_client_session_recovers_across_provider_switches() { + let history = CodexHistoryStore::default(); + // The scope deliberately contains no provider identity: switching the active provider + // must not sever the client's previous_response_id chain. + let scope = "session-1"; + history + .record_response_scoped( + scope, + &json!({ "input": [] }), + &json!({ + "id": "resp_1", + "output": [{ + "type":"function_call", + "call_id":"unique_call", + "name":"lookup", + "arguments":"{}" + }] + }), + ) + .await; + + for previous in [None, Some("stale_response"), Some("resp_1")] { + let mut request = json!({ + "input": [{ + "type":"function_call_output", + "call_id":"unique_call", + "output":"ok" + }] + }); + if let Some(previous) = previous { + request["previous_response_id"] = json!(previous); + } + + assert_eq!( + history + .enrich_request_scoped(scope, true, &mut request) + .await, + 1 + ); + assert_eq!(request["input"][0]["type"], "function_call"); + assert_eq!(request["input"][0]["name"], "lookup"); + } +} + +#[tokio::test] +async fn missing_previous_response_fallback_requires_a_safe_scope() { + let history = CodexHistoryStore::default(); + history + .record_response( + &json!({ "input": [] }), + &json!({ + "id":"resp_1", + "output":[{ + "type":"function_call","call_id":"call_1", + "name":"lookup","arguments":"{}" + }] + }), + ) + .await; + let mut request = json!({ + "input":[{ + "type":"function_call_output","call_id":"call_1","output":"ok" + }] + }); + + assert_eq!(history.enrich_request(&mut request).await, 0); + assert!(crate::protocol::openai_responses::decode_request(&request).is_err()); +} + diff --git a/src-tauri/src/protocol/codex_history/tests_limits.rs b/src-tauri/src/protocol/codex_history/tests_limits.rs new file mode 100644 index 0000000..8244f8c --- /dev/null +++ b/src-tauri/src/protocol/codex_history/tests_limits.rs @@ -0,0 +1,172 @@ +use super::*; +use super::types::{HistoryInner, MAX_CACHED_RESPONSES}; +use std::sync::Arc; +use serde_json::{json, Value}; + +#[tokio::test] +async fn concurrent_recording_is_safe_and_searchable() { + let history = Arc::new(CodexHistoryStore::default()); + let scope = "session-1"; + let mut tasks = Vec::new(); + for index in 0..16 { + let history = history.clone(); + tasks.push(tokio::spawn(async move { + history + .record_response_scoped( + scope, + &json!({ "input": [] }), + &json!({ + "id": format!("resp_{index}"), + "output": [{ + "type":"function_call", + "call_id":format!("call_{index}"), + "name":"work", + "arguments":"{}" + }] + }), + ) + .await + })); + } + for task in tasks { + assert_eq!(task.await.unwrap(), 1); + } + + let mut request = json!({ + "input":[{ + "type":"function_call_output", + "call_id":"call_9", + "output":"done" + }] + }); + assert_eq!( + history + .enrich_request_scoped(scope, true, &mut request) + .await, + 1 + ); + assert_eq!(request["input"][0]["call_id"], "call_9"); + assert_eq!(request["input"][0]["name"], "work"); +} + +#[test] +fn byte_budget_evicts_oldest_responses_and_cleans_call_index() { + let old_request = vec![json!({ + "type":"message","role":"user","content":"old request" + })]; + let old_output = vec![json!({ + "type":"function_call", + "call_id":"shared_call", + "name":"old_tool", + "arguments":"{\"value\":\"old\"}" + })]; + let new_request = vec![json!({ + "type":"message","role":"user","content":"new request" + })]; + let new_output = vec![json!({ + "type":"function_call", + "call_id":"shared_call", + "name":"new_tool", + "arguments":"{\"value\":\"new\"}" + })]; + let mut probe = HistoryInner::default(); + let scope = "session-1"; + let new_key = (scope.to_string(), "resp_new".to_string()); + let old_key = (scope.to_string(), "resp_old".to_string()); + probe.insert_response(scope, "resp_new", new_request.clone(), new_output.clone()); + let newest_size = probe.responses[&new_key].serialized_bytes; + + let mut inner = HistoryInner::default(); + assert_eq!( + inner.insert_response_with_limits( + scope, + "resp_old", + old_request, + old_output, + MAX_CACHED_RESPONSES, + usize::MAX, + ), + 1 + ); + assert_eq!( + inner.insert_response_with_limits( + scope, + "resp_new", + new_request, + new_output, + MAX_CACHED_RESPONSES, + newest_size, + ), + 1 + ); + + assert_eq!(inner.cached_bytes, newest_size); + assert!(!inner.responses.contains_key(&old_key)); + assert!(inner.responses.contains_key(&new_key)); + assert_eq!( + inner + .unique_call(scope, "shared_call") + .and_then(|item| item.get("name")) + .and_then(Value::as_str), + Some("new_tool") + ); +} + +#[test] +fn same_id_replacement_keeps_exact_accounting_and_no_stale_call_index() { + let mut inner = HistoryInner::default(); + let scope = "session-1"; + let response_key = (scope.to_string(), "resp_same".to_string()); + let call_key = (scope.to_string(), "call_new".to_string()); + assert_eq!( + inner.insert_response( + scope, + "resp_same", + vec![json!({"type":"message","role":"user","content":"short"})], + vec![json!({ + "type":"function_call","call_id":"call_old", + "name":"old_tool","arguments":"{}" + })], + ), + 1 + ); + assert_eq!( + inner.insert_response( + scope, + "resp_same", + vec![json!({ + "type":"message","role":"user", + "content":"a longer authoritative replacement" + })], + vec![json!({ + "type":"function_call","call_id":"call_new", + "name":"new_tool","arguments":"{\"ok\":true}" + })], + ), + 1 + ); + + let replacement_bytes = inner.responses[&response_key].serialized_bytes; + assert_eq!(inner.response_order.len(), 1); + assert_eq!(inner.cached_bytes, replacement_bytes); + assert!(inner.unique_call(scope, "call_old").is_none()); + assert_eq!( + inner + .unique_call(scope, "call_new") + .and_then(|item| item.get("name")) + .and_then(Value::as_str), + Some("new_tool") + ); + + // Replaying the same completed response must not duplicate order/index entries or bytes. + let request = inner.responses[&response_key].request_input.clone(); + let output = inner.responses[&response_key].output.clone(); + assert_eq!( + inner.insert_response(scope, "resp_same", request, output), + 1 + ); + assert_eq!(inner.response_order.len(), 1); + assert_eq!(inner.cached_bytes, replacement_bytes); + assert_eq!(inner.call_index[&call_key].len(), 1); +} + diff --git a/src-tauri/src/protocol/codex_history/tests_materialize.rs b/src-tauri/src/protocol/codex_history/tests_materialize.rs new file mode 100644 index 0000000..0b87ba5 --- /dev/null +++ b/src-tauri/src/protocol/codex_history/tests_materialize.rs @@ -0,0 +1,167 @@ +use super::*; +use serde_json::json; + +#[tokio::test] +async fn empty_output_does_not_collapse_an_identical_follow_up_input() { + let history = CodexHistoryStore::default(); + history + .record_response_scoped_with_metadata( + "session-empty-output", + ResponseOrigin::Local, + true, + &json!({"input":"ping"}), + &json!({"id":"resp_empty","status":"completed","output":[]}), + ) + .await; + let mut next = json!({ + "previous_response_id":"resp_empty", + "input":"ping" + }); + + let resolution = history + .materialize_request_scoped("session-empty-output", true, &mut next) + .await; + assert_eq!(resolution.changed, 1); + assert!(next.get("previous_response_id").is_none()); + let input = next["input"].as_array().unwrap(); + assert_eq!(input.len(), 2); + assert_eq!(input[0]["content"], "ping"); + assert_eq!(input[1]["content"], "ping"); +} + +#[tokio::test] +async fn call_fallback_uses_one_complete_branch_and_never_grafts_onto_previous() { + let history = CodexHistoryStore::default(); + let scope = "session-fallback-branch"; + for (response_id, call_id, prompt) in [ + ("resp_a", "call_a", "branch a"), + ("resp_b", "call_b", "branch b"), + ] { + history + .record_response_scoped_with_metadata( + scope, + ResponseOrigin::Local, + true, + &json!({ + "input":[{"type":"message","role":"user","content":prompt}] + }), + &json!({ + "id":response_id,"status":"completed", + "output":[{ + "type":"function_call","call_id":call_id, + "name":"lookup","arguments":"{}" + }] + }), + ) + .await; + } + + let mut one_branch = json!({ + "input":[{ + "type":"function_call_output","call_id":"call_a","output":"a" + }] + }); + let resolution = history + .materialize_request_scoped(scope, true, &mut one_branch) + .await; + assert!(!resolution.had_previous_response_id); + assert_eq!(resolution.changed, 2); + assert_eq!(one_branch["input"][0]["content"], "branch a"); + assert_eq!(one_branch["input"][1]["call_id"], "call_a"); + assert_eq!(one_branch["input"][2]["call_id"], "call_a"); + history + .record_response_scoped_with_metadata( + scope, + ResponseOrigin::Local, + true, + &one_branch, + &json!({ + "id":"resp_after_fallback","status":"completed", + "output":[{ + "type":"message","role":"assistant", + "content":[{"type":"output_text","text":"done"}] + }] + }), + ) + .await; + let mut switched_provider = json!({ + "previous_response_id":"resp_after_fallback", + "input":"next" + }); + let switched = history + .materialize_request_scoped(scope, true, &mut switched_provider) + .await; + assert!(switched.previous_materialized); + assert!(switched_provider.get("previous_response_id").is_none()); + assert_eq!(switched_provider["input"][0]["content"], "branch a"); + + let mut mixed_branches = json!({ + "input":[ + {"type":"function_call_output","call_id":"call_a","output":"a"}, + {"type":"function_call_output","call_id":"call_b","output":"b"} + ] + }); + assert_eq!( + history + .enrich_request_scoped(scope, true, &mut mixed_branches) + .await, + 0 + ); + assert_eq!(mixed_branches["input"].as_array().unwrap().len(), 2); + + let mut unrelated_to_previous = json!({ + "previous_response_id":"resp_a", + "input":[{ + "type":"function_call_output","call_id":"call_b","output":"b" + }] + }); + history + .materialize_request_scoped(scope, true, &mut unrelated_to_previous) + .await; + let input = unrelated_to_previous["input"].as_array().unwrap(); + assert!(input.iter().any(|item| item["call_id"] == "call_a")); + assert!(!input + .iter() + .any(|item| { item["type"] == "function_call" && item["call_id"] == "call_b" })); + assert!(crate::protocol::openai_responses::decode_request(&unrelated_to_previous).is_err()); +} + +#[tokio::test] +async fn previous_response_is_resolved_and_materialized_without_new_input() { + let history = CodexHistoryStore::default(); + let scope = "session-no-input"; + history + .record_response_scoped_with_metadata( + scope, + ResponseOrigin::Native("provider-a".to_string()), + true, + &json!({ + "input":[{"type":"message","role":"user","content":"first"}] + }), + &json!({ + "id":"resp_no_input","status":"completed", + "output":[{ + "type":"message","id":"msg_no_input","role":"assistant", + "content":[{"type":"output_text","text":"answer"}] + }] + }), + ) + .await; + let mut next = json!({"previous_response_id":"resp_no_input"}); + + let resolution = history + .materialize_request_scoped(scope, true, &mut next) + .await; + assert!(resolution.had_previous_response_id); + assert!(resolution.previous_found); + assert!(resolution.previous_materialized); + assert_eq!( + resolution.previous_origin, + Some(ResponseOrigin::Native("provider-a".to_string())) + ); + assert!(next.get("previous_response_id").is_none()); + let input = next["input"].as_array().unwrap(); + assert_eq!(input.len(), 2); + assert_eq!(input[0]["content"], "first"); + assert_eq!(input[1]["content"][0]["text"], "answer"); +} diff --git a/src-tauri/src/protocol/codex_history/tests_native.rs b/src-tauri/src/protocol/codex_history/tests_native.rs new file mode 100644 index 0000000..6caa3c8 --- /dev/null +++ b/src-tauri/src/protocol/codex_history/tests_native.rs @@ -0,0 +1,163 @@ +use super::*; +use serde_json::json; + +#[tokio::test] +async fn incomplete_response_remains_resumable_history() { + let history = CodexHistoryStore::default(); + let scope = "session-incomplete"; + history + .record_response_scoped_with_metadata( + scope, + ResponseOrigin::Local, + true, + &json!({ + "input":[{"type":"message","role":"user","content":"write a lot"}] + }), + &json!({ + "id":"resp_incomplete","status":"incomplete", + "incomplete_details":{"reason":"max_output_tokens"}, + "output":[{ + "type":"message","id":"msg_partial","role":"assistant", + "content":[{"type":"output_text","text":"partial"}] + }] + }), + ) + .await; + let mut next = json!({ + "previous_response_id":"resp_incomplete", + "input":[{"type":"message","role":"user","content":"continue"}] + }); + + let resolution = history + .materialize_request_scoped(scope, true, &mut next) + .await; + assert!(resolution.previous_materialized); + assert!(next.get("previous_response_id").is_none()); + let decoded = crate::protocol::openai_responses::decode_request(&next).unwrap(); + assert_eq!( + decoded + .messages + .iter() + .map(|message| message.content_as_text()) + .collect::>(), + vec!["write a lot", "partial", "continue"] + ); +} + +#[tokio::test] +async fn metadata_recording_rejects_non_resumable_terminals() { + let history = CodexHistoryStore::default(); + let request = json!({ + "input":[{"type":"message","role":"user","content":"hello"}] + }); + for response in [ + json!({"id":"resp_failed","status":"failed","output":[]}), + json!({"id":"resp_partial","output":[]}), + json!({ + "id":"resp_compaction","object":"response.compaction","status":"completed", + "output":[{"type":"compaction","encrypted_content":"opaque"}] + }), + ] { + assert_eq!( + history + .record_response_scoped_with_metadata( + "session-terminal", + ResponseOrigin::Native("provider-a".to_string()), + true, + &request, + &response, + ) + .await, + 0 + ); + assert!(history + .response_metadata("session-terminal", response["id"].as_str().unwrap()) + .await + .is_none()); + } +} + +#[tokio::test] +async fn unsupported_output_and_owner_only_calls_never_become_portable_fallback() { + let history = CodexHistoryStore::default(); + let scope = "session-partial"; + history + .record_response_scoped_with_metadata( + scope, + ResponseOrigin::Native("provider-a".to_string()), + true, + &json!({"input":[{"type":"message","role":"user","content":"look"}]}), + &json!({ + "id":"resp_unsupported","object":"response","status":"completed", + "output":[{"type":"computer_call","id":"computer_1"}] + }), + ) + .await; + assert_eq!( + history + .response_metadata(scope, "resp_unsupported") + .await + .unwrap(), + ResponseMetadata { + origin: ResponseOrigin::Native("provider-a".to_string()), + materializable: false, + } + ); + + history + .record_response_scoped_with_metadata( + scope, + ResponseOrigin::Native("provider-a".to_string()), + false, + &json!({ + "previous_response_id":"missing-prefix", + "input":[{"type":"message","role":"user","content":"run"}] + }), + &json!({ + "id":"resp_owner_call","status":"completed", + "output":[{ + "type":"function_call","call_id":"call_owner_only", + "name":"shell","arguments":"{}" + }] + }), + ) + .await; + let mut fallback = json!({ + "input":[{ + "type":"function_call_output","call_id":"call_owner_only","output":"ok" + }] + }); + assert_eq!( + history + .enrich_request_scoped(scope, true, &mut fallback) + .await, + 0 + ); + assert_eq!(fallback["input"].as_array().unwrap().len(), 1); + + history + .record_response_scoped_with_metadata( + scope, + ResponseOrigin::Native("provider-a".to_string()), + true, + &json!({ + "input":[{"type":"compaction","encrypted_content":"opaque-prefix"}] + }), + &json!({ + "id":"resp_compacted_input","status":"completed", + "output":[{ + "type":"message","role":"assistant", + "content":[{"type":"output_text","text":"answer"}] + }] + }), + ) + .await; + assert!( + !history + .response_metadata(scope, "resp_compacted_input") + .await + .unwrap() + .materializable + ); +} + diff --git a/src-tauri/src/protocol/codex_history/types.rs b/src-tauri/src/protocol/codex_history/types.rs new file mode 100644 index 0000000..44c4aac --- /dev/null +++ b/src-tauri/src/protocol/codex_history/types.rs @@ -0,0 +1,82 @@ +// Cache record types and the bounded store handle: what a recorded Responses turn keeps, plus the +// reverse indexes `resolve` consults when `previous_response_id` is absent or stale. + +use serde_json::Value; +use std::collections::{HashMap, VecDeque}; +use tokio::sync::RwLock; + +pub(super) const MAX_CACHED_RESPONSES: usize = 512; +// Count-bounding alone is not enough once every entry carries the cumulative transcript: a long +// conversation would otherwise make the cache grow quadratically. This is a logical serialized +// size ceiling (including the duplicated call lookup values), which keeps resident memory in the +// same order of magnitude while still leaving ample room for large model contexts. +pub(super) const MAX_CACHED_HISTORY_BYTES: usize = 32 * 1024 * 1024; + +pub(super) type ScopedResponseId = (String, String); +pub(super) type ScopedCallId = (String, String); + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub enum ResponseOrigin { + #[default] + Local, + Native(String), +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ResponseMetadata { + pub origin: ResponseOrigin, + pub materializable: bool, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct HistoryResolution { + pub changed: usize, + pub had_previous_response_id: bool, + pub previous_found: bool, + pub previous_materialized: bool, + pub previous_origin: Option, +} + +#[derive(Debug, Clone, Default)] +pub(super) struct CachedResponse { + /// Full model-visible input used to create this response. For an incremental + /// `previous_response_id` request this already includes every earlier request and response. + pub(super) request_input: Vec, + pub(super) output: Vec, + pub(super) calls_by_id: HashMap, + pub(super) call_order: Vec, + pub(super) serialized_bytes: usize, + pub(super) origin: ResponseOrigin, + pub(super) materializable: bool, +} + +#[derive(Debug, Default)] +pub(super) struct HistoryInner { + pub(super) responses: HashMap, + pub(super) response_order: VecDeque, + /// Reverse index used only when `previous_response_id` is absent or stale. + /// A fallback is safe only when a call id resolves to exactly one response. + pub(super) call_index: HashMap>, + pub(super) cached_bytes: usize, +} + +#[derive(Debug, Clone, Default)] +pub(super) struct CachedLookup { + pub(super) previous: Option, + pub(super) fallback: CachedResponse, +} + +/// Thread-safe, bounded Responses conversation-history store. +#[derive(Debug, Default)] +pub struct CodexHistoryStore { + pub(super) inner: RwLock, +} + +impl CachedLookup { + pub(super) fn call(&self, call_id: &str) -> Option<&Value> { + self.previous + .as_ref() + .and_then(|previous| previous.calls_by_id.get(call_id)) + .or_else(|| self.fallback.calls_by_id.get(call_id)) + } +} diff --git a/src-tauri/src/protocol/mod.rs b/src-tauri/src/protocol/mod.rs index 11f14d1..27d8a99 100644 --- a/src-tauri/src/protocol/mod.rs +++ b/src-tauri/src/protocol/mod.rs @@ -16,523 +16,20 @@ #![allow(dead_code)] pub mod anthropic; +mod codec; pub mod codex_history; pub mod openai_chat_client; pub mod openai_responses; +mod signatures; pub mod stream; - -use axum::http::Uri; - -/// A wire protocol a request or provider speaks. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum Wire { - Anthropic, - OpenAiChat, - OpenAiResponses, -} - -impl Wire { - /// The provider's declared protocol (config `protocol` field). Unknown / absent → Anthropic, - /// which is today's passthrough default. - pub fn from_provider(s: Option<&str>) -> Wire { - match s { - Some("openai-chat") => Wire::OpenAiChat, - Some("openai-responses") => Wire::OpenAiResponses, - _ => Wire::Anthropic, - } - } - - /// The client's protocol, inferred from the inbound request path. Claude Code hits - /// `/v1/messages`; an OpenAI/Codex client hits `/v1/chat/completions` or `/v1/responses`. - pub fn from_request_path(uri: &Uri) -> Wire { - let p = uri.path().trim_end_matches('/'); - if p.ends_with("/responses") || p.ends_with("/responses/compact") { - Wire::OpenAiResponses - } else if p.contains("/chat/completions") { - Wire::OpenAiChat - } else { - Wire::Anthropic - } - } - - /// Short human label for exchange records / monitor UI. - pub fn label(self) -> &'static str { - match self { - Wire::Anthropic => "anthropic", - Wire::OpenAiChat => "openai-chat", - Wire::OpenAiResponses => "openai-responses", - } - } - - /// The bare endpoint appended to the provider's configured baseUrl. - pub fn endpoint_path(self) -> &'static str { - match self { - Wire::Anthropic => "/messages", - Wire::OpenAiChat => "/chat/completions", - Wire::OpenAiResponses => "/responses", - } - } - - /// Full upstream URL, treating the configured baseUrl as authoritative. - pub fn upstream_url(self, base_url: &str) -> String { - let base = base_url.trim_end_matches('/'); - format!("{}{}", base, self.endpoint_path()) - } - - /// Compatibility URL for configurations created when ccbud implicitly inserted `/v1`. - /// A versioned baseUrl (`v1`, `v4`, `v1beta`, …), or Google's `/openai` compatibility root, - /// must never receive another version segment. - pub fn v1_fallback_url(self, base_url: &str) -> Option { - let base = base_url.trim_end_matches('/'); - if base_url_has_version_suffix(base_url) - || (self == Wire::OpenAiChat && base.ends_with("/openai")) - { - return None; - } - Some(format!("{}/v1{}", base, self.endpoint_path())) - } - - /// Match only the three request endpoints that may be safely rebased onto a provider URL. - /// Models, count_tokens, HEAD, and unknown routes keep the generic passthrough path. - pub fn from_request_endpoint(path: &str) -> Option { - match path.trim_end_matches('/') { - "/messages" | "/v1/messages" => Some(Wire::Anthropic), - "/chat/completions" | "/v1/chat/completions" => Some(Wire::OpenAiChat), - "/responses" | "/v1/responses" | "/responses/compact" | "/v1/responses/compact" => { - Some(Wire::OpenAiResponses) - } - _ => None, - } - } - - pub fn request_endpoint_path(self, inbound_path: &str) -> &'static str { - if self == Wire::OpenAiResponses - && inbound_path.trim_end_matches('/').ends_with("/responses/compact") - { - "/responses/compact" - } else { - self.endpoint_path() - } - } - - pub fn upstream_url_for_request(self, base_url: &str, inbound_path: &str) -> String { - let base = base_url.trim_end_matches('/'); - format!("{}{}", base, self.request_endpoint_path(inbound_path)) - } - - pub fn v1_fallback_url_for_request( - self, - base_url: &str, - inbound_path: &str, - ) -> Option { - let base = base_url.trim_end_matches('/'); - if base_url_has_version_suffix(base_url) - || (self == Wire::OpenAiChat && base.ends_with("/openai")) - { - return None; - } - Some(format!("{}/v1{}", base, self.request_endpoint_path(inbound_path))) - } -} - -fn base_url_has_version_suffix(base_url: &str) -> bool { - let clean = base_url - .split(['?', '#']) - .next() - .unwrap_or(base_url) - .trim_end_matches('/'); - let after_authority = clean - .split_once("://") - .map(|(_, rest)| rest) - .unwrap_or(clean); - let Some((_, path)) = after_authority.split_once('/') else { - return false; - }; - let Some(segment) = path.rsplit('/').find(|segment| !segment.is_empty()) else { - return false; - }; - let mut chars = segment.chars(); - matches!(chars.next(), Some('v' | 'V')) - && matches!(chars.next(), Some(c) if c.is_ascii_digit()) -} - -/// Statuses commonly used by upstreams for an unrecognized or unsupported endpoint path. -/// Authentication, validation, payload-size, and rate-limit errors intentionally do not qualify. -pub fn should_try_v1_fallback(status: u16) -> bool { - matches!(status, 400 | 404 | 405) -} - -use llm_connector::core::Protocol; -use llm_connector::protocols::adapters::anthropic::AnthropicProtocol; -use llm_connector::protocols::adapters::openai::OpenAIProtocol; -use llm_connector::types::{ChatRequest, ChatResponse, ToolCall}; -use serde_json::{json, Value}; - -/// Unique id for a synthesized response ("msg_ccbud__"). Clients persist these ids into -/// their history, and usage analytics de-dupes assistant messages BY id — a constant fallback id -/// would collapse every translated turn into a single counted request. -pub fn uid(prefix: &str) -> String { - use std::sync::atomic::{AtomicU64, Ordering}; - static N: AtomicU64 = AtomicU64::new(0); - let ms = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_millis()) - .unwrap_or(0); - format!("{}_{}_{}", prefix, ms, N.fetch_add(1, Ordering::Relaxed)) -} - -/// Extract Gemini's opaque thought signature from its OpenAI-compatible wire location, or from -/// an internal/native spelling encountered while translating. The canonical OpenAI compatibility -/// shape is `extra_content.google.thought_signature`. -pub(crate) fn json_thought_signature(value: &Value) -> Option { - value - .pointer("/extra_content/google/thought_signature") - .and_then(Value::as_str) - .filter(|s| !s.is_empty()) - .or_else(|| value.get("thought_signature").and_then(Value::as_str).filter(|s| !s.is_empty())) - .or_else(|| value.pointer("/function/thought_signature").and_then(Value::as_str).filter(|s| !s.is_empty())) - .map(str::to_string) -} - -/// Read the signature from the llm-connector IR. The crate supports both placements for native -/// Gemini, so accept either while keeping a single canonical wire representation at the edge. -pub(crate) fn tool_call_thought_signature(call: &ToolCall) -> Option { - call.thought_signature - .as_deref() - .filter(|s| !s.is_empty()) - .or_else(|| call.function.thought_signature.as_deref().filter(|s| !s.is_empty())) - .map(str::to_string) -} - -fn strip_internal_thought_signature(call: &mut Value) { - let Some(call_obj) = call.as_object_mut() else { return }; - call_obj.remove("thought_signature"); - if let Some(function) = call_obj.get_mut("function").and_then(Value::as_object_mut) { - function.remove("thought_signature"); - } -} - -fn set_google_thought_signature(call: &mut Value, signature: &str) { - strip_internal_thought_signature(call); - call["extra_content"]["google"]["thought_signature"] = json!(signature); -} - -/// llm-connector serializes its internal signature fields literally. Rewrite them into Gemini's -/// OpenAI-compatible `extra_content.google.thought_signature` before forwarding. -fn normalize_openai_request_thought_signatures(body: &mut Value) { - let Some(messages) = body.get_mut("messages").and_then(Value::as_array_mut) else { return }; - for message in messages { - let Some(calls) = message.get_mut("tool_calls").and_then(Value::as_array_mut) else { continue }; - for call in calls { - if let Some(signature) = json_thought_signature(call) { - set_google_thought_signature(call, &signature); - } - } - } -} - -/// Thinking chat upstreams (Kimi/Moonshot, DeepSeek, …) require every assistant message that -/// carries `tool_calls` to also carry a non-empty `reasoning_content`, and answer -/// "reasoning_content is missing in assistant tool call message" otherwise. Real reasoning is -/// bridged from the client history where available (thinking blocks, Responses reasoning items); -/// this is the last-resort placeholder for turns whose reasoning didn't survive the wire. -/// Providers without the requirement ignore the extra field. -fn ensure_chat_tool_call_reasoning_content(body: &mut Value) { - let Some(messages) = body.get_mut("messages").and_then(Value::as_array_mut) else { return }; - for message in messages { - let has_tool_calls = message.get("role").and_then(Value::as_str) == Some("assistant") - && message.get("tool_calls").and_then(Value::as_array).is_some_and(|c| !c.is_empty()); - if !has_tool_calls { - continue; - } - let missing = message - .get("reasoning_content") - .and_then(Value::as_str) - .map_or(true, |s| s.trim().is_empty()); - if missing { - message["reasoning_content"] = json!("tool call"); - } - } -} - -/// Gemini/OpenRouter/Cloudflare return provider metadata in `extra_content`, which serde ignores -/// when llm-connector parses a standard OpenAI ToolCall. Copy the opaque signature into the -/// crate's internal field before parsing; the original response remains otherwise unchanged. -fn normalize_openai_response_thought_signatures(body: &mut Value) { - let Some(choices) = body.get_mut("choices").and_then(Value::as_array_mut) else { return }; - for choice in choices { - let Some(calls) = choice - .get_mut("message") - .and_then(|message| message.get_mut("tool_calls")) - .and_then(Value::as_array_mut) - else { continue }; - for call in calls { - if let Some(signature) = json_thought_signature(call) { - call["thought_signature"] = json!(signature); - } - } - } -} - -/// Decode an inbound client request (in its wire format) into the unified IR. -pub fn decode_client_request(client: Wire, body: &Value) -> Result { - match client { - Wire::Anthropic => anthropic::decode_request(body), - Wire::OpenAiChat => openai_chat_client::decode_request(body), - // Hand-rolled (not the crate's responses_request_to_chat_request, which drops - // function_call / function_call_output / assistant items and rejects flattened tools — - // fatal for Codex). - Wire::OpenAiResponses => openai_responses::decode_request(body), - } -} - -/// Encode the IR into the upstream provider's request BODY. `outgoing_model` is the provider's real -/// model (gateway already resolved it); `stream` requests SSE from the upstream. For the first cut -/// we translate cross-protocol responses buffered, so callers pass stream=false here and synthesize -/// the client SSE from the full response (true incremental transcoding is P2). -pub fn encode_upstream_request( - provider: Wire, - ir: &ChatRequest, - outgoing_model: &str, - stream: bool, -) -> Result { - let mut ir = ir.clone(); - ir.model = outgoing_model.to_string(); - ir.stream = Some(stream); - match provider { - Wire::OpenAiChat => { - let mut body = OpenAIProtocol::new("") - .build_chat_request_body(&ir) - .map_err(|e| e.to_string())?; - let lower_model = outgoing_model.to_ascii_lowercase(); - if lower_model.contains("gemini") { - normalize_openai_request_thought_signatures(&mut body); - } - // GLM's OpenAI-compatible coding endpoint uses its native `thinking` switch rather - // than the OpenAI `reasoning_effort` field emitted by the generic connector. - if lower_model.contains("glm") || lower_model.contains("zhipu") || lower_model.contains("z-ai") { - if let Some(object) = body.as_object_mut() { - object.remove("reasoning_effort"); - } - if ir.enable_thinking == Some(true) { - body["thinking"] = json!({ "type": "enabled" }); - } - } - ensure_chat_tool_call_reasoning_content(&mut body); - Ok(body) - } - Wire::OpenAiResponses => Ok(openai_responses::encode_request(&ir, outgoing_model, stream)), - // Reverse direction: an OpenAI/Codex client → an Anthropic upstream. The crate encodes the - // IR into an Anthropic Messages request (tool_calls→tool_use blocks, etc.). Anthropic - // requires max_tokens; OpenAI-family clients (Codex) usually omit it and the crate's - // fallback (1024) truncates agent turns — default to a workable ceiling instead. - Wire::Anthropic => { - if ir.max_tokens.is_none() { - ir.max_tokens = Some(8192); - } - AnthropicProtocol::new("") - .build_chat_request_body(&ir) - .map_err(|e| e.to_string()) - } - } -} - -/// Decode an upstream provider RESPONSE (its wire format, buffered) into the IR. -pub fn decode_upstream_response(provider: Wire, text: &str) -> Result { - match provider { - Wire::OpenAiChat => { - let normalized = match serde_json::from_str::(text) { - Ok(mut body) => { - normalize_openai_response_thought_signatures(&mut body); - body.to_string() - } - Err(_) => text.to_string(), - }; - OpenAIProtocol::new("").parse_response(&normalized).map_err(|e| e.to_string()) - } - Wire::OpenAiResponses => openai_responses::decode_response(text), - Wire::Anthropic => AnthropicProtocol::new("").parse_response(text).map_err(|e| e.to_string()), - } -} - -/// Encode the IR response back to the client's wire format as a buffered JSON body. -pub fn encode_client_response(client: Wire, ir: &ChatResponse, client_model: &str) -> Result { - match client { - Wire::Anthropic => Ok(anthropic::encode_response(ir, client_model)), - Wire::OpenAiChat => Ok(openai_chat_client::encode_response(ir, client_model)), - // Hand-rolled (not the crate's chat_response_to_responses_response, which drops - // tool_calls from the output — Codex would never see a function call). - Wire::OpenAiResponses => Ok(openai_responses::encode_response(ir, client_model)), - } -} - -/// Whether we have an incremental (event-by-event) stream transcoder from `provider` to `client`. -/// When false, cross-protocol streaming falls back to buffer-upstream + synthesize-client-SSE. -pub fn can_transcode_stream(provider: Wire, client: Wire) -> bool { - stream::Transcoder::supports(provider, client) -} - -/// Encode the IR response to the client's wire format as a full SSE stream body (used when the -/// client asked to stream but we translated the upstream buffered — synthesize the event sequence). -pub fn encode_client_response_sse(client: Wire, ir: &ChatResponse, client_model: &str) -> Result { - match client { - Wire::Anthropic => Ok(anthropic::encode_response_sse(ir, client_model)), - Wire::OpenAiChat => Ok(openai_chat_client::encode_response_sse(ir, client_model)), - Wire::OpenAiResponses => Ok(openai_responses::encode_response_sse(ir, client_model)), - } -} - #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn upstream_urls_respect_the_configured_base() { - let cases = [ - (Wire::Anthropic, "/messages"), - (Wire::OpenAiChat, "/chat/completions"), - (Wire::OpenAiResponses, "/responses"), - ]; - for (wire, endpoint) in cases { - for base in [ - "https://example.com", - "https://example.com/v1", - "https://example.com/v4", - "https://generativelanguage.googleapis.com/v1beta/openai", - ] { - assert_eq!(wire.upstream_url(base), format!("{}{}", base, endpoint)); - } - } - } - - #[test] - fn v1_fallback_is_only_offered_for_unversioned_bases() { - assert_eq!( - Wire::OpenAiChat.v1_fallback_url("https://example.com/api"), - Some("https://example.com/api/v1/chat/completions".to_string()) - ); - for base in [ - "https://example.com/v1", - "https://example.com/v4/", - "https://example.com/v1beta", - "https://example.com/V2alpha", - "https://generativelanguage.googleapis.com/v1beta/openai", - ] { - assert_eq!(Wire::OpenAiChat.v1_fallback_url(base), None, "{base}"); - } - } - - #[test] - fn canonical_request_endpoints_exclude_auxiliary_routes() { - assert_eq!(Wire::from_request_endpoint("/v1/messages"), Some(Wire::Anthropic)); - assert_eq!(Wire::from_request_endpoint("/v1/chat/completions"), Some(Wire::OpenAiChat)); - assert_eq!(Wire::from_request_endpoint("/v1/responses"), Some(Wire::OpenAiResponses)); - assert_eq!( - Wire::from_request_endpoint("/v1/responses/compact"), - Some(Wire::OpenAiResponses) - ); - assert_eq!( - Wire::OpenAiResponses.upstream_url_for_request( - "https://example.com/v1", - "/v1/responses/compact", - ), - "https://example.com/v1/responses/compact" - ); - assert_eq!(Wire::from_request_endpoint("/v1/messages/count_tokens"), None); - assert_eq!(Wire::from_request_endpoint("/v1/models"), None); - } - - #[test] - fn v1_fallback_statuses_exclude_non_path_errors() { - for status in [400, 404, 405] { - assert!(should_try_v1_fallback(status)); - } - for status in [401, 403, 413, 415, 422, 429, 500] { - assert!(!should_try_v1_fallback(status)); - } - } - - // Kimi/Moonshot and DeepSeek thinking models 400 on assistant tool-call history missing - // `reasoning_content`: real reasoning must survive the Responses→chat bridge, and turns whose - // reasoning didn't survive get the placeholder. - #[test] - fn chat_bodies_backfill_tool_call_reasoning() { - let codex = json!({ - "model": "m", - "input": [ - { "type": "message", "role": "user", "content": [{ "type": "input_text", "text": "go" }] }, - { "type": "function_call", "call_id": "c1", "name": "shell", "arguments": "{}" }, - { "type": "function_call_output", "call_id": "c1", "output": "ok" }, - { "type": "reasoning", "summary": [{ "type": "summary_text", "text": "real thoughts" }] }, - { "type": "function_call", "call_id": "c2", "name": "shell", "arguments": "{}" }, - { "type": "function_call_output", "call_id": "c2", "output": "ok" } - ] - }); - let ir = decode_client_request(Wire::OpenAiResponses, &codex).unwrap(); - let body = encode_upstream_request(Wire::OpenAiChat, &ir, "kimi-k2-thinking", true).unwrap(); - let assistants: Vec<_> = body["messages"].as_array().unwrap().iter() - .filter(|m| m["role"] == "assistant").collect(); - assert_eq!(assistants.len(), 2); - // step 1 lost its reasoning → placeholder; step 2's bridged reasoning is preserved - assert_eq!(assistants[0]["reasoning_content"], "tool call"); - assert_eq!(assistants[1]["reasoning_content"], "real thoughts"); - } - - #[test] - fn glm_chat_uses_native_thinking_switch() { - let codex = json!({ - "model": "gpt-5.4", - "input": [{ - "type": "message", - "role": "user", - "content": [{ "type": "input_text", "text": "inspect" }] - }], - "reasoning": { "effort": "ultra" } - }); - let ir = decode_client_request(Wire::OpenAiResponses, &codex).unwrap(); - let body = encode_upstream_request(Wire::OpenAiChat, &ir, "glm-5.2", true).unwrap(); - assert_eq!(body["thinking"]["type"], "enabled"); - assert!(body.get("reasoning_effort").is_none()); - } - - #[test] - fn gemini_thought_signature_maps_between_openai_wire_and_ir() { - let signature = "sig-regression-abc"; - let upstream_response = json!({ - "id": "chatcmpl-gemini", "object": "chat.completion", "created": 1, - "model": "google/gemini-3-flash-preview", - "choices": [{ "index": 0, "finish_reason": "tool_calls", "message": { - "role": "assistant", "content": Value::Null, - "tool_calls": [{ - "id": "default_api:Bash", "type": "function", - "function": { "name": "default_api:Bash", "arguments": "{\"command\":\"pwd\"}" }, - "extra_content": { "google": { "thought_signature": signature } } - }] - }}], - "usage": { "prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15 } - }); - - let ir = decode_upstream_response(Wire::OpenAiChat, &upstream_response.to_string()).unwrap(); - let call = &ir.choices[0].message.tool_calls.as_ref().unwrap()[0]; - assert_eq!(tool_call_thought_signature(call).as_deref(), Some(signature)); - assert_eq!(json_thought_signature(&json!({ - "thought_signature": "", "function": { "thought_signature": signature } - })).as_deref(), Some(signature)); - - let mut message = llm_connector::types::Message::new( - llm_connector::types::Role::Assistant, - vec![], - ); - message.tool_calls = Some(vec![call.clone()]); - let next_ir = ChatRequest::new("gemini").with_messages(vec![message]); - let outgoing = encode_upstream_request( - Wire::OpenAiChat, &next_ir, "google/gemini-3-flash-preview", false, - ).unwrap(); - let assistant = outgoing["messages"].as_array().unwrap().iter() - .find(|message| message["role"] == "assistant").unwrap(); - let outgoing_call = &assistant["tool_calls"][0]; - assert_eq!(outgoing_call["extra_content"]["google"]["thought_signature"], signature); - assert!(outgoing_call.get("thought_signature").is_none()); - assert!(outgoing_call["function"].get("thought_signature").is_none()); - } -} +mod tests; +mod wire; + +pub use codec::{ + can_transcode_stream, decode_client_request, decode_upstream_response, encode_client_response, + encode_client_response_sse, encode_upstream_request, +}; +pub use signatures::uid; +pub(crate) use signatures::{json_thought_signature, tool_call_thought_signature}; +pub use wire::{should_try_v1_fallback, Wire}; diff --git a/src-tauri/src/protocol/openai_chat_client.rs b/src-tauri/src/protocol/openai_chat_client.rs deleted file mode 100644 index 6d52e24..0000000 --- a/src-tauri/src/protocol/openai_chat_client.rs +++ /dev/null @@ -1,237 +0,0 @@ -// OpenAI Chat CLIENT-side codec (P4 reverse direction): when an OpenAI/Codex-style client hits the -// gateway at /v1/chat/completions and the provider is Anthropic, we decode the client's Chat request -// into the IR and re-encode the IR response back to Chat Completions shape. The Anthropic upstream -// side is handled by the crate's AnthropicProtocol. - -use llm_connector::types::{ - ChatRequest, ChatResponse, FunctionCall, Message, MessageBlock, Role, Tool, ToolCall, -}; -use serde_json::{json, Value}; - -fn content_to_blocks(content: &Value) -> Vec { - if let Some(s) = content.as_str() { - return if s.is_empty() { vec![] } else { vec![MessageBlock::text(s)] }; - } - let arr = match content.as_array() { - Some(a) => a, - None => return vec![], - }; - let mut out = vec![]; - for part in arr { - match part.get("type").and_then(|t| t.as_str()) { - Some("text") => { - if let Some(t) = part.get("text").and_then(|v| v.as_str()) { - out.push(MessageBlock::text(t)); - } - } - Some("image_url") => { - if let Some(u) = part.get("image_url").and_then(|i| i.get("url")).and_then(|v| v.as_str()) { - out.push(MessageBlock::image_url(u)); - } - } - _ => {} - } - } - out -} - -/// Decode an OpenAI Chat Completions REQUEST json into the IR. -pub fn decode_request(req: &Value) -> Result { - let model = req.get("model").and_then(|v| v.as_str()).unwrap_or("").to_string(); - let mut messages: Vec = vec![]; - for m in req.get("messages").and_then(|v| v.as_array()).cloned().unwrap_or_default().iter() { - let role = match m.get("role").and_then(|v| v.as_str()) { - Some("system") | Some("developer") => Role::System, - Some("assistant") => Role::Assistant, - Some("tool") => Role::Tool, - _ => Role::User, - }; - let content = m.get("content").cloned().unwrap_or(Value::Null); - let mut msg = Message::new(role, content_to_blocks(&content)); - if let Some(name) = m.get("name").and_then(|v| v.as_str()) { - msg.name = Some(name.to_string()); - } - if let Some(tcid) = m.get("tool_call_id").and_then(|v| v.as_str()) { - msg.tool_call_id = Some(tcid.to_string()); - } - if let Some(tcs) = m.get("tool_calls").and_then(|v| v.as_array()) { - let calls: Vec = tcs - .iter() - .map(|tc| ToolCall { - id: tc.get("id").and_then(|v| v.as_str()).unwrap_or("").to_string(), - call_type: "function".to_string(), - function: FunctionCall { - name: tc.get("function").and_then(|f| f.get("name")).and_then(|v| v.as_str()).unwrap_or("").to_string(), - arguments: tc.get("function").and_then(|f| f.get("arguments")).and_then(|v| v.as_str()).unwrap_or("{}").to_string(), - thought_signature: None, - }, - index: None, - thought_signature: None, - }) - .collect(); - if !calls.is_empty() { - msg.tool_calls = Some(calls); - } - } - messages.push(msg); - } - - let mut cr = ChatRequest::new(model).with_messages(messages); - if let Some(mt) = req.get("max_tokens").and_then(|v| v.as_u64()) { - cr = cr.with_max_tokens(mt as u32); - } - if let Some(mt) = req.get("max_completion_tokens").and_then(|v| v.as_u64()) { - cr = cr.with_max_tokens(mt as u32); - } - if let Some(t) = req.get("temperature").and_then(|v| v.as_f64()) { - cr = cr.with_temperature(t as f32); - } - if let Some(p) = req.get("top_p").and_then(|v| v.as_f64()) { - cr = cr.with_top_p(p as f32); - } - if req.get("stream").and_then(|v| v.as_bool()).unwrap_or(false) { - cr = cr.with_stream(true); - } - if let Some(tools) = req.get("tools").and_then(|v| v.as_array()) { - let ts: Vec = tools - .iter() - .filter_map(|t| { - let f = t.get("function")?; - let name = f.get("name").and_then(|v| v.as_str())?; - let desc = f.get("description").and_then(|v| v.as_str()).map(|s| s.to_string()); - let params = f.get("parameters").cloned().unwrap_or_else(|| json!({ "type": "object" })); - Some(Tool::function(name, desc, params)) - }) - .collect(); - if !ts.is_empty() { - cr = cr.with_tools(ts); - } - } - Ok(cr) -} - -/// IR ChatResponse → OpenAI Chat Completions RESPONSE json. -pub fn encode_response(resp: &ChatResponse, client_model: &str) -> Value { - let choice = resp.choices.first(); - let msg = choice.map(|c| &c.message); - let text = { - let t = msg.map(|m| m.content_as_text()).unwrap_or_default(); - if t.is_empty() { resp.content.clone() } else { t } - }; - let mut message = json!({ "role": "assistant", "content": if text.is_empty() { Value::Null } else { json!(text) } }); - // Normalize the finish reason to OpenAI vocabulary (the IR may carry an Anthropic stop_reason - // when the upstream was Anthropic). - let mut finish = match choice.and_then(|c| c.finish_reason.as_deref()) { - Some("end_turn") | Some("stop") | None => "stop", - Some("max_tokens") | Some("length") => "length", - Some("tool_use") | Some("tool_calls") => "tool_calls", - Some(other) => other, - } - .to_string(); - if let Some(m) = msg { - if let Some(calls) = &m.tool_calls { - if !calls.is_empty() { - let tcs: Vec = calls - .iter() - .enumerate() - .map(|(i, tc)| json!({ - "index": i, - "id": if tc.id.is_empty() { format!("call_{}", i) } else { tc.id.clone() }, - "type": "function", - "function": { "name": tc.function.name, "arguments": tc.function.arguments }, - })) - .collect(); - message["tool_calls"] = json!(tcs); - finish = "tool_calls".to_string(); - } - } - } - let usage = resp.usage.as_ref(); - json!({ - // never a constant fallback — clients persist this id and usage de-dupes by it - "id": if resp.id.is_empty() { super::uid("chatcmpl-ccbud") } else { resp.id.clone() }, - "object": "chat.completion", - "created": 0, - "model": client_model, - "choices": [{ "index": 0, "finish_reason": finish, "message": message }], - "usage": { - "prompt_tokens": usage.map(|u| u.prompt_tokens).unwrap_or(0), - "completion_tokens": usage.map(|u| u.completion_tokens).unwrap_or(0), - "total_tokens": usage.map(|u| u.total_tokens).unwrap_or(0), - } - }) -} - -/// IR ChatResponse → OpenAI Chat SSE stream (buffered synthesize: role chunk, content chunk(s), -/// tool_call chunk(s), final finish chunk, `[DONE]`). -pub fn encode_response_sse(resp: &ChatResponse, client_model: &str) -> String { - let full = encode_response(resp, client_model); - let choice = &full["choices"][0]; - let message = &choice["message"]; - let finish = choice.get("finish_reason").and_then(|v| v.as_str()).unwrap_or("stop"); - let id = full.get("id").cloned().unwrap_or(json!("chatcmpl-ccbud")); - let chunk = |delta: Value, fin: Value| { - format!( - "data: {}\n\n", - serde_json::to_string(&json!({ - "id": id, "object": "chat.completion.chunk", "created": 0, "model": client_model, - "choices": [{ "index": 0, "delta": delta, "finish_reason": fin }], - })).unwrap_or_default() - ) - }; - let mut out = String::new(); - out.push_str(&chunk(json!({ "role": "assistant" }), Value::Null)); - if let Some(t) = message.get("content").and_then(|v| v.as_str()) { - if !t.is_empty() { - out.push_str(&chunk(json!({ "content": t }), Value::Null)); - } - } - if let Some(tcs) = message.get("tool_calls").and_then(|v| v.as_array()) { - out.push_str(&chunk(json!({ "tool_calls": tcs }), Value::Null)); - } - out.push_str(&chunk(json!({}), json!(finish))); - out.push_str("data: [DONE]\n\n"); - out -} - -#[cfg(test)] -mod tests { - use super::*; - use llm_connector::core::Protocol; - use llm_connector::protocols::adapters::anthropic::AnthropicProtocol; - - #[test] - fn chat_request_to_ir_to_anthropic_upstream() { - let chat = json!({ - "model": "gpt-x", "max_tokens": 200, - "messages": [ - { "role": "system", "content": "be nice" }, - { "role": "user", "content": "hello" } - ], - "tools": [{ "type": "function", "function": { "name": "f", "description": "d", "parameters": { "type": "object" } } }] - }); - let ir = decode_request(&chat).unwrap(); - assert_eq!(ir.messages[0].content_as_text(), "be nice"); - assert_eq!(ir.messages[1].content_as_text(), "hello"); - assert_eq!(ir.tools.as_ref().unwrap()[0].function.name, "f"); - // crate encodes IR → Anthropic upstream request (the reverse direction's upstream half) - let body = AnthropicProtocol::new("").build_chat_request_body(&ir).unwrap(); - assert!(body.get("messages").is_some()); - } - - #[test] - fn anthropic_reply_to_ir_to_chat_response() { - // crate decodes an Anthropic response → IR; we encode IR → Chat Completions for the client. - let anthropic = r#"{"id":"msg_1","type":"message","role":"assistant","model":"claude", - "content":[{"type":"text","text":"done"}],"stop_reason":"end_turn", - "usage":{"input_tokens":9,"output_tokens":4}}"#; - let ir = AnthropicProtocol::new("").parse_response(anthropic).unwrap(); - let out = encode_response(&ir, "gpt-x"); - assert_eq!(out["object"], "chat.completion"); - assert_eq!(out["model"], "gpt-x"); - assert_eq!(out["choices"][0]["message"]["content"], "done"); - assert_eq!(out["choices"][0]["finish_reason"], "stop"); - assert_eq!(out["usage"]["prompt_tokens"], 9); - assert_eq!(out["usage"]["completion_tokens"], 4); - } -} diff --git a/src-tauri/src/protocol/openai_chat_client/decode.rs b/src-tauri/src/protocol/openai_chat_client/decode.rs new file mode 100644 index 0000000..e030a88 --- /dev/null +++ b/src-tauri/src/protocol/openai_chat_client/decode.rs @@ -0,0 +1,106 @@ +// Chat Completions REQUEST json → llm-connector ChatRequest IR. + +use llm_connector::types::{ChatRequest, FunctionCall, Message, MessageBlock, Role, Tool, ToolCall}; +use serde_json::{json, Value}; + +fn content_to_blocks(content: &Value) -> Vec { + if let Some(s) = content.as_str() { + return if s.is_empty() { vec![] } else { vec![MessageBlock::text(s)] }; + } + let arr = match content.as_array() { + Some(a) => a, + None => return vec![], + }; + let mut out = vec![]; + for part in arr { + match part.get("type").and_then(|t| t.as_str()) { + Some("text") => { + if let Some(t) = part.get("text").and_then(|v| v.as_str()) { + out.push(MessageBlock::text(t)); + } + } + Some("image_url") => { + if let Some(u) = part.get("image_url").and_then(|i| i.get("url")).and_then(|v| v.as_str()) { + out.push(MessageBlock::image_url(u)); + } + } + _ => {} + } + } + out +} + +/// Decode an OpenAI Chat Completions REQUEST json into the IR. +pub fn decode_request(req: &Value) -> Result { + let model = req.get("model").and_then(|v| v.as_str()).unwrap_or("").to_string(); + let mut messages: Vec = vec![]; + for m in req.get("messages").and_then(|v| v.as_array()).cloned().unwrap_or_default().iter() { + let role = match m.get("role").and_then(|v| v.as_str()) { + Some("system") | Some("developer") => Role::System, + Some("assistant") => Role::Assistant, + Some("tool") => Role::Tool, + _ => Role::User, + }; + let content = m.get("content").cloned().unwrap_or(Value::Null); + let mut msg = Message::new(role, content_to_blocks(&content)); + if let Some(name) = m.get("name").and_then(|v| v.as_str()) { + msg.name = Some(name.to_string()); + } + if let Some(tcid) = m.get("tool_call_id").and_then(|v| v.as_str()) { + msg.tool_call_id = Some(tcid.to_string()); + } + if let Some(tcs) = m.get("tool_calls").and_then(|v| v.as_array()) { + let calls: Vec = tcs + .iter() + .map(|tc| ToolCall { + id: tc.get("id").and_then(|v| v.as_str()).unwrap_or("").to_string(), + call_type: "function".to_string(), + function: FunctionCall { + name: tc.get("function").and_then(|f| f.get("name")).and_then(|v| v.as_str()).unwrap_or("").to_string(), + arguments: tc.get("function").and_then(|f| f.get("arguments")).and_then(|v| v.as_str()).unwrap_or("{}").to_string(), + thought_signature: None, + }, + index: None, + thought_signature: None, + }) + .collect(); + if !calls.is_empty() { + msg.tool_calls = Some(calls); + } + } + messages.push(msg); + } + + let mut cr = ChatRequest::new(model).with_messages(messages); + if let Some(mt) = req.get("max_tokens").and_then(|v| v.as_u64()) { + cr = cr.with_max_tokens(mt as u32); + } + if let Some(mt) = req.get("max_completion_tokens").and_then(|v| v.as_u64()) { + cr = cr.with_max_tokens(mt as u32); + } + if let Some(t) = req.get("temperature").and_then(|v| v.as_f64()) { + cr = cr.with_temperature(t as f32); + } + if let Some(p) = req.get("top_p").and_then(|v| v.as_f64()) { + cr = cr.with_top_p(p as f32); + } + if req.get("stream").and_then(|v| v.as_bool()).unwrap_or(false) { + cr = cr.with_stream(true); + } + if let Some(tools) = req.get("tools").and_then(|v| v.as_array()) { + let ts: Vec = tools + .iter() + .filter_map(|t| { + let f = t.get("function")?; + let name = f.get("name").and_then(|v| v.as_str())?; + let desc = f.get("description").and_then(|v| v.as_str()).map(|s| s.to_string()); + let params = f.get("parameters").cloned().unwrap_or_else(|| json!({ "type": "object" })); + Some(Tool::function(name, desc, params)) + }) + .collect(); + if !ts.is_empty() { + cr = cr.with_tools(ts); + } + } + Ok(cr) +} diff --git a/src-tauri/src/protocol/openai_chat_client/encode.rs b/src-tauri/src/protocol/openai_chat_client/encode.rs new file mode 100644 index 0000000..b64e81a --- /dev/null +++ b/src-tauri/src/protocol/openai_chat_client/encode.rs @@ -0,0 +1,88 @@ +// llm-connector ChatResponse IR → Chat Completions RESPONSE json, buffered and as SSE. + +use llm_connector::types::ChatResponse; +use serde_json::{json, Value}; + +/// IR ChatResponse → OpenAI Chat Completions RESPONSE json. +pub fn encode_response(resp: &ChatResponse, client_model: &str) -> Value { + let choice = resp.choices.first(); + let msg = choice.map(|c| &c.message); + let text = { + let t = msg.map(|m| m.content_as_text()).unwrap_or_default(); + if t.is_empty() { resp.content.clone() } else { t } + }; + let mut message = json!({ "role": "assistant", "content": if text.is_empty() { Value::Null } else { json!(text) } }); + // Normalize the finish reason to OpenAI vocabulary (the IR may carry an Anthropic stop_reason + // when the upstream was Anthropic). + let mut finish = match choice.and_then(|c| c.finish_reason.as_deref()) { + Some("end_turn") | Some("stop") | None => "stop", + Some("max_tokens") | Some("length") => "length", + Some("tool_use") | Some("tool_calls") => "tool_calls", + Some(other) => other, + } + .to_string(); + if let Some(m) = msg { + if let Some(calls) = &m.tool_calls { + if !calls.is_empty() { + let tcs: Vec = calls + .iter() + .enumerate() + .map(|(i, tc)| json!({ + "index": i, + "id": if tc.id.is_empty() { format!("call_{}", i) } else { tc.id.clone() }, + "type": "function", + "function": { "name": tc.function.name, "arguments": tc.function.arguments }, + })) + .collect(); + message["tool_calls"] = json!(tcs); + finish = "tool_calls".to_string(); + } + } + } + let usage = resp.usage.as_ref(); + json!({ + // never a constant fallback — clients persist this id and usage de-dupes by it + "id": if resp.id.is_empty() { crate::protocol::uid("chatcmpl-ccbud") } else { resp.id.clone() }, + "object": "chat.completion", + "created": 0, + "model": client_model, + "choices": [{ "index": 0, "finish_reason": finish, "message": message }], + "usage": { + "prompt_tokens": usage.map(|u| u.prompt_tokens).unwrap_or(0), + "completion_tokens": usage.map(|u| u.completion_tokens).unwrap_or(0), + "total_tokens": usage.map(|u| u.total_tokens).unwrap_or(0), + } + }) +} + +/// IR ChatResponse → OpenAI Chat SSE stream (buffered synthesize: role chunk, content chunk(s), +/// tool_call chunk(s), final finish chunk, `[DONE]`). +pub fn encode_response_sse(resp: &ChatResponse, client_model: &str) -> String { + let full = encode_response(resp, client_model); + let choice = &full["choices"][0]; + let message = &choice["message"]; + let finish = choice.get("finish_reason").and_then(|v| v.as_str()).unwrap_or("stop"); + let id = full.get("id").cloned().unwrap_or(json!("chatcmpl-ccbud")); + let chunk = |delta: Value, fin: Value| { + format!( + "data: {}\n\n", + serde_json::to_string(&json!({ + "id": id, "object": "chat.completion.chunk", "created": 0, "model": client_model, + "choices": [{ "index": 0, "delta": delta, "finish_reason": fin }], + })).unwrap_or_default() + ) + }; + let mut out = String::new(); + out.push_str(&chunk(json!({ "role": "assistant" }), Value::Null)); + if let Some(t) = message.get("content").and_then(|v| v.as_str()) { + if !t.is_empty() { + out.push_str(&chunk(json!({ "content": t }), Value::Null)); + } + } + if let Some(tcs) = message.get("tool_calls").and_then(|v| v.as_array()) { + out.push_str(&chunk(json!({ "tool_calls": tcs }), Value::Null)); + } + out.push_str(&chunk(json!({}), json!(finish))); + out.push_str("data: [DONE]\n\n"); + out +} diff --git a/src-tauri/src/protocol/openai_chat_client/mod.rs b/src-tauri/src/protocol/openai_chat_client/mod.rs new file mode 100644 index 0000000..6b15046 --- /dev/null +++ b/src-tauri/src/protocol/openai_chat_client/mod.rs @@ -0,0 +1,12 @@ +// OpenAI Chat CLIENT-side codec (P4 reverse direction): when an OpenAI/Codex-style client hits the +// gateway at /v1/chat/completions and the provider is Anthropic, we decode the client's Chat request +// into the IR and re-encode the IR response back to Chat Completions shape. The Anthropic upstream +// side is handled by the crate's AnthropicProtocol. + +mod decode; +mod encode; +#[cfg(test)] +mod tests; + +pub use decode::decode_request; +pub use encode::{encode_response, encode_response_sse}; diff --git a/src-tauri/src/protocol/openai_chat_client/tests.rs b/src-tauri/src/protocol/openai_chat_client/tests.rs new file mode 100644 index 0000000..a9e9f6d --- /dev/null +++ b/src-tauri/src/protocol/openai_chat_client/tests.rs @@ -0,0 +1,40 @@ +use super::decode::decode_request; +use super::encode::encode_response; +use serde_json::json; +use llm_connector::core::Protocol; +use llm_connector::protocols::adapters::anthropic::AnthropicProtocol; + +#[test] +fn chat_request_to_ir_to_anthropic_upstream() { + let chat = json!({ + "model": "gpt-x", "max_tokens": 200, + "messages": [ + { "role": "system", "content": "be nice" }, + { "role": "user", "content": "hello" } + ], + "tools": [{ "type": "function", "function": { "name": "f", "description": "d", "parameters": { "type": "object" } } }] + }); + let ir = decode_request(&chat).unwrap(); + assert_eq!(ir.messages[0].content_as_text(), "be nice"); + assert_eq!(ir.messages[1].content_as_text(), "hello"); + assert_eq!(ir.tools.as_ref().unwrap()[0].function.name, "f"); + // crate encodes IR → Anthropic upstream request (the reverse direction's upstream half) + let body = AnthropicProtocol::new("").build_chat_request_body(&ir).unwrap(); + assert!(body.get("messages").is_some()); +} + +#[test] +fn anthropic_reply_to_ir_to_chat_response() { + // crate decodes an Anthropic response → IR; we encode IR → Chat Completions for the client. + let anthropic = r#"{"id":"msg_1","type":"message","role":"assistant","model":"claude", + "content":[{"type":"text","text":"done"}],"stop_reason":"end_turn", + "usage":{"input_tokens":9,"output_tokens":4}}"#; + let ir = AnthropicProtocol::new("").parse_response(anthropic).unwrap(); + let out = encode_response(&ir, "gpt-x"); + assert_eq!(out["object"], "chat.completion"); + assert_eq!(out["model"], "gpt-x"); + assert_eq!(out["choices"][0]["message"]["content"], "done"); + assert_eq!(out["choices"][0]["finish_reason"], "stop"); + assert_eq!(out["usage"]["prompt_tokens"], 9); + assert_eq!(out["usage"]["completion_tokens"], 4); +} diff --git a/src-tauri/src/protocol/openai_responses.rs b/src-tauri/src/protocol/openai_responses.rs deleted file mode 100644 index 34e5ffc..0000000 --- a/src-tauri/src/protocol/openai_responses.rs +++ /dev/null @@ -1,2722 +0,0 @@ -// OpenAI Responses (/v1/responses) codec — BOTH halves: -// -// provider-side (gateway → a Responses upstream): -// encode_request: IR → Responses REQUEST body -// decode_response: Responses RESPONSE → IR -// -// client-side (a Responses client, i.e. Codex with wire_api="responses", → gateway): -// decode_request: Responses REQUEST → IR -// encode_response / encode_response_sse: IR → Responses RESPONSE (json / synthesized SSE) -// -// The Responses API uses an item-based `input` array (role messages + function_call / -// function_call_output items), `instructions` for the system prompt, `max_output_tokens`, and a -// `reasoning.effort` knob. Its response is an `output` array of items. Tool definitions are -// FLATTENED at the item level (`{"type":"function","name",...}`), unlike Chat Completions. -// -// The client-side halves are hand-rolled rather than reusing llm-connector's -// responses_request_to_chat_request / chat_response_to_responses_response: the crate's versions -// silently DROP function_call / function_call_output / assistant output_text history items and -// tool_calls in responses, and reject the flattened tool form — all fatal for Codex, whose agent -// loop is tool calls end-to-end. -// -// Codex reads the turn's items ONLY from `response.output_item.done` SSE events (text deltas are -// cosmetic; the stream MUST end with `response.completed` carrying id + usage), so the synthesized -// stream emits the full added → delta → done sequence per item. - -use llm_connector::core::Protocol; -use llm_connector::protocols::adapters::openai::OpenAIProtocol; -use llm_connector::types::{ - ChatRequest, ChatResponse, FunctionCall, Message, MessageBlock, ReasoningEffort, Role, Tool, - ToolCall, ToolChoice, -}; -use serde_json::{json, Value}; -use sha1::{Digest, Sha1}; -use std::collections::{HashMap, HashSet}; - -const CUSTOM_TOOL_INPUT_FIELD: &str = "input"; -const CUSTOM_TOOL_RAW_INPUT_INSTRUCTION: &str = - "Pass the custom tool's raw input unchanged in the `input` string field."; -const APPLY_PATCH_CHAT_INSTRUCTION: &str = "For apply_patch, the first line must be `*** Begin Patch` and the final line must be an unprefixed `*** End Patch`. Exact Add File skeleton:\n*** Begin Patch\n*** Add File: path\n+content\n*** End Patch\nPrefix every added file-content line with `+`, but never prefix either boundary marker. For updates, use `*** Update File: path` with an `@@` context hunk and ` `, `-`, or `+` line prefixes; for deletion, use `*** Delete File: path`."; -const TOOL_SEARCH_CHAT_NAME: &str = "tool_search"; -const CHAT_TOOL_NAME_MAX_LEN: usize = 64; -const CHAT_TOOL_NAME_HASH_LEN: usize = 12; - -/// The Responses tool shape that a chat-compatible upstream is standing in for. -/// -/// OpenAI Chat only has flat JSON-schema functions, while current Codex requests also carry -/// freeform custom tools, tool search, and namespace tools. The translation layer flattens all of -/// them to chat functions, then uses this metadata to restore the exact Responses item type on the -/// way back to Codex. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] -pub enum CodexToolKind { - Function, - Namespace, - Custom, - ToolSearch, -} - -#[derive(Clone, Debug, PartialEq, Eq, Hash)] -pub struct CodexToolSpec { - pub kind: CodexToolKind, - pub name: String, - pub namespace: Option, -} - -/// Request-scoped tool metadata used by both buffered and streaming Responses encoders. -/// -/// Build this from the original Codex request before decoding it to the connector IR. Loaded tools -/// embedded in `tool_search_output` history are included, so subsequent calls can be translated -/// even when their definitions are not repeated in the top-level `tools` array. -#[derive(Clone, Debug, Default)] -pub struct CodexToolContext { - ir_tools: Vec, - seen_chat_names: HashSet, - colliding_preferred_names: HashSet, - chat_name_to_spec: HashMap, - spec_to_chat_name: HashMap, -} - -impl CodexToolContext { - pub fn from_request(req: &Value) -> Self { - let mut context = Self::default(); - if let Some(tools) = req.get("tools").and_then(Value::as_array) { - for tool in tools { - context.add_response_tool(tool); - } - } - if let Some(input) = req.get("input") { - // Codex Responses Lite (used by gpt-5.6-sol*) moves the complete tool registry out - // of the top-level `tools` field and into an `additional_tools` developer item. Treat - // those definitions exactly like top-level tools; the item itself is request metadata, - // not a chat message. - collect_additional_tools(input, &mut context); - collect_tool_search_output_tools(input, &mut context); - collect_response_tool_call_identities(input, &mut context); - } - collect_tool_choice_identity(req.get("tool_choice"), &mut context); - context - } - - pub fn ir_tools(&self) -> Vec { - self.ir_tools.clone() - } - - pub fn lookup_chat_name(&self, chat_name: &str) -> Option<&CodexToolSpec> { - self.chat_name_to_spec.get(chat_name) - } - - pub fn kind_for_chat_name(&self, chat_name: &str) -> CodexToolKind { - self.lookup_chat_name(chat_name) - .map(|spec| spec.kind) - .unwrap_or(CodexToolKind::Function) - } - - pub fn chat_name_for_response_tool(&self, name: &str, namespace: Option<&str>) -> String { - let namespace = namespace.filter(|value| !value.is_empty()); - self.chat_name_for_spec(&CodexToolSpec { - kind: if namespace.is_some() { - CodexToolKind::Namespace - } else { - CodexToolKind::Function - }, - name: name.to_string(), - namespace: namespace.map(ToString::to_string), - }) - } - - fn chat_name_for_custom_tool(&self, name: &str) -> String { - self.chat_name_for_spec(&CodexToolSpec { - kind: CodexToolKind::Custom, - name: name.to_string(), - namespace: None, - }) - } - - fn chat_name_for_tool_search(&self) -> String { - self.chat_name_for_spec(&CodexToolSpec { - kind: CodexToolKind::ToolSearch, - name: TOOL_SEARCH_CHAT_NAME.to_string(), - namespace: None, - }) - } - - fn chat_name_for_spec(&self, spec: &CodexToolSpec) -> String { - self.spec_to_chat_name - .get(spec) - .cloned() - .unwrap_or_else(|| self.allocate_chat_name(spec)) - } - - pub(crate) fn response_item_id( - &self, - chat_name: &str, - response_id: &str, - index: usize, - ) -> String { - let prefix = match self.kind_for_chat_name(chat_name) { - CodexToolKind::Custom => "ctc", - CodexToolKind::ToolSearch => "tsc", - CodexToolKind::Function | CodexToolKind::Namespace => "fc", - }; - format!( - "{}_{}_{}", - prefix, - response_id.trim_start_matches("resp_"), - index - ) - } - - pub(crate) fn response_tool_item( - &self, - item_id: &str, - status: &str, - call_id: &str, - chat_name: &str, - arguments: &str, - ) -> Value { - self.response_tool_item_with_reasoning(item_id, status, call_id, chat_name, arguments, None) - } - - pub(crate) fn response_tool_item_with_reasoning( - &self, - item_id: &str, - status: &str, - call_id: &str, - chat_name: &str, - arguments: &str, - reasoning: Option<&str>, - ) -> Value { - let mut item = match self.lookup_chat_name(chat_name) { - Some(spec) if spec.kind == CodexToolKind::Custom => json!({ - "type": "custom_tool_call", - "id": item_id, - "status": status, - "call_id": call_id, - "name": spec.name, - "input": custom_tool_input_from_chat_arguments(arguments), - }), - Some(spec) if spec.kind == CodexToolKind::ToolSearch => json!({ - "type": "tool_search_call", - "status": status, - "call_id": call_id, - "execution": "client", - "arguments": parse_tool_arguments_object(arguments), - }), - Some(spec) => { - let mut item = json!({ - "type": "function_call", - "id": item_id, - "status": status, - "call_id": call_id, - "name": spec.name, - "arguments": if arguments.is_empty() { "{}" } else { arguments }, - }); - if let Some(namespace) = spec.namespace.as_deref().filter(|value| !value.is_empty()) - { - item["namespace"] = json!(namespace); - } - item - } - None => json!({ - "type": "function_call", - "id": item_id, - "status": status, - "call_id": call_id, - "name": chat_name, - "arguments": if arguments.is_empty() { "{}" } else { arguments }, - }), - }; - if let Some(reasoning) = reasoning.map(str::trim).filter(|value| !value.is_empty()) { - item["reasoning_content"] = json!(reasoning); - } - item - } - - fn add_response_tool(&mut self, tool: &Value) { - match tool { - Value::String(name) => self.add_custom_tool(&json!({ - "type": "custom", - "name": name, - })), - Value::Object(_) => match tool.get("type").and_then(Value::as_str) { - Some("function") | None => self.add_function_tool(tool, None), - Some("custom") => self.add_custom_tool(tool), - Some("tool_search") => self.add_tool_search_tool(tool), - Some("namespace") => self.add_namespace_tool(tool), - _ => {} - }, - _ => {} - } - } - - fn add_function_tool(&mut self, tool: &Value, namespace: Option<&str>) { - let function = tool - .get("function") - .filter(|value| value.is_object()) - .unwrap_or(tool); - let Some(name) = function.get("name").and_then(Value::as_str) else { - return; - }; - if name.trim().is_empty() { - return; - } - let description = function - .get("description") - .and_then(Value::as_str) - .map(ToString::to_string); - let parameters = normalize_function_parameters(function.get("parameters")); - let spec = CodexToolSpec { - kind: if namespace.is_some() { - CodexToolKind::Namespace - } else { - CodexToolKind::Function - }, - name: name.to_string(), - namespace: namespace.map(ToString::to_string), - }; - self.add_chat_tool(spec, description, parameters); - } - - fn add_custom_tool(&mut self, tool: &Value) { - let Some(name) = tool.get("name").and_then(Value::as_str) else { - return; - }; - if name.trim().is_empty() { - return; - } - let mut description = tool - .get("description") - .and_then(Value::as_str) - .map(|description| format!("{description}\n\n{CUSTOM_TOOL_RAW_INPUT_INSTRUCTION}")) - .unwrap_or_else(|| CUSTOM_TOOL_RAW_INPUT_INSTRUCTION.to_string()); - if name == "apply_patch" { - description.push_str("\n\n"); - description.push_str(APPLY_PATCH_CHAT_INSTRUCTION); - } - let parameters = json!({ - "type": "object", - "properties": { - "input": { - "type": "string", - "description": "Raw string input for the original custom tool. Preserve formatting exactly." - } - }, - "required": [CUSTOM_TOOL_INPUT_FIELD], - "additionalProperties": false, - }); - self.add_chat_tool( - CodexToolSpec { - kind: CodexToolKind::Custom, - name: name.to_string(), - namespace: None, - }, - Some(description), - parameters, - ); - } - - fn add_tool_search_tool(&mut self, tool: &Value) { - let description = tool - .get("description") - .and_then(Value::as_str) - .map(ToString::to_string) - .unwrap_or_else(|| { - "Search and load Codex tools, plugins, connectors, and MCP namespaces for the current task." - .to_string() - }); - let parameters = if tool.get("parameters").is_some_and(Value::is_object) { - normalize_function_parameters(tool.get("parameters")) - } else { - json!({ - "type": "object", - "properties": { - "query": { "type": "string" }, - "limit": { "type": "integer" } - }, - "required": ["query"], - "additionalProperties": false, - }) - }; - self.add_chat_tool( - CodexToolSpec { - kind: CodexToolKind::ToolSearch, - name: TOOL_SEARCH_CHAT_NAME.to_string(), - namespace: None, - }, - Some(description), - parameters, - ); - } - - fn add_namespace_tool(&mut self, tool: &Value) { - let Some(namespace) = tool.get("name").and_then(Value::as_str) else { - return; - }; - if namespace.trim().is_empty() { - return; - } - let Some(children) = tool - .get("tools") - .or_else(|| tool.get("children")) - .and_then(Value::as_array) - else { - return; - }; - for child in children { - if child.get("type").and_then(Value::as_str) == Some("function") { - self.add_function_tool(child, Some(namespace)); - } - } - } - - fn add_chat_tool( - &mut self, - spec: CodexToolSpec, - description: Option, - parameters: Value, - ) { - if self.spec_to_chat_name.contains_key(&spec) { - return; - } - let chat_name = self.reserve_chat_name(&spec); - self.ir_tools - .push(Tool::function(chat_name.clone(), description, parameters)); - } - - fn register_tool_identity(&mut self, spec: CodexToolSpec) { - if self.spec_to_chat_name.contains_key(&spec) { - return; - } - self.reserve_chat_name(&spec); - } - - fn reserve_chat_name(&mut self, spec: &CodexToolSpec) -> String { - let preferred = preferred_chat_tool_name(spec); - let chat_name = if is_valid_chat_tool_name(&preferred) - && !self.colliding_preferred_names.contains(&preferred) - { - if let Some(existing_spec) = self.chat_name_to_spec.get(&preferred).cloned() { - self.colliding_preferred_names.insert(preferred.clone()); - if preferred_chat_tool_name(&existing_spec) == preferred { - self.move_identity_to_hashed_alias(&existing_spec, &preferred); - } - self.allocate_hashed_chat_name(spec) - } else { - preferred - } - } else { - self.allocate_hashed_chat_name(spec) - }; - self.seen_chat_names.insert(chat_name.clone()); - self.chat_name_to_spec - .insert(chat_name.clone(), spec.clone()); - self.spec_to_chat_name - .insert(spec.clone(), chat_name.clone()); - chat_name - } - - fn move_identity_to_hashed_alias(&mut self, spec: &CodexToolSpec, old_name: &str) { - self.seen_chat_names.remove(old_name); - self.chat_name_to_spec.remove(old_name); - let new_name = self.allocate_hashed_chat_name(spec); - self.seen_chat_names.insert(new_name.clone()); - self.chat_name_to_spec - .insert(new_name.clone(), spec.clone()); - self.spec_to_chat_name - .insert(spec.clone(), new_name.clone()); - if let Some(tool) = self - .ir_tools - .iter_mut() - .find(|tool| tool.function.name == old_name) - { - tool.function.name = new_name; - } - } - - fn allocate_chat_name(&self, spec: &CodexToolSpec) -> String { - let preferred = preferred_chat_tool_name(spec); - if is_valid_chat_tool_name(&preferred) - && !self.colliding_preferred_names.contains(&preferred) - && !self.seen_chat_names.contains(&preferred) - { - return preferred; - } - - self.allocate_hashed_chat_name(spec) - } - - fn allocate_hashed_chat_name(&self, spec: &CodexToolSpec) -> String { - let preferred = preferred_chat_tool_name(spec); - let digest = tool_identity_digest(spec); - for attempt in 0_u64.. { - let candidate = hashed_chat_tool_name(&preferred, &digest, attempt); - if !self.seen_chat_names.contains(&candidate) { - return candidate; - } - } - unreachable!("the finite request cannot exhaust all valid Chat tool aliases") - } -} - -/// Client-visible call ids must be unique even when an OpenAI-compatible upstream repeats or -/// omits its own ids for parallel calls. Scope them to the response and output position; the -/// client echoes this id, so subsequent translated history remains unambiguous. -pub(crate) fn response_scoped_call_id(response_id: &str, index: usize) -> String { - let mut digest = Sha1::new(); - digest.update(response_id.as_bytes()); - let digest = format!("{:x}", digest.finalize()); - format!("call_{}_{}", &digest[..16], index) -} - -fn normalize_function_parameters(parameters: Option<&Value>) -> Value { - let mut parameters = parameters - .filter(|value| value.is_object()) - .cloned() - .unwrap_or_else(|| json!({ "type": "object", "properties": {} })); - if let Some(object) = parameters.as_object_mut() { - if object.get("type").and_then(Value::as_str) != Some("object") { - object.insert("type".to_string(), json!("object")); - } - object - .entry("properties".to_string()) - .or_insert_with(|| json!({})); - } - parameters -} - -fn preferred_chat_tool_name(spec: &CodexToolSpec) -> String { - match spec.namespace.as_deref() { - Some(namespace) if !namespace.is_empty() => format!("{namespace}__{}", spec.name), - _ => spec.name.clone(), - } -} - -fn is_valid_chat_tool_name(name: &str) -> bool { - !name.is_empty() - && name.len() <= CHAT_TOOL_NAME_MAX_LEN - && name - .bytes() - .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-')) -} - -fn sanitized_chat_tool_name(name: &str) -> String { - let mut sanitized = String::with_capacity(name.len().min(CHAT_TOOL_NAME_MAX_LEN)); - for ch in name.chars() { - if ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-') { - sanitized.push(ch); - } else { - sanitized.push('_'); - } - } - if sanitized.is_empty() { - sanitized.push_str("tool"); - } - sanitized -} - -fn tool_identity_digest(spec: &CodexToolSpec) -> String { - let mut digest = Sha1::new(); - digest.update([match spec.kind { - CodexToolKind::Function => 0, - CodexToolKind::Namespace => 1, - CodexToolKind::Custom => 2, - CodexToolKind::ToolSearch => 3, - }]); - match spec.namespace.as_deref() { - Some(namespace) => { - digest.update([1]); - digest.update((namespace.len() as u64).to_be_bytes()); - digest.update(namespace.as_bytes()); - } - None => digest.update([0]), - } - digest.update((spec.name.len() as u64).to_be_bytes()); - digest.update(spec.name.as_bytes()); - format!("{:x}", digest.finalize()) -} - -fn hashed_chat_tool_name(preferred: &str, digest: &str, attempt: u64) -> String { - let suffix = if attempt == 0 { - format!("__{}", &digest[..CHAT_TOOL_NAME_HASH_LEN]) - } else { - format!("__{}_{attempt}", &digest[..CHAT_TOOL_NAME_HASH_LEN]) - }; - let prefix_len = CHAT_TOOL_NAME_MAX_LEN.saturating_sub(suffix.len()); - let mut prefix = sanitized_chat_tool_name(preferred); - prefix.truncate(prefix_len); - format!("{prefix}{suffix}") -} - -fn collect_additional_tools(value: &Value, context: &mut CodexToolContext) { - match value { - Value::Array(items) => { - for item in items { - collect_additional_tools(item, context); - } - } - Value::Object(object) => { - if object.get("type").and_then(Value::as_str) == Some("additional_tools") { - if let Some(tools) = object.get("tools").and_then(Value::as_array) { - for tool in tools { - context.add_response_tool(tool); - } - } - } - for child in object.values() { - collect_additional_tools(child, context); - } - } - _ => {} - } -} - -fn collect_tool_search_output_tools(value: &Value, context: &mut CodexToolContext) { - match value { - Value::Array(items) => { - for item in items { - collect_tool_search_output_tools(item, context); - } - } - Value::Object(object) => { - if object.get("type").and_then(Value::as_str) == Some("tool_search_output") { - if let Some(tools) = object.get("tools").and_then(Value::as_array) { - for tool in tools { - context.add_response_tool(tool); - } - } - } - for child in object.values() { - collect_tool_search_output_tools(child, context); - } - } - _ => {} - } -} - -fn collect_response_tool_call_identities(value: &Value, context: &mut CodexToolContext) { - match value { - Value::Array(items) => { - for item in items { - collect_response_tool_call_identities(item, context); - } - } - Value::Object(object) => { - let spec = match object.get("type").and_then(Value::as_str) { - Some("function_call") => object - .get("name") - .and_then(Value::as_str) - .filter(|name| !name.trim().is_empty()) - .map(|name| { - let namespace = object - .get("namespace") - .and_then(Value::as_str) - .filter(|namespace| !namespace.is_empty()); - CodexToolSpec { - kind: if namespace.is_some() { - CodexToolKind::Namespace - } else { - CodexToolKind::Function - }, - name: name.to_string(), - namespace: namespace.map(ToString::to_string), - } - }), - Some("custom_tool_call") => object - .get("name") - .and_then(Value::as_str) - .filter(|name| !name.trim().is_empty()) - .map(|name| CodexToolSpec { - kind: CodexToolKind::Custom, - name: name.to_string(), - namespace: None, - }), - Some("tool_search_call") => Some(CodexToolSpec { - kind: CodexToolKind::ToolSearch, - name: TOOL_SEARCH_CHAT_NAME.to_string(), - namespace: None, - }), - _ => None, - }; - if let Some(spec) = spec { - context.register_tool_identity(spec); - } - for child in object.values() { - collect_response_tool_call_identities(child, context); - } - } - _ => {} - } -} - -fn collect_tool_choice_identity(tool_choice: Option<&Value>, context: &mut CodexToolContext) { - let Some(tool_choice) = tool_choice.filter(|value| value.is_object()) else { - return; - }; - let spec = match tool_choice.get("type").and_then(Value::as_str) { - Some("function") => tool_choice - .get("name") - .and_then(Value::as_str) - .or_else(|| { - tool_choice - .get("function") - .and_then(|function| function.get("name")) - .and_then(Value::as_str) - }) - .filter(|name| !name.trim().is_empty()) - .map(|name| { - let namespace = tool_choice - .get("namespace") - .and_then(Value::as_str) - .filter(|namespace| !namespace.is_empty()); - CodexToolSpec { - kind: if namespace.is_some() { - CodexToolKind::Namespace - } else { - CodexToolKind::Function - }, - name: name.to_string(), - namespace: namespace.map(ToString::to_string), - } - }), - Some("custom") => tool_choice - .get("name") - .and_then(Value::as_str) - .filter(|name| !name.trim().is_empty()) - .map(|name| CodexToolSpec { - kind: CodexToolKind::Custom, - name: name.to_string(), - namespace: None, - }), - Some("tool_search") => Some(CodexToolSpec { - kind: CodexToolKind::ToolSearch, - name: TOOL_SEARCH_CHAT_NAME.to_string(), - namespace: None, - }), - _ => None, - }; - if let Some(spec) = spec { - context.register_tool_identity(spec); - } -} - -pub(crate) fn custom_tool_input_from_chat_arguments(arguments: &str) -> String { - if arguments.trim().is_empty() { - return String::new(); - } - match serde_json::from_str::(arguments) { - Ok(Value::Object(object)) => object - .get(CUSTOM_TOOL_INPUT_FIELD) - .and_then(Value::as_str) - .unwrap_or(arguments) - .to_string(), - _ => arguments.to_string(), - } -} - -fn wrap_custom_tool_input(input: &Value) -> String { - let input = input - .as_str() - .map(ToString::to_string) - .unwrap_or_else(|| input.to_string()); - json!({ "input": input }).to_string() -} - -fn parse_tool_arguments_object(arguments: &str) -> Value { - if arguments.trim().is_empty() { - return json!({}); - } - serde_json::from_str::(arguments) - .ok() - .filter(Value::is_object) - .unwrap_or_else(|| json!({ "query": arguments })) -} - -/// Map a thinking budget (tokens) to a Responses reasoning effort tier. -fn budget_to_effort(budget: Option) -> &'static str { - match budget { - Some(b) if b >= 8192 => "high", - Some(b) if b >= 2048 => "medium", - _ => "low", - } -} - -/// Reverse of budget_to_effort: a Responses reasoning effort tier → a thinking budget (tokens). -fn effort_to_budget(effort: &str) -> u32 { - match effort { - "ultra" | "max" => 32768, - "xhigh" => 24576, - "high" => 16384, - "medium" => 4096, - _ => 1024, // "low" / "minimal" - } -} - -fn effort_to_reasoning_effort(effort: &str) -> ReasoningEffort { - match effort { - "medium" => ReasoningEffort::Medium, - "high" | "xhigh" | "max" | "ultra" => ReasoningEffort::High, - _ => ReasoningEffort::Low, - } -} - -fn response_incomplete_reason(finish_reason: Option<&str>) -> Option<&'static str> { - match finish_reason { - Some("length" | "max_tokens" | "model_context_window_exceeded") => { - Some("max_output_tokens") - } - Some("content_filter") => Some("content_filter"), - _ => None, - } -} - -/// IR (ChatRequest) → OpenAI Responses request BODY. `outgoing_model` is the provider's real model. -pub fn encode_request(ir: &ChatRequest, outgoing_model: &str, stream: bool) -> Value { - let mut instructions: Option = None; - let mut input: Vec = vec![]; - - for m in &ir.messages { - match m.role { - Role::System => { - // Responses carries the system prompt in `instructions`, not the input array. - let t = m.content_as_text(); - if !t.trim().is_empty() { - instructions = Some(match instructions.take() { - Some(prev) => format!("{}\n{}", prev, t), - None => t, - }); - } - } - Role::Tool => { - // a tool result → function_call_output item - input.push(json!({ - "type": "function_call_output", - "call_id": m.tool_call_id.clone().unwrap_or_default(), - "output": m.content_as_text(), - })); - } - Role::User => { - let text = m.content_as_text(); - let mut content: Vec = vec![]; - if !text.is_empty() { - content.push(json!({ "type": "input_text", "text": text })); - } - for b64 in m.content_as_images_base64() { - content.push(json!({ "type": "input_image", "image_url": format!("data:image/png;base64,{}", b64) })); - } - if !content.is_empty() { - input.push(json!({ "type": "message", "role": "user", "content": content })); - } - } - Role::Assistant => { - let text = m.content_as_text(); - if !text.is_empty() { - input.push(json!({ "type": "message", "role": "assistant", - "content": [{ "type": "output_text", "text": text }] })); - } - if let Some(calls) = &m.tool_calls { - for tc in calls { - input.push(json!({ - "type": "function_call", - "call_id": tc.id, - "name": tc.function.name, - "arguments": tc.function.arguments, - })); - } - } - } - } - } - - let mut body = json!({ - "model": outgoing_model, - "input": input, - "stream": stream, - }); - if let Some(instr) = instructions { - body["instructions"] = json!(instr); - } - if let Some(mt) = ir.max_tokens { - body["max_output_tokens"] = json!(mt); - } - if let Some(t) = ir.temperature { - body["temperature"] = json!(t); - } - if let Some(p) = ir.top_p { - body["top_p"] = json!(p); - } - // tools → Responses function tools (fields flattened at the item level, not nested under - // "function" like Chat Completions). - if let Some(tools) = &ir.tools { - let arr: Vec = tools - .iter() - .map(|t| { - json!({ - "type": "function", - "name": t.function.name, - "description": t.function.description, - "parameters": t.function.parameters, - }) - }) - .collect(); - if !arr.is_empty() { - body["tools"] = json!(arr); - } - } - // Anthropic extended thinking → Responses reasoning effort. - if ir.enable_thinking == Some(true) { - body["reasoning"] = json!({ "effort": budget_to_effort(ir.thinking_budget) }); - } - body -} - -/// OpenAI Responses RESPONSE (buffered) → IR. We reshape the Responses reply into an OpenAI Chat -/// completion and let the crate's parse_response build the IR — reusing its battle-tested mapping. -pub fn decode_response(text: &str) -> Result { - let v: Value = serde_json::from_str(text).map_err(|e| format!("responses parse: {}", e))?; - if v.get("status").and_then(Value::as_str) == Some("failed") { - let message = v - .pointer("/error/message") - .and_then(Value::as_str) - .unwrap_or("upstream Responses request failed"); - return Err(message.to_string()); - } - let output = v - .get("output") - .and_then(|o| o.as_array()) - .cloned() - .unwrap_or_default(); - - let mut content = String::new(); - let mut tool_calls: Vec = vec![]; - let mut had_tool = false; - for item in &output { - match item.get("type").and_then(|t| t.as_str()) { - Some("message") => { - if let Some(cs) = item.get("content").and_then(|c| c.as_array()) { - for c in cs { - if let Some(t) = c.get("text").and_then(|v| v.as_str()) { - content.push_str(t); - } - } - } - } - Some("function_call") => { - had_tool = true; - tool_calls.push(json!({ - "id": item.get("call_id").or_else(|| item.get("id")).cloned().unwrap_or(json!("")), - "type": "function", - "function": { - "name": item.get("name").cloned().unwrap_or(json!("")), - "arguments": item.get("arguments").and_then(|v| v.as_str()).unwrap_or("{}"), - } - })); - } - _ => {} - } - } - // fall back to the flattened output_text if no message items carried content - if content.is_empty() { - if let Some(t) = v.get("output_text").and_then(|v| v.as_str()) { - content = t.to_string(); - } - } - - let usage = v.get("usage").cloned().unwrap_or(json!({})); - let mut message = json!({ "role": "assistant", "content": content }); - if !tool_calls.is_empty() { - message["tool_calls"] = json!(tool_calls); - } - let finish_reason = if v.get("status").and_then(Value::as_str) == Some("incomplete") { - match v - .pointer("/incomplete_details/reason") - .and_then(Value::as_str) - { - Some("content_filter") => "content_filter", - _ => "length", - } - } else if had_tool { - "tool_calls" - } else { - "stop" - }; - let chat = json!({ - "id": v.get("id").cloned().unwrap_or(json!("resp")), - "object": "chat.completion", - "created": 0, - "model": v.get("model").cloned().unwrap_or(json!("")), - "choices": [{ "index": 0, "finish_reason": finish_reason, "message": message }], - "usage": { - "prompt_tokens": usage.get("input_tokens").and_then(|v| v.as_i64()).unwrap_or(0), - "completion_tokens": usage.get("output_tokens").and_then(|v| v.as_i64()).unwrap_or(0), - "total_tokens": usage.get("total_tokens").and_then(|v| v.as_i64()).unwrap_or(0), - } - }); - OpenAIProtocol::new("") - .parse_response(&chat.to_string()) - .map_err(|e| e.to_string()) -} - -// --------------------------------------------------------------------------- -// client-side half: a Responses client (Codex) in front of the gateway -// --------------------------------------------------------------------------- - -/// Pull text out of a Responses content value (string, or array of typed parts). Accepts the -/// input_text / output_text / text / summary_text part flavors. -fn parts_text(content: &Value) -> String { - if let Some(s) = content.as_str() { - return s.to_string(); - } - let arr = match content.as_array() { - Some(a) => a, - None => return String::new(), - }; - let mut out: Vec = vec![]; - for p in arr { - match p.get("type").and_then(|t| t.as_str()) { - Some("input_text") | Some("output_text") | Some("text") | Some("summary_text") => { - if let Some(t) = p.get("text").and_then(|v| v.as_str()) { - out.push(t.to_string()); - } - } - _ => {} - } - } - out.join("\n") -} - -/// input_image parts → IR image blocks. Codex sends `image_url` as a data URI (screenshots / -/// attached images); a plain URL is also accepted per the OpenAI spec. -fn parts_images(content: &Value) -> Vec { - let arr = match content.as_array() { - Some(a) => a, - None => return vec![], - }; - let mut out = vec![]; - for p in arr { - if p.get("type").and_then(|t| t.as_str()) != Some("input_image") { - continue; - } - let url = p.get("image_url").and_then(|v| v.as_str()).or_else(|| { - p.get("image_url") - .and_then(|v| v.get("url")) - .and_then(|v| v.as_str()) - }); - let Some(u) = url else { continue }; - if let Some(rest) = u.strip_prefix("data:") { - if let Some((meta, data)) = rest.split_once(";base64,") { - if !data.is_empty() { - out.push(MessageBlock::image_base64( - if meta.is_empty() { "image/png" } else { meta }, - data, - )); - } - continue; - } - } - out.push(MessageBlock::image_url(u)); - } - out -} - -/// Reasoning text carried by a Responses `reasoning` item: the summary parts (what a transcoded -/// stream emits and Codex echoes back), falling back to full `content` parts. -fn reasoning_item_text(item: &Value) -> Option { - for key in ["summary", "content"] { - let Some(parts) = item.get(key).and_then(|v| v.as_array()) else { - continue; - }; - let text = parts - .iter() - .filter_map(|p| { - p.get("text") - .and_then(|v| v.as_str()) - .or_else(|| p.as_str()) - }) - .filter(|t| !t.is_empty()) - .collect::>() - .join("\n\n"); - if !text.trim().is_empty() { - return Some(text); - } - } - None -} - -/// Append reasoning text onto a message's `reasoning_content` (the OpenAI-chat wire field). -fn append_reasoning_content(message: &mut Message, text: &str) { - let text = text.trim(); - if text.is_empty() { - return; - } - match &mut message.reasoning_content { - // Transcoded Responses output deliberately carries the same reasoning both - // as a sibling `reasoning` item and on each call item, so either surviving - // history representation is sufficient. Do not multiply it when both (or - // several parallel calls) are present. - Some(existing) if existing.trim() == text => {} - Some(existing) if !existing.is_empty() => { - existing.push_str("\n\n"); - existing.push_str(text); - } - slot => *slot = Some(text.to_string()), - } -} - -fn response_item_call_id(item: &Value) -> Option<&str> { - item.get("call_id") - .or_else(|| item.get("id")) - .and_then(Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()) -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum ResponseCallKind { - Function, - Custom, - ToolSearch, -} - -impl ResponseCallKind { - fn call_item(item_type: &str) -> Option { - match item_type { - "function_call" => Some(Self::Function), - "custom_tool_call" => Some(Self::Custom), - "tool_search_call" => Some(Self::ToolSearch), - _ => None, - } - } - - fn output_item(item_type: &str) -> Option { - match item_type { - "function_call_output" => Some(Self::Function), - "custom_tool_call_output" => Some(Self::Custom), - "tool_search_output" => Some(Self::ToolSearch), - _ => None, - } - } - - fn label(self) -> &'static str { - match self { - Self::Function => "function", - Self::Custom => "custom tool", - Self::ToolSearch => "tool search", - } - } -} - -fn validate_call_output_pairs(req: &Value) -> Result<(), String> { - let items = match req.get("input") { - Some(Value::Array(items)) => items.iter().collect::>(), - Some(Value::Object(_)) => req.get("input").into_iter().collect::>(), - _ => return Ok(()), - }; - let mut calls = HashMap::::new(); - let mut seen_call_ids = HashSet::new(); - let mut unresolved = Vec::new(); - let mut consumed_in_group = false; - for item in items { - let item_type = item.get("type").and_then(Value::as_str).unwrap_or(""); - if item - .get("role") - .and_then(Value::as_str) - .is_some_and(|role| matches!(role, "user" | "system" | "developer")) - { - // A new client-authored turn closes the window in which an older call can be - // satisfied. Outputs after this point are stale/out of order. - calls.clear(); - consumed_in_group = false; - continue; - } - - if let Some(kind) = ResponseCallKind::call_item(item_type) { - let Some(call_id) = response_item_call_id(item) else { - return Err("Responses call item is missing call_id".to_string()); - }; - if consumed_in_group && !calls.is_empty() { - return Err(format!( - "Responses call order is ambiguous: new call {call_id} appeared before every preceding call produced an output" - )); - } - if calls.is_empty() { - consumed_in_group = false; - } - if !seen_call_ids.insert(call_id.to_string()) - || calls.insert(call_id.to_string(), kind).is_some() - { - return Err(format!( - "Responses call id is ambiguous because it appears more than once before output: {call_id}" - )); - } - continue; - } - - if let Some(output_kind) = ResponseCallKind::output_item(item_type) { - match response_item_call_id(item) { - Some(call_id) => match calls.remove(call_id) { - Some(call_kind) if call_kind == output_kind => { - consumed_in_group = true; - } - Some(call_kind) => unresolved.push(format!( - "{call_id} ({} output cannot satisfy {} call)", - output_kind.label(), - call_kind.label() - )), - None => { - if !unresolved.iter().any(|value| value == call_id) { - unresolved.push(call_id.to_string()); - } - } - }, - None => unresolved.push("".to_string()), - } - if !unresolved.is_empty() { - // Keep collecting only adjacent invalid outputs so the client gets useful ids, - // but never let a later call retroactively legitimize an earlier output. - consumed_in_group = true; - } - continue; - } - - match item_type { - // Reasoning and assistant output items can neighbor the same model turn and do not - // make otherwise ordered call/output pairs stale. - "reasoning" | "message" | "" => {} - _ => { - if item.get("role").is_some() { - calls.clear(); - consumed_in_group = false; - } - } - } - } - if unresolved.is_empty() { - Ok(()) - } else { - Err(format!( - "Responses call output has no preceding matching call: {}", - unresolved.join(", ") - )) - } -} - -fn response_history_tool_call(item: &Value, context: &CodexToolContext) -> Option { - let ty = item.get("type").and_then(Value::as_str).unwrap_or(""); - let id = response_item_call_id(item).unwrap_or("").to_string(); - if id.is_empty() { - return None; - } - let (name, arguments) = match ty { - "function_call" => { - let original_name = item.get("name").and_then(Value::as_str).unwrap_or(""); - let namespace = item.get("namespace").and_then(Value::as_str); - let name = context.chat_name_for_response_tool(original_name, namespace); - let arguments = match item.get("arguments") { - Some(Value::String(arguments)) => arguments.clone(), - Some(arguments) if !arguments.is_null() => arguments.to_string(), - _ => "{}".to_string(), - }; - (name, arguments) - } - "custom_tool_call" => { - let original_name = item.get("name").and_then(Value::as_str).unwrap_or(""); - let name = context.chat_name_for_custom_tool(original_name); - let input = item - .get("input") - .cloned() - .unwrap_or(Value::String(String::new())); - (name, wrap_custom_tool_input(&input)) - } - "tool_search_call" => { - let arguments = item - .get("arguments") - .map(|value| { - if let Some(arguments) = value.as_str() { - arguments.to_string() - } else { - value.to_string() - } - }) - .unwrap_or_else(|| "{}".to_string()); - (context.chat_name_for_tool_search(), arguments) - } - _ => return None, - }; - if name.is_empty() { - return None; - } - Some(ToolCall { - id, - call_type: "function".to_string(), - function: FunctionCall { - name, - arguments, - thought_signature: None, - }, - index: None, - thought_signature: None, - }) -} - -fn append_history_tool_call( - messages: &mut Vec, - pending_reasoning: &mut Option, - item_reasoning: Option<&str>, - call: ToolCall, -) { - // Codex emits a turn's prose and tool calls as sibling items. Fold the calls into the trailing - // assistant message so Chat/Anthropic upstreams receive one coherent assistant turn. - match messages.last_mut() { - Some(message) if message.role == Role::Assistant => { - if let Some(reasoning) = pending_reasoning.take() { - append_reasoning_content(message, &reasoning); - } - if let Some(reasoning) = item_reasoning { - append_reasoning_content(message, reasoning); - } - message.tool_calls.get_or_insert_with(Vec::new).push(call); - } - _ => { - let mut message = Message::new(Role::Assistant, vec![]); - if let Some(reasoning) = pending_reasoning.take() { - append_reasoning_content(&mut message, &reasoning); - } - if let Some(reasoning) = item_reasoning { - append_reasoning_content(&mut message, reasoning); - } - message.tool_calls = Some(vec![call]); - messages.push(message); - } - } -} - -fn response_tool_output_text(item: &Value) -> String { - if item.get("type").and_then(Value::as_str) == Some("tool_search_output") { - return json!({ - "status": item.get("status").cloned().unwrap_or(json!("completed")), - "execution": item.get("execution").cloned().unwrap_or(json!("client")), - "tools": item.get("tools").cloned().unwrap_or_else(|| json!([])), - }) - .to_string(); - } - match item.get("output") { - Some(Value::String(output)) => output.clone(), - Some(output @ Value::Array(_)) => { - let text = parts_text(output); - if text.is_empty() { - output.to_string() - } else { - text - } - } - Some(Value::Object(object)) => object - .get("content") - .map(|content| { - let text = parts_text(content); - if text.is_empty() { - content.to_string() - } else { - text - } - }) - .unwrap_or_else(|| Value::Object(object.clone()).to_string()), - _ => String::new(), - } -} - -/// Decode an OpenAI Responses REQUEST json (what Codex sends with wire_api="responses") into the -/// IR. Handles the full item vocabulary of an agentic history: message items (user input_text / -/// input_image, assistant output_text), all client-executed tool call/output item types, and `reasoning` -/// items — whose text is bridged onto the adjacent assistant message as `reasoning_content`, -/// because thinking chat upstreams (Kimi/Moonshot, DeepSeek, …) reject assistant tool-call -/// history that lost its reasoning. System/developer items collapse into ONE leading system -/// message: strict providers (MiniMax) reject `role:system` anywhere but the head. Custom, -/// tool-search, and namespace tools are flattened to chat functions and restored with the returned -/// [`CodexToolContext`]. -pub fn decode_request(req: &Value) -> Result { - decode_request_with_context(req).map(|(request, _)| request) -} - -pub fn decode_request_with_context(req: &Value) -> Result<(ChatRequest, CodexToolContext), String> { - validate_call_output_pairs(req)?; - let model = req - .get("model") - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(); - let tool_context = CodexToolContext::from_request(req); - let mut messages: Vec = vec![]; - // All system text (instructions + system/developer message items), merged to the head. - let mut system_texts: Vec = vec![]; - // Reasoning waiting for the assistant message it belongs to (model output order is - // reasoning → prose → tool calls, so reasoning usually precedes its assistant message). - let mut pending_reasoning: Option = None; - - if let Some(instr) = req.get("instructions").and_then(|v| v.as_str()) { - if !instr.trim().is_empty() { - system_texts.push(instr.to_string()); - } - } - - match req.get("input") { - Some(Value::String(s)) => { - if !s.is_empty() { - messages.push(Message::text(Role::User, s.clone())); - } - } - Some(Value::Array(items)) => { - for item in items { - // Bare `{role, content}` items (no "type") are legal Responses input; treat them - // as message items. - let ty = item.get("type").and_then(|v| v.as_str()).unwrap_or( - if item.get("role").is_some() { - "message" - } else { - "" - }, - ); - match ty { - "message" => { - let role = item.get("role").and_then(|v| v.as_str()).unwrap_or("user"); - let content = item.get("content").cloned().unwrap_or(Value::Null); - let text = parts_text(&content); - match role { - "assistant" => { - if !text.is_empty() { - let mut m = Message::text(Role::Assistant, text); - if let Some(r) = pending_reasoning.take() { - append_reasoning_content(&mut m, &r); - } - messages.push(m); - } - } - "system" | "developer" => { - pending_reasoning = None; - if !text.trim().is_empty() { - system_texts.push(text); - } - } - _ => { - pending_reasoning = None; - let mut blocks: Vec = vec![]; - if !text.is_empty() { - blocks.push(MessageBlock::text(text)); - } - blocks.extend(parts_images(&content)); - if !blocks.is_empty() { - messages.push(Message::new(Role::User, blocks)); - } - } - } - } - "reasoning" => { - // Belongs to the assistant step it neighbors: fold backward onto a - // directly preceding assistant message, else hold for the next one. - if let Some(text) = reasoning_item_text(item) { - match messages.last_mut() { - Some(m) if m.role == Role::Assistant => { - append_reasoning_content(m, &text) - } - _ => match &mut pending_reasoning { - Some(existing) if !existing.is_empty() => { - existing.push_str("\n\n"); - existing.push_str(text.trim()); - } - slot => *slot = Some(text.trim().to_string()), - }, - } - } - } - "function_call" | "custom_tool_call" | "tool_search_call" => { - if let Some(call) = response_history_tool_call(item, &tool_context) { - let item_reasoning = item - .get("reasoning_content") - .or_else(|| item.get("reasoning")) - .and_then(Value::as_str); - append_history_tool_call( - &mut messages, - &mut pending_reasoning, - item_reasoning, - call, - ); - } - } - "function_call_output" | "custom_tool_call_output" | "tool_search_output" => { - pending_reasoning = None; - let id = response_item_call_id(item).unwrap_or("").to_string(); - if !id.is_empty() { - messages.push(Message::tool(response_tool_output_text(item), id)); - } - } - _ => {} - } - } - } - _ => {} - } - - if !system_texts.is_empty() { - messages.insert(0, Message::text(Role::System, system_texts.join("\n\n"))); - } - - let mut cr = ChatRequest::new(model).with_messages(messages); - if let Some(mt) = req.get("max_output_tokens").and_then(|v| v.as_u64()) { - cr = cr.with_max_tokens(mt as u32); - } - if let Some(t) = req.get("temperature").and_then(|v| v.as_f64()) { - cr = cr.with_temperature(t as f32); - } - if let Some(p) = req.get("top_p").and_then(|v| v.as_f64()) { - cr = cr.with_top_p(p as f32); - } - if req.get("stream").and_then(|v| v.as_bool()).unwrap_or(false) { - cr = cr.with_stream(true); - } - let tools = tool_context.ir_tools(); - if !tools.is_empty() { - cr = cr.with_tools(tools); - } - // tool_choice: mode strings pass through; both the flattened Responses object form - // ({type:"function",name}) and the nested Chat form pin a specific function. - if let Some(tc) = req.get("tool_choice") { - if let Some(mode) = tc.as_str() { - if matches!(mode, "auto" | "none" | "required") { - cr.tool_choice = Some(ToolChoice::Mode(mode.to_string())); - } - } else if let Some(kind) = tc.get("type").and_then(Value::as_str) { - let selected = match kind { - "function" => tc - .get("name") - .and_then(Value::as_str) - .or_else(|| { - tc.get("function") - .and_then(|f| f.get("name")) - .and_then(Value::as_str) - }) - .map(|name| { - tool_context.chat_name_for_response_tool( - name, - tc.get("namespace").and_then(Value::as_str), - ) - }), - "custom" => tc - .get("name") - .and_then(Value::as_str) - .map(|name| tool_context.chat_name_for_custom_tool(name)), - "tool_search" => Some(tool_context.chat_name_for_tool_search()), - _ => None, - }; - if let Some(name) = selected { - cr.tool_choice = Some(ToolChoice::function(name)); - } - } - } - // Preserve both representations: Anthropic-family encoders consume the thinking budget, - // while OpenAI-compatible Chat encoders consume reasoning_effort. Higher Responses tiers do - // not exist in the connector enum, so xhigh/max/ultra intentionally collapse to High there. - if let Some(effort) = req - .get("reasoning") - .and_then(|r| r.get("effort")) - .and_then(|v| v.as_str()) - { - cr = cr - .with_enable_thinking(true) - .with_thinking_budget(effort_to_budget(effort)) - .with_reasoning_effort(effort_to_reasoning_effort(effort)); - } - Ok((cr, tool_context)) -} - -/// Encode the IR response back into an OpenAI Responses RESPONSE json. `client_model` is the name -/// the client asked for (so Codex sees its own model, not the upstream's). Unlike the crate's -/// chat_response_to_responses_response this maps tool_calls → function_call items and provider -/// reasoning → a reasoning item — both load-bearing for Codex's agent loop. -pub fn encode_response(resp: &ChatResponse, client_model: &str) -> Value { - encode_response_with_context(resp, client_model, &CodexToolContext::default()) -} - -pub fn encode_response_with_context( - resp: &ChatResponse, - client_model: &str, - tool_context: &CodexToolContext, -) -> Value { - let choice = resp.choices.first(); - let msg = choice.map(|c| &c.message); - // Same fallback as anthropic.rs: when a turn has tool_calls the crate parks the prose only in - // the top-level ChatResponse.content. - let text = { - let t = msg.map(|m| m.content_as_text()).unwrap_or_default(); - if t.is_empty() { - resp.content.clone() - } else { - t - } - }; - // never a constant fallback — item ids derive from this and land in client history - let rid = if resp.id.is_empty() { - super::uid("ccbud") - } else { - resp.id.clone() - }; - - let mut output: Vec = vec![]; - if let Some(reasoning) = msg.and_then(|m| m.reasoning_any()) { - if !reasoning.trim().is_empty() { - output.push(json!({ "type": "reasoning", "id": format!("rs_{}", rid), - "summary": [{ "type": "summary_text", "text": reasoning }] })); - } - } - if !text.is_empty() { - output.push( - json!({ "type": "message", "id": format!("msg_{}", rid), "status": "completed", - "role": "assistant", - "content": [{ "type": "output_text", "annotations": [], "text": text }] }), - ); - } - if let Some(m) = msg { - if let Some(calls) = &m.tool_calls { - for (i, tc) in calls.iter().enumerate() { - let call_id = response_scoped_call_id(&format!("resp_{}", rid), i); - let item_id = tool_context.response_item_id(&tc.function.name, &rid, i); - output.push(tool_context.response_tool_item_with_reasoning( - &item_id, - "completed", - &call_id, - &tc.function.name, - &tc.function.arguments, - m.reasoning_any(), - )); - } - } - } - if output.is_empty() { - // Codex builds the turn from output items; an empty message beats an empty array. - output.push(json!({ "type": "message", "id": format!("msg_{}", rid), "status": "completed", - "role": "assistant", "content": [{ "type": "output_text", "annotations": [], "text": "" }] })); - } - - let usage = resp.usage.as_ref(); - let input_tokens = usage.map(|u| u.prompt_tokens).unwrap_or(0) as i64; - let output_tokens = usage.map(|u| u.completion_tokens).unwrap_or(0) as i64; - let total = - (usage.map(|u| u.total_tokens).unwrap_or(0) as i64).max(input_tokens + output_tokens); - let incomplete_reason = - response_incomplete_reason(choice.and_then(|choice| choice.finish_reason.as_deref())); - let mut response = json!({ - "id": format!("resp_{}", rid), - "object": "response", - "created_at": resp.created, - "status": if incomplete_reason.is_some() { "incomplete" } else { "completed" }, - "model": client_model, - "output": output, - "output_text": text, - "usage": { - "input_tokens": input_tokens, - "input_tokens_details": { "cached_tokens": 0 }, - "output_tokens": output_tokens, - "output_tokens_details": { "reasoning_tokens": 0 }, - "total_tokens": total, - } - }); - if let Some(reason) = incomplete_reason { - response["incomplete_details"] = json!({ "reason": reason }); - } - response -} - -fn sse_ev(data: &Value) -> String { - let t = data - .get("type") - .and_then(|v| v.as_str()) - .unwrap_or("message"); - format!( - "event: {}\ndata: {}\n\n", - t, - serde_json::to_string(data).unwrap_or_default() - ) -} - -/// Synthesize an OpenAI Responses SSE event sequence from a finished IR response. Used when the -/// client (Codex) asked to stream but the upstream was translated buffered — the client still gets -/// a valid `response.created → output_item.added/delta/done per item → terminal event` stream, just -/// delivered at once. Codex materializes items only from `response.output_item.done`; truncations -/// terminate with `response.incomplete` instead of being mislabeled completed. -pub fn encode_response_sse(resp: &ChatResponse, client_model: &str) -> String { - encode_response_sse_with_context(resp, client_model, &CodexToolContext::default()) -} - -pub fn encode_response_sse_with_context( - resp: &ChatResponse, - client_model: &str, - tool_context: &CodexToolContext, -) -> String { - let full = encode_response_with_context(resp, client_model, tool_context); - let rid = full - .get("id") - .and_then(|v| v.as_str()) - .unwrap_or("resp_ccbud") - .to_string(); - let mut out = String::new(); - out.push_str(&sse_ev(&json!({ "type": "response.created", - "response": { "id": rid, "object": "response", "status": "in_progress", "model": client_model } }))); - - let items = full - .get("output") - .and_then(|v| v.as_array()) - .cloned() - .unwrap_or_default(); - for (idx, item) in items.iter().enumerate() { - let item_id = item - .get("id") - .and_then(|v| v.as_str()) - .unwrap_or("item") - .to_string(); - match item.get("type").and_then(|v| v.as_str()).unwrap_or("") { - "message" => { - let text = item["content"][0]["text"].as_str().unwrap_or(""); - out.push_str(&sse_ev(&json!({ "type": "response.output_item.added", "output_index": idx, - "item": { "type": "message", "id": item_id, "status": "in_progress", "role": "assistant", "content": [] } }))); - out.push_str(&sse_ev( - &json!({ "type": "response.content_part.added", "item_id": item_id, - "output_index": idx, "content_index": 0, - "part": { "type": "output_text", "annotations": [], "text": "" } }), - )); - if !text.is_empty() { - out.push_str(&sse_ev( - &json!({ "type": "response.output_text.delta", "item_id": item_id, - "output_index": idx, "content_index": 0, "delta": text }), - )); - } - out.push_str(&sse_ev( - &json!({ "type": "response.output_text.done", "item_id": item_id, - "output_index": idx, "content_index": 0, "text": text }), - )); - out.push_str(&sse_ev( - &json!({ "type": "response.content_part.done", "item_id": item_id, - "output_index": idx, "content_index": 0, - "part": { "type": "output_text", "annotations": [], "text": text } }), - )); - out.push_str(&sse_ev(&json!({ "type": "response.output_item.done", "output_index": idx, "item": item }))); - } - "function_call" => { - let args = item - .get("arguments") - .and_then(|v| v.as_str()) - .unwrap_or("{}"); - let mut added = item.clone(); - added["status"] = json!("in_progress"); - added["arguments"] = json!(""); - out.push_str(&sse_ev(&json!({ "type": "response.output_item.added", "output_index": idx, "item": added }))); - out.push_str(&sse_ev( - &json!({ "type": "response.function_call_arguments.delta", "item_id": item_id, - "output_index": idx, "delta": args }), - )); - out.push_str(&sse_ev( - &json!({ "type": "response.function_call_arguments.done", "item_id": item_id, - "output_index": idx, "arguments": args }), - )); - out.push_str(&sse_ev(&json!({ "type": "response.output_item.done", "output_index": idx, "item": item }))); - } - "custom_tool_call" => { - let input = item.get("input").and_then(Value::as_str).unwrap_or(""); - let mut added = item.clone(); - added["status"] = json!("in_progress"); - added["input"] = json!(""); - out.push_str(&sse_ev(&json!({ "type": "response.output_item.added", "output_index": idx, "item": added }))); - if !input.is_empty() { - out.push_str(&sse_ev(&json!({ "type": "response.custom_tool_call_input.delta", - "item_id": item_id, "call_id": item.get("call_id").cloned().unwrap_or(json!("")), - "output_index": idx, "delta": input }))); - } - out.push_str(&sse_ev(&json!({ "type": "response.custom_tool_call_input.done", - "item_id": item_id, "call_id": item.get("call_id").cloned().unwrap_or(json!("")), - "output_index": idx, "input": input }))); - out.push_str(&sse_ev(&json!({ "type": "response.output_item.done", "output_index": idx, "item": item }))); - } - "tool_search_call" => { - let mut added = item.clone(); - added["status"] = json!("in_progress"); - out.push_str(&sse_ev(&json!({ "type": "response.output_item.added", "output_index": idx, "item": added }))); - out.push_str(&sse_ev(&json!({ "type": "response.output_item.done", "output_index": idx, "item": item }))); - } - "reasoning" => { - let think = item["summary"][0]["text"].as_str().unwrap_or(""); - out.push_str(&sse_ev( - &json!({ "type": "response.output_item.added", "output_index": idx, - "item": { "type": "reasoning", "id": item_id, "summary": [] } }), - )); - if !think.is_empty() { - out.push_str(&sse_ev(&json!({ "type": "response.reasoning_summary_text.delta", "item_id": item_id, - "output_index": idx, "summary_index": 0, "delta": think }))); - } - out.push_str(&sse_ev(&json!({ "type": "response.output_item.done", "output_index": idx, "item": item }))); - } - _ => { - out.push_str(&sse_ev(&json!({ "type": "response.output_item.done", "output_index": idx, "item": item }))); - } - } - } - - let terminal_type = if full.get("status").and_then(Value::as_str) == Some("incomplete") { - "response.incomplete" - } else { - "response.completed" - }; - out.push_str(&sse_ev(&json!({ "type": terminal_type, "response": full }))); - out -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn encodes_ir_to_responses_request() { - let anthropic = json!({ - "model": "claude-x", "max_tokens": 500, - "system": "be terse", - "tools": [{ "name": "grep", "description": "search", "input_schema": { "type": "object" } }], - "thinking": { "type": "enabled", "budget_tokens": 4096 }, - "messages": [ - { "role": "user", "content": "find foo" }, - { "role": "assistant", "content": [{ "type": "tool_use", "id": "c1", "name": "grep", "input": { "q": "foo" } }] }, - { "role": "user", "content": [{ "type": "tool_result", "tool_use_id": "c1", "content": "found" }] } - ] - }); - let ir = crate::protocol::anthropic::decode_request(&anthropic).unwrap(); - let body = encode_request(&ir, "gpt-5.5", false); - - assert_eq!(body["model"], "gpt-5.5"); - assert_eq!(body["instructions"], "be terse"); - assert_eq!(body["max_output_tokens"], 500); - assert_eq!(body["reasoning"]["effort"], "medium"); // 4096 → medium - // tools flattened (name at item level, not under "function") - assert_eq!(body["tools"][0]["type"], "function"); - assert_eq!(body["tools"][0]["name"], "grep"); - // input items: user message, function_call, function_call_output - let input = body["input"].as_array().unwrap(); - assert!(input.iter().any(|i| i["type"] == "message" - && i["role"] == "user" - && i["content"][0]["type"] == "input_text" - && i["content"][0]["text"] == "find foo")); - let fc = input.iter().find(|i| i["type"] == "function_call").unwrap(); - assert_eq!(fc["name"], "grep"); - assert_eq!(fc["call_id"], "c1"); - let fco = input - .iter() - .find(|i| i["type"] == "function_call_output") - .unwrap(); - assert_eq!(fco["call_id"], "c1"); - assert_eq!(fco["output"], "found"); - } - - #[test] - fn decodes_responses_reply_to_ir_then_anthropic() { - // A Responses reply with an assistant message + a function_call output item. - let resp = json!({ - "id": "resp_1", "object": "response", "created_at": 1, "model": "gpt-5.5", "status": "completed", - "output": [ - { "type": "message", "role": "assistant", "content": [{ "type": "output_text", "text": "Working on it." }] }, - { "type": "function_call", "call_id": "call_7", "name": "grep", "arguments": "{\"q\":\"foo\"}" } - ], - "usage": { "input_tokens": 15, "output_tokens": 8, "total_tokens": 23 } - }); - let ir = decode_response(&resp.to_string()).unwrap(); - // reuse the Anthropic response encoder → verify the round-trip surfaces text + tool_use + usage - let out = crate::protocol::anthropic::encode_response(&ir, "claude-x"); - assert_eq!(out["stop_reason"], "tool_use"); - assert_eq!(out["usage"]["input_tokens"], 15); - assert_eq!(out["usage"]["output_tokens"], 8); - let content = out["content"].as_array().unwrap(); - assert!(content - .iter() - .any(|b| b["type"] == "text" && b["text"] == "Working on it.")); - let tu = content.iter().find(|b| b["type"] == "tool_use").unwrap(); - assert_eq!(tu["name"], "grep"); - assert_eq!(tu["input"]["q"], "foo"); - } - - // A representative Codex request (wire_api="responses"): instructions, flattened function - // tools, and an agentic history — user message, assistant prose + function_call, its - // function_call_output, and a reasoning item bridged onto the assistant turn. - fn codex_request() -> Value { - json!({ - "model": "z-ai/glm-5.2", - "instructions": "You are Codex.", - "input": [ - { "type": "message", "role": "user", "content": [{ "type": "input_text", "text": "list files" }] }, - { "type": "reasoning", "id": "rs_x", "summary": [{ "type": "summary_text", "text": "thinking…" }] }, - { "type": "message", "role": "assistant", "content": [{ "type": "output_text", "text": "Running ls." }] }, - { "type": "function_call", "call_id": "call_1", "name": "shell", "arguments": "{\"command\":[\"ls\"]}" }, - { "type": "function_call_output", "call_id": "call_1", "output": "a.txt\nb.txt" }, - { "type": "message", "role": "user", "content": [{ "type": "input_text", "text": "read a.txt" }] } - ], - "tools": [ - { "type": "function", "name": "shell", "description": "run a command", "strict": false, - "parameters": { "type": "object", "properties": { "command": { "type": "array" } } } }, - { "type": "web_search" } - ], - "tool_choice": "auto", - "parallel_tool_calls": false, - "reasoning": { "effort": "medium", "summary": "auto" }, - "store": false, - "stream": true - }) - } - - #[test] - fn decodes_codex_responses_request_to_ir() { - let ir = decode_request(&codex_request()).unwrap(); - let roles: Vec<_> = ir - .messages - .iter() - .map(|m| format!("{:?}", m.role)) - .collect(); - // instructions → System; assistant prose + function_call folded into ONE assistant turn; - // function_call_output → Tool; reasoning item bridged onto the assistant turn. - assert_eq!(roles, vec!["System", "User", "Assistant", "Tool", "User"]); - assert_eq!(ir.messages[0].content_as_text(), "You are Codex."); - assert_eq!(ir.messages[1].content_as_text(), "list files"); - assert_eq!(ir.messages[2].content_as_text(), "Running ls."); - assert_eq!( - ir.messages[2].reasoning_content.as_deref(), - Some("thinking…") - ); - let calls = ir.messages[2].tool_calls.as_ref().unwrap(); - assert_eq!(calls[0].id, "call_1"); - assert_eq!(calls[0].function.name, "shell"); - assert!(calls[0].function.arguments.contains("ls")); - assert_eq!(ir.messages[3].tool_call_id.as_deref(), Some("call_1")); - assert_eq!(ir.messages[3].content_as_text(), "a.txt\nb.txt"); - // flattened function tool recognized, non-function web_search dropped - let tools = ir.tools.as_ref().unwrap(); - assert_eq!(tools.len(), 1); - assert_eq!(tools[0].function.name, "shell"); - assert_eq!(ir.stream, Some(true)); - // reasoning.effort medium → thinking budget for an Anthropic upstream - assert_eq!(ir.enable_thinking, Some(true)); - assert_eq!(ir.thinking_budget, Some(4096)); - - // The crate encodes the IR to a real Anthropic Messages body — proves the reused half - // works end-to-end (responses client → anthropic upstream). - use llm_connector::core::Protocol; - use llm_connector::protocols::adapters::anthropic::AnthropicProtocol; - let body = AnthropicProtocol::new("") - .build_chat_request_body(&ir) - .unwrap(); - let msgs = body.get("messages").and_then(|v| v.as_array()).unwrap(); - // assistant turn carries a tool_use block; tool output became a user tool_result turn - assert!(msgs.iter().any(|m| m["role"] == "assistant" - && m["content"] - .as_array() - .unwrap() - .iter() - .any(|b| b["type"] == "tool_use" && b["id"] == "call_1"))); - assert!(msgs.iter().any(|m| m["role"] == "user" - && m["content"] - .as_array() - .unwrap() - .iter() - .any(|b| b["type"] == "tool_result" && b["tool_use_id"] == "call_1"))); - assert_eq!(body["system"], "You are Codex."); - } - - fn codex_responses_lite_request() -> Value { - json!({ - "model": "gpt-5.6-sol-pro", - "input": [ - { - "type": "additional_tools", - "role": "developer", - "tools": [ - { - "type": "custom", - "name": "exec", - "description": "Run JavaScript that can call nested Codex tools.", - "format": { - "type": "grammar", - "syntax": "lark", - "definition": "start: /[\\s\\S]+/" - } - }, - { - "type": "function", - "name": "wait", - "description": "Wait for a yielded exec cell.", - "parameters": { - "type": "object", - "properties": { "cell_id": { "type": "string" } }, - "required": ["cell_id"], - "additionalProperties": false - } - }, - { - "type": "function", - "name": "request_user_input", - "description": "Ask the user a question.", - "parameters": { - "type": "object", - "properties": { "question": { "type": "string" } }, - "required": ["question"] - } - }, - { - "type": "namespace", - "name": "collaboration", - "tools": [{ - "type": "function", - "name": "spawn_agent", - "description": "Spawn a sub-agent.", - "parameters": { - "type": "object", - "properties": { "task_name": { "type": "string" } }, - "required": ["task_name"] - } - }] - } - ] - }, - { - "type": "message", - "role": "developer", - "content": [{ "type": "input_text", "text": "You are Codex." }] - }, - { - "type": "message", - "role": "user", - "content": [{ "type": "input_text", "text": "Inspect the project." }] - } - ], - "tool_choice": "auto", - "parallel_tool_calls": false, - "reasoning": { "effort": "ultra", "summary": "none", "context": "all_turns" }, - "stream": true - }) - } - - #[test] - fn decodes_responses_lite_additional_tools_and_restores_custom_calls() { - let (ir, context) = - decode_request_with_context(&codex_responses_lite_request()).unwrap(); - - let roles = ir - .messages - .iter() - .map(|message| format!("{:?}", message.role)) - .collect::>(); - assert_eq!(roles, vec!["System", "User"]); - assert_eq!(ir.messages[0].content_as_text(), "You are Codex."); - - let tools = ir.tools.as_ref().unwrap(); - let names = tools - .iter() - .map(|tool| tool.function.name.as_str()) - .collect::>(); - assert_eq!( - names, - vec![ - "exec", - "wait", - "request_user_input", - "collaboration__spawn_agent" - ] - ); - assert_eq!( - context.lookup_chat_name("exec").map(|spec| spec.kind), - Some(CodexToolKind::Custom) - ); - assert_eq!(ir.enable_thinking, Some(true)); - assert_eq!(ir.thinking_budget, Some(32768)); - assert_eq!(ir.reasoning_effort, Some(ReasoningEffort::High)); - - let chat_request = OpenAIProtocol::new("") - .build_chat_request_body(&ir) - .unwrap(); - assert_eq!(chat_request["tools"].as_array().map(Vec::len), Some(4)); - assert_eq!(chat_request["reasoning_effort"], "high"); - - let chat_response = r#"{ - "id":"chatcmpl-lite","object":"chat.completion","created":1,"model":"up", - "choices":[{"index":0,"finish_reason":"tool_calls","message":{ - "role":"assistant","content":null, - "tool_calls":[{"id":"call_exec","type":"function","function":{ - "name":"exec","arguments":"{\"input\":\"const result = await tools.exec_command({cmd: \\\"pwd\\\"});\"}" - }}] - }}], - "usage":{"prompt_tokens":9,"completion_tokens":4,"total_tokens":13} - }"#; - let response_ir = OpenAIProtocol::new("") - .parse_response(chat_response) - .unwrap(); - let response = - encode_response_with_context(&response_ir, "gpt-5.6-sol-pro", &context); - let exec = response["output"] - .as_array() - .unwrap() - .iter() - .find(|item| item["type"] == "custom_tool_call") - .unwrap(); - assert_eq!(exec["name"], "exec"); - assert_eq!( - exec["input"], - "const result = await tools.exec_command({cmd: \"pwd\"});" - ); - } - - #[test] - fn rejects_call_outputs_without_a_preceding_matching_call() { - for input in [ - json!([{ - "type":"function_call_output","call_id":"missing_call","output":"done" - }]), - json!([ - {"type":"custom_tool_call_output","call_id":"late_call","output":"done"}, - {"type":"custom_tool_call","call_id":"late_call","name":"apply_patch","input":"patch"} - ]), - json!([{"type":"tool_search_output","tools":[]}]), - json!([ - {"type":"function_call","call_id":"duplicate_output","name":"shell","arguments":"{}"}, - {"type":"function_call_output","call_id":"duplicate_output","output":"one"}, - {"type":"function_call_output","call_id":"duplicate_output","output":"two"} - ]), - json!([ - {"type":"function_call","call_id":"wrong_kind","name":"shell","arguments":"{}"}, - {"type":"custom_tool_call_output","call_id":"wrong_kind","output":"done"} - ]), - json!([ - {"type":"function_call","call_id":"stale_call","name":"shell","arguments":"{}"}, - {"type":"message","role":"user","content":"start another turn"}, - {"type":"function_call_output","call_id":"stale_call","output":"done"} - ]), - json!([ - {"type":"function_call","call_id":"stale_bare","name":"shell","arguments":"{}"}, - {"role":"user","content":"start another turn"}, - {"type":"function_call_output","call_id":"stale_bare","output":"done"} - ]), - ] { - let error = decode_request(&json!({ "model":"m", "input":input })).unwrap_err(); - assert!( - error.contains("no preceding matching call"), - "unexpected validation error: {error}" - ); - } - } - - #[test] - fn rejects_ambiguous_duplicate_call_ids_and_interleaved_call_groups() { - for input in [ - json!([ - {"type":"function_call","call_id":"same","name":"first","arguments":"{}"}, - {"type":"custom_tool_call","call_id":"same","name":"second","input":"x"} - ]), - json!([ - {"type":"function_call","call_id":"c1","name":"first","arguments":"{}"}, - {"type":"function_call","call_id":"c2","name":"second","arguments":"{}"}, - {"type":"function_call_output","call_id":"c1","output":"one"}, - {"type":"function_call","call_id":"c3","name":"third","arguments":"{}"} - ]), - json!([ - {"type":"function_call","call_id":"reused","name":"first","arguments":"{}"}, - {"type":"function_call_output","call_id":"reused","output":"one"}, - {"role":"user","content":"next turn"}, - {"type":"function_call","call_id":"reused","name":"second","arguments":"{}"} - ]), - ] { - let error = decode_request(&json!({"model":"m","input":input})).unwrap_err(); - assert!( - error.contains("ambiguous"), - "unexpected validation error: {error}" - ); - } - } - - // Thinking chat upstreams reject tool-call history without reasoning, and MiniMax rejects - // `role:system` anywhere but the head — the decoder must bridge reasoning items onto their - // assistant turn and merge all system/developer text into one leading system message. - #[test] - fn bridges_reasoning_and_collapses_system_into_head() { - let req = json!({ - "model": "m", - "instructions": "You are Codex.", - "input": [ - { "type": "message", "role": "user", "content": [{ "type": "input_text", "text": "run ls" }] }, - { "type": "reasoning", "summary": [{ "type": "summary_text", "text": "need to list" }] }, - { "type": "function_call", "call_id": "c1", "name": "shell", "arguments": "{}" }, - { "type": "function_call_output", "call_id": "c1", "output": "a.txt" }, - { "type": "message", "role": "developer", "content": [{ "type": "input_text", "text": "be careful" }] }, - { "type": "message", "role": "user", "content": [{ "type": "input_text", "text": "again" }] } - ] - }); - let ir = decode_request(&req).unwrap(); - let roles: Vec<_> = ir - .messages - .iter() - .map(|m| format!("{:?}", m.role)) - .collect(); - // exactly ONE system message, at the head, carrying instructions + the developer item - assert_eq!(roles, vec!["System", "User", "Assistant", "Tool", "User"]); - assert_eq!( - ir.messages[0].content_as_text(), - "You are Codex.\n\nbe careful" - ); - // the reasoning that produced the tool call rides the tool-call assistant turn - assert_eq!( - ir.messages[2].reasoning_content.as_deref(), - Some("need to list") - ); - assert_eq!(ir.messages[2].tool_calls.as_ref().unwrap()[0].id, "c1"); - } - - #[test] - fn bridges_call_item_reasoning_without_duplicate_parallel_copies() { - let req = json!({ - "model":"m", - "input":[ - {"type":"reasoning","summary":[{"type":"summary_text","text":"inspect both"}]}, - {"type":"function_call","call_id":"c1","name":"first","arguments":"{}", - "reasoning_content":"inspect both"}, - {"type":"function_call","call_id":"c2","name":"second","arguments":"{}", - "reasoning_content":"inspect both"}, - {"type":"function_call_output","call_id":"c1","output":"one"}, - {"type":"function_call_output","call_id":"c2","output":"two"} - ] - }); - - let ir = decode_request(&req).unwrap(); - assert_eq!(ir.messages[0].role, Role::Assistant); - assert_eq!( - ir.messages[0].reasoning_content.as_deref(), - Some("inspect both") - ); - assert_eq!(ir.messages[0].tool_calls.as_ref().unwrap().len(), 2); - - let item_only = json!({ - "model":"m", - "input":[ - {"type":"function_call","call_id":"c3","name":"third","arguments":"{}", - "reasoning_content":"cached reasoning"}, - {"type":"function_call_output","call_id":"c3","output":"three"} - ] - }); - let ir = decode_request(&item_only).unwrap(); - assert_eq!( - ir.messages[0].reasoning_content.as_deref(), - Some("cached reasoning") - ); - } - - #[test] - fn encodes_ir_to_responses_response_with_tool_calls() { - // A chat upstream reply with prose + a tool call → the Responses body Codex consumes. - use llm_connector::core::Protocol; - let chat = r#"{ - "id":"chatcmpl-9","object":"chat.completion","created":1,"model":"gpt-4o", - "choices":[{"index":0,"finish_reason":"tool_calls","message":{ - "role":"assistant","content":"Checking.", - "tool_calls":[{"id":"call_9","type":"function", - "function":{"name":"shell","arguments":"{\"command\":[\"ls\"]}"}}]}}], - "usage":{"prompt_tokens":11,"completion_tokens":7,"total_tokens":18} - }"#; - let ir = OpenAIProtocol::new("").parse_response(chat).unwrap(); - let out = encode_response(&ir, "z-ai/glm-5.2"); - assert_eq!(out["object"], "response"); - assert_eq!(out["status"], "completed"); - assert_eq!(out["model"], "z-ai/glm-5.2"); - assert_eq!(out["usage"]["input_tokens"], 11); - assert_eq!(out["usage"]["output_tokens"], 7); - assert_eq!(out["usage"]["total_tokens"], 18); - let output = out["output"].as_array().unwrap(); - let m = output.iter().find(|i| i["type"] == "message").unwrap(); - assert_eq!(m["content"][0]["type"], "output_text"); - assert_eq!(m["content"][0]["text"], "Checking."); - let fc = output - .iter() - .find(|i| i["type"] == "function_call") - .unwrap(); - assert_eq!( - fc["call_id"], - response_scoped_call_id(out["id"].as_str().unwrap(), 0) - ); - assert_eq!(fc["name"], "shell"); - assert_eq!(fc["arguments"], "{\"command\":[\"ls\"]}"); - } - - #[test] - fn synthesized_responses_sse_carries_items_and_completed() { - use llm_connector::core::Protocol; - let chat = r#"{ - "id":"c1","object":"chat.completion","created":1,"model":"up", - "choices":[{"index":0,"finish_reason":"tool_calls","message":{ - "role":"assistant","content":"On it.", - "tool_calls":[{"id":"call_2","type":"function", - "function":{"name":"apply_patch","arguments":"{\"p\":1}"}}]}}], - "usage":{"prompt_tokens":5,"completion_tokens":3,"total_tokens":8} - }"#; - let ir = OpenAIProtocol::new("").parse_response(chat).unwrap(); - let sse = encode_response_sse(&ir, "alias-model"); - // ordered: created → message item events → function_call item events → completed - let created = sse.find("\"type\":\"response.created\"").unwrap(); - let item_done = sse.find("response.output_item.done").unwrap(); - let completed = sse.find("\"type\":\"response.completed\"").unwrap(); - assert!(created < item_done && item_done < completed); - // Codex reads items exclusively from output_item.done: both items must appear there. - assert!(sse.contains(r#""delta":"On it.""#)); - assert!(sse.contains(&format!( - r#""call_id":"{}""#, - response_scoped_call_id("resp_c1", 0) - ))); - assert!(sse.contains(r#""name":"apply_patch""#)); - assert!(sse.contains(r#""arguments":"{\"p\":1}""#)); - // completed carries id + usage (codex errors without them) - assert!(sse.contains(r#""input_tokens":5"#)); - assert!(sse.contains(r#""output_tokens":3"#)); - assert!(sse.contains(r#""id":"resp_c1""#)); - } - - #[test] - fn buffered_duplicate_upstream_call_ids_become_unique_and_response_scoped() { - use llm_connector::core::Protocol; - - let parse = |response_id: &str| { - OpenAIProtocol::new("") - .parse_response( - &json!({ - "id":response_id, - "object":"chat.completion", - "created":1, - "model":"up", - "choices":[{"index":0,"finish_reason":"tool_calls","message":{ - "role":"assistant","content":"", - "tool_calls":[ - {"id":"same","type":"function","function":{"name":"first","arguments":"{}"}}, - {"id":"same","type":"function","function":{"name":"second","arguments":"{}"}} - ] - }}] - }) - .to_string(), - ) - .unwrap() - }; - let first = encode_response(&parse("turn-1"), "alias"); - let second = encode_response(&parse("turn-2"), "alias"); - let call_ids = |response: &Value| { - response["output"] - .as_array() - .unwrap() - .iter() - .filter_map(|item| item.get("call_id").and_then(Value::as_str)) - .map(str::to_string) - .collect::>() - }; - let first_ids = call_ids(&first); - let second_ids = call_ids(&second); - - assert_eq!(first_ids.len(), 2); - assert_ne!(first_ids[0], first_ids[1]); - assert_ne!(first_ids[0], second_ids[0]); - assert_eq!( - first_ids[0], - response_scoped_call_id(first["id"].as_str().unwrap(), 0) - ); - assert_eq!( - first_ids[1], - response_scoped_call_id(first["id"].as_str().unwrap(), 1) - ); - } - - #[test] - fn buffered_truncation_stays_incomplete_across_responses_encoding() { - use llm_connector::core::Protocol; - let chat = r#"{ - "id":"c-length","object":"chat.completion","created":1,"model":"up", - "choices":[{"index":0,"finish_reason":"length","message":{ - "role":"assistant","content":"partial"}}], - "usage":{"prompt_tokens":5,"completion_tokens":3,"total_tokens":8} - }"#; - let ir = OpenAIProtocol::new("").parse_response(chat).unwrap(); - let response = encode_response(&ir, "alias-model"); - assert_eq!(response["status"], "incomplete"); - assert_eq!( - response["incomplete_details"]["reason"], - "max_output_tokens" - ); - - let sse = encode_response_sse(&ir, "alias-model"); - assert!(sse.contains("event: response.incomplete")); - assert!(!sse.contains("event: response.completed")); - - let decoded = decode_response(&response.to_string()).unwrap(); - assert_eq!(decoded.choices[0].finish_reason.as_deref(), Some("length")); - let failed = json!({ - "id":"resp_failed","status":"failed", - "error":{"message":"provider failed"} - }); - assert_eq!( - decode_response(&failed.to_string()).unwrap_err(), - "provider failed" - ); - } - - fn codex_request_with_extended_tools() -> Value { - json!({ - "model": "gpt-5.4", - "input": [ - { "type": "reasoning", "summary": [{ "type": "summary_text", "text": "use tools" }] }, - { "type": "custom_tool_call", "id": "ctc_1", "call_id": "call_custom", - "name": "apply_patch", "input": "*** Begin Patch\n*** End Patch" }, - { "type": "custom_tool_call_output", "call_id": "call_custom", "output": "Done!" }, - { "type": "function_call", "id": "fc_1", "call_id": "call_spawn", - "namespace": "multi_agent_v1", "name": "spawn_agent", - "arguments": "{\"task_name\":\"audit\"}" }, - { "type": "function_call_output", "call_id": "call_spawn", "output": "spawned" }, - { "type": "tool_search_call", "call_id": "call_search", "status": "completed", - "execution": "client", "arguments": { "query": "browser", "limit": 3 } }, - { "type": "tool_search_output", "call_id": "call_search", "status": "completed", - "execution": "client", "tools": [ - { "type": "custom", "name": "exec", "description": "Run JavaScript" } - ] } - ], - "tools": [ - { "type": "custom", "name": "apply_patch", "description": "Apply a patch", - "format": { "type": "grammar", "syntax": "lark", "definition": "start: /.+/" } }, - { "type": "namespace", "name": "multi_agent_v1", "tools": [ - { "type": "function", "name": "spawn_agent", "description": "Spawn an agent", - "parameters": { "type": "object", "properties": { - "task_name": { "type": "string" } - }, "required": ["task_name"] } } - ] }, - { "type": "tool_search", "execution": "client", - "description": "Search deferred tools from Drive and MCP; always prefer this over MCP listing.", - "parameters": { "type": "object", "properties": { - "query": { "type": "string" }, "limit": { "type": "number" } - }, "required": ["query"], "additionalProperties": false } } - ] - }) - } - - #[test] - fn preserves_custom_namespace_and_tool_search_request_semantics() { - let (ir, context) = - decode_request_with_context(&codex_request_with_extended_tools()).unwrap(); - let tools = ir.tools.as_ref().unwrap(); - let names = tools - .iter() - .map(|tool| tool.function.name.as_str()) - .collect::>(); - assert!(names.contains(&"apply_patch")); - assert!(names.contains(&"multi_agent_v1__spawn_agent")); - assert!(names.contains(&"tool_search")); - assert!(names.contains(&"exec")); - let search = tools - .iter() - .find(|tool| tool.function.name == "tool_search") - .unwrap(); - assert!(search - .function - .description - .as_deref() - .unwrap() - .contains("always prefer this")); - - let calls = ir - .messages - .iter() - .filter_map(|message| message.tool_calls.as_ref()) - .flatten() - .collect::>(); - let custom = calls - .iter() - .find(|call| call.function.name == "apply_patch") - .unwrap(); - assert_eq!( - serde_json::from_str::(&custom.function.arguments).unwrap()["input"], - "*** Begin Patch\n*** End Patch" - ); - assert!(calls - .iter() - .any(|call| call.function.name == "multi_agent_v1__spawn_agent")); - assert!(calls.iter().any(|call| call.function.name == "tool_search")); - assert_eq!( - context - .lookup_chat_name("multi_agent_v1__spawn_agent") - .unwrap() - .kind, - CodexToolKind::Namespace - ); - } - - #[test] - fn restores_extended_tool_types_in_buffered_response_and_sse() { - use llm_connector::core::Protocol; - - let (_, context) = - decode_request_with_context(&codex_request_with_extended_tools()).unwrap(); - let chat = r#"{ - "id":"chatcmpl-tools","object":"chat.completion","created":1,"model":"up", - "choices":[{"index":0,"finish_reason":"tool_calls","message":{ - "role":"assistant","content":null,"reasoning_content":"pick the right tools", - "tool_calls":[ - {"id":"call_custom","type":"function","function":{"name":"apply_patch","arguments":"{\"input\":\"*** Begin Patch\\n*** End Patch\"}"}}, - {"id":"call_spawn","type":"function","function":{"name":"multi_agent_v1__spawn_agent","arguments":"{\"task_name\":\"audit\"}"}}, - {"id":"call_search","type":"function","function":{"name":"tool_search","arguments":"{\"query\":\"browser\",\"limit\":3}"}} - ]}}], - "usage":{"prompt_tokens":9,"completion_tokens":4,"total_tokens":13} - }"#; - let ir = OpenAIProtocol::new("").parse_response(chat).unwrap(); - let response = encode_response_with_context(&ir, "gpt-5.4", &context); - let output = response["output"].as_array().unwrap(); - - let custom = output - .iter() - .find(|item| item["type"] == "custom_tool_call") - .unwrap(); - assert_eq!(custom["name"], "apply_patch"); - assert_eq!(custom["input"], "*** Begin Patch\n*** End Patch"); - assert_eq!(custom["reasoning_content"], "pick the right tools"); - - let namespaced = output - .iter() - .find(|item| item["namespace"] == "multi_agent_v1") - .unwrap(); - assert_eq!(namespaced["type"], "function_call"); - assert_eq!(namespaced["name"], "spawn_agent"); - assert_eq!(namespaced["namespace"], "multi_agent_v1"); - - let search = output - .iter() - .find(|item| item["type"] == "tool_search_call") - .unwrap(); - assert!(search.get("id").is_none()); - assert_eq!(search["arguments"]["query"], "browser"); - assert_eq!(search["arguments"]["limit"], 3); - - let sse = encode_response_sse_with_context(&ir, "gpt-5.4", &context); - assert!(sse.contains("event: response.custom_tool_call_input.delta")); - assert!(sse.contains("event: response.custom_tool_call_input.done")); - assert!(sse.contains(r#""type":"custom_tool_call""#)); - assert!(sse.contains(r#""type":"tool_search_call""#)); - assert!(sse.contains(r#""namespace":"multi_agent_v1""#)); - } - - fn assert_valid_chat_tool_alias(alias: &str) { - assert!( - is_valid_chat_tool_name(alias), - "invalid Chat tool alias: {alias:?}" - ); - assert!(alias.is_ascii()); - assert!(alias.len() <= CHAT_TOOL_NAME_MAX_LEN); - } - - #[test] - fn aliases_illegal_tool_names_and_restores_exact_response_identities() { - let req = json!({ - "model": "gpt-5.4", - "input": [ - { "type": "function_call", "call_id": "call_fn", "name": "read file/现在", "arguments": "{}" }, - { "type": "custom_tool_call", "call_id": "call_custom", "name": "apply.patch/β", "input": "raw" }, - { "type": "function_call", "call_id": "call_ns", "namespace": "multi agent/一", - "name": "spawn.agent?", "arguments": "{}" } - ], - "tools": [ - { "type": "function", "name": "read file/现在" }, - { "type": "custom", "name": "apply.patch/β" }, - { "type": "namespace", "name": "multi agent/一", "tools": [ - { "type": "function", "name": "spawn.agent?" } - ] } - ] - }); - let (ir, context) = decode_request_with_context(&req).unwrap(); - let tools = ir.tools.as_ref().unwrap(); - assert_eq!(tools.len(), 3); - for tool in tools { - assert_valid_chat_tool_alias(&tool.function.name); - } - for call in ir - .messages - .iter() - .filter_map(|message| message.tool_calls.as_ref()) - .flatten() - { - assert_valid_chat_tool_alias(&call.function.name); - } - - let function_alias = context.chat_name_for_response_tool("read file/现在", None); - let function_item = - context.response_tool_item("fc_fn", "completed", "call_fn", &function_alias, "{}"); - assert_eq!(function_item["type"], "function_call"); - assert_eq!(function_item["name"], "read file/现在"); - - let custom_alias = context.chat_name_for_custom_tool("apply.patch/β"); - let custom_item = context.response_tool_item( - "ctc_custom", - "completed", - "call_custom", - &custom_alias, - r#"{"input":"raw"}"#, - ); - assert_eq!(custom_item["type"], "custom_tool_call"); - assert_eq!(custom_item["name"], "apply.patch/β"); - assert_eq!(custom_item["input"], "raw"); - - let namespace_alias = - context.chat_name_for_response_tool("spawn.agent?", Some("multi agent/一")); - let namespace_item = - context.response_tool_item("fc_ns", "completed", "call_ns", &namespace_alias, "{}"); - assert_eq!(namespace_item["type"], "function_call"); - assert_eq!(namespace_item["namespace"], "multi agent/一"); - assert_eq!(namespace_item["name"], "spawn.agent?"); - } - - #[test] - fn aliases_long_utf8_names_deterministically_within_64_bytes() { - let first_name = format!("读取工具-{}-甲", "界".repeat(40)); - let second_name = format!("读取工具-{}-乙", "界".repeat(40)); - let req = json!({ - "tools": [ - { "type": "function", "name": first_name }, - { "type": "function", "name": second_name } - ] - }); - let first_context = CodexToolContext::from_request(&req); - let second_context = CodexToolContext::from_request(&req); - let first_alias = first_context.chat_name_for_response_tool(&first_name, None); - let second_alias = first_context.chat_name_for_response_tool(&second_name, None); - assert_valid_chat_tool_alias(&first_alias); - assert_valid_chat_tool_alias(&second_alias); - assert_ne!(first_alias, second_alias); - assert_eq!( - first_alias, - second_context.chat_name_for_response_tool(&first_name, None) - ); - assert_eq!( - second_alias, - second_context.chat_name_for_response_tool(&second_name, None) - ); - - let restored = first_context.response_tool_item( - "fc_long", - "completed", - "call_long", - &first_alias, - "{}", - ); - assert_eq!(restored["name"], first_name); - } - - #[test] - fn keeps_colliding_function_custom_search_and_namespace_identities_distinct() { - let definitions = vec![ - json!({ "type": "function", "name": "same" }), - json!({ "type": "custom", "name": "same" }), - json!({ "type": "function", "name": "tool_search" }), - json!({ "type": "tool_search" }), - json!({ "type": "function", "name": "a__b" }), - json!({ "type": "namespace", "name": "a", "tools": [ - { "type": "function", "name": "b" } - ] }), - ]; - let req = json!({ "tools": definitions }); - let mut reversed_definitions = req["tools"].as_array().unwrap().clone(); - reversed_definitions.reverse(); - let reversed_req = json!({ "tools": reversed_definitions }); - let context = CodexToolContext::from_request(&req); - let reversed_context = CodexToolContext::from_request(&reversed_req); - let specs = vec![ - CodexToolSpec { - kind: CodexToolKind::Function, - name: "same".into(), - namespace: None, - }, - CodexToolSpec { - kind: CodexToolKind::Custom, - name: "same".into(), - namespace: None, - }, - CodexToolSpec { - kind: CodexToolKind::Function, - name: "tool_search".into(), - namespace: None, - }, - CodexToolSpec { - kind: CodexToolKind::ToolSearch, - name: "tool_search".into(), - namespace: None, - }, - CodexToolSpec { - kind: CodexToolKind::Function, - name: "a__b".into(), - namespace: None, - }, - CodexToolSpec { - kind: CodexToolKind::Namespace, - name: "b".into(), - namespace: Some("a".into()), - }, - ]; - - assert_eq!(context.ir_tools().len(), specs.len()); - let aliases = specs - .iter() - .map(|spec| { - let alias = context.chat_name_for_spec(spec); - assert_valid_chat_tool_alias(&alias); - assert_eq!(context.lookup_chat_name(&alias), Some(spec)); - assert_eq!(alias, reversed_context.chat_name_for_spec(spec)); - alias - }) - .collect::>(); - assert_eq!(aliases.len(), specs.len()); - - let function_search_alias = context.chat_name_for_spec(&specs[2]); - let function_search = context.response_tool_item( - "fc_search", - "completed", - "call_fn_search", - &function_search_alias, - "{}", - ); - assert_eq!(function_search["type"], "function_call"); - assert_eq!(function_search["name"], "tool_search"); - - let actual_search_alias = context.chat_name_for_spec(&specs[3]); - let actual_search = context.response_tool_item( - "tsc_search", - "completed", - "call_search", - &actual_search_alias, - r#"{"query":"x"}"#, - ); - assert_eq!(actual_search["type"], "tool_search_call"); - } - - #[test] - fn custom_tool_description_omits_full_definition_and_lark_grammar() { - let req = json!({ - "tools": [{ - "type": "custom", - "name": "apply_patch", - "description": "Apply a patch to the workspace.", - "format": { - "type": "grammar", - "syntax": "lark", - "definition": "start: patch+\npatch: /.+/" - } - }] - }); - let context = CodexToolContext::from_request(&req); - let tool = &context.ir_tools()[0]; - let description = tool.function.description.as_deref().unwrap(); - assert!(description.starts_with( - "Apply a patch to the workspace.\n\nPass the custom tool's raw input unchanged in the `input` string field." - )); - assert!(description.contains("*** Add File: path")); - assert!( - description.contains("*** Begin Patch\n*** Add File: path\n+content\n*** End Patch") - ); - assert!(description.contains("never prefix either boundary marker")); - assert!(!description.contains("Original Responses custom-tool definition")); - assert!(!description.contains("definition")); - assert!(!description.contains("start: patch")); - assert!(!description.contains("lark")); - } -} diff --git a/src-tauri/src/protocol/openai_responses/decode_request.rs b/src-tauri/src/protocol/openai_responses/decode_request.rs new file mode 100644 index 0000000..316865d --- /dev/null +++ b/src-tauri/src/protocol/openai_responses/decode_request.rs @@ -0,0 +1,215 @@ +// OpenAI Responses REQUEST → IR (client side: what Codex sends the gateway with +// wire_api="responses"). + +use super::helpers::{effort_to_budget, effort_to_reasoning_effort}; +use super::history::{append_history_tool_call, response_history_tool_call, response_tool_output_text}; +use super::parts::{ + append_reasoning_content, parts_images, parts_text, reasoning_item_text, response_item_call_id, +}; +use super::tools::CodexToolContext; +use super::validate::validate_call_output_pairs; +use llm_connector::types::{ChatRequest, Message, MessageBlock, Role, ToolChoice}; +use serde_json::Value; + +/// Decode an OpenAI Responses REQUEST json (what Codex sends with wire_api="responses") into the +/// IR. Handles the full item vocabulary of an agentic history: message items (user input_text / +/// input_image, assistant output_text), all client-executed tool call/output item types, and `reasoning` +/// items — whose text is bridged onto the adjacent assistant message as `reasoning_content`, +/// because thinking chat upstreams (Kimi/Moonshot, DeepSeek, …) reject assistant tool-call +/// history that lost its reasoning. System/developer items collapse into ONE leading system +/// message: strict providers (MiniMax) reject `role:system` anywhere but the head. Custom, +/// tool-search, and namespace tools are flattened to chat functions and restored with the returned +/// [`CodexToolContext`]. +pub fn decode_request(req: &Value) -> Result { + decode_request_with_context(req).map(|(request, _)| request) +} + +pub fn decode_request_with_context(req: &Value) -> Result<(ChatRequest, CodexToolContext), String> { + validate_call_output_pairs(req)?; + let model = req + .get("model") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let tool_context = CodexToolContext::from_request(req); + let mut messages: Vec = vec![]; + // All system text (instructions + system/developer message items), merged to the head. + let mut system_texts: Vec = vec![]; + // Reasoning waiting for the assistant message it belongs to (model output order is + // reasoning → prose → tool calls, so reasoning usually precedes its assistant message). + let mut pending_reasoning: Option = None; + + if let Some(instr) = req.get("instructions").and_then(|v| v.as_str()) { + if !instr.trim().is_empty() { + system_texts.push(instr.to_string()); + } + } + + match req.get("input") { + Some(Value::String(s)) => { + if !s.is_empty() { + messages.push(Message::text(Role::User, s.clone())); + } + } + Some(Value::Array(items)) => { + for item in items { + // Bare `{role, content}` items (no "type") are legal Responses input; treat them + // as message items. + let ty = item.get("type").and_then(|v| v.as_str()).unwrap_or( + if item.get("role").is_some() { + "message" + } else { + "" + }, + ); + match ty { + "message" => { + let role = item.get("role").and_then(|v| v.as_str()).unwrap_or("user"); + let content = item.get("content").cloned().unwrap_or(Value::Null); + let text = parts_text(&content); + match role { + "assistant" => { + if !text.is_empty() { + let mut m = Message::text(Role::Assistant, text); + if let Some(r) = pending_reasoning.take() { + append_reasoning_content(&mut m, &r); + } + messages.push(m); + } + } + "system" | "developer" => { + pending_reasoning = None; + if !text.trim().is_empty() { + system_texts.push(text); + } + } + _ => { + pending_reasoning = None; + let mut blocks: Vec = vec![]; + if !text.is_empty() { + blocks.push(MessageBlock::text(text)); + } + blocks.extend(parts_images(&content)); + if !blocks.is_empty() { + messages.push(Message::new(Role::User, blocks)); + } + } + } + } + "reasoning" => { + // Belongs to the assistant step it neighbors: fold backward onto a + // directly preceding assistant message, else hold for the next one. + if let Some(text) = reasoning_item_text(item) { + match messages.last_mut() { + Some(m) if m.role == Role::Assistant => { + append_reasoning_content(m, &text) + } + _ => match &mut pending_reasoning { + Some(existing) if !existing.is_empty() => { + existing.push_str("\n\n"); + existing.push_str(text.trim()); + } + slot => *slot = Some(text.trim().to_string()), + }, + } + } + } + "function_call" | "custom_tool_call" | "tool_search_call" => { + if let Some(call) = response_history_tool_call(item, &tool_context) { + let item_reasoning = item + .get("reasoning_content") + .or_else(|| item.get("reasoning")) + .and_then(Value::as_str); + append_history_tool_call( + &mut messages, + &mut pending_reasoning, + item_reasoning, + call, + ); + } + } + "function_call_output" | "custom_tool_call_output" | "tool_search_output" => { + pending_reasoning = None; + let id = response_item_call_id(item).unwrap_or("").to_string(); + if !id.is_empty() { + messages.push(Message::tool(response_tool_output_text(item), id)); + } + } + _ => {} + } + } + } + _ => {} + } + + if !system_texts.is_empty() { + messages.insert(0, Message::text(Role::System, system_texts.join("\n\n"))); + } + + let mut cr = ChatRequest::new(model).with_messages(messages); + if let Some(mt) = req.get("max_output_tokens").and_then(|v| v.as_u64()) { + cr = cr.with_max_tokens(mt as u32); + } + if let Some(t) = req.get("temperature").and_then(|v| v.as_f64()) { + cr = cr.with_temperature(t as f32); + } + if let Some(p) = req.get("top_p").and_then(|v| v.as_f64()) { + cr = cr.with_top_p(p as f32); + } + if req.get("stream").and_then(|v| v.as_bool()).unwrap_or(false) { + cr = cr.with_stream(true); + } + let tools = tool_context.ir_tools(); + if !tools.is_empty() { + cr = cr.with_tools(tools); + } + // tool_choice: mode strings pass through; both the flattened Responses object form + // ({type:"function",name}) and the nested Chat form pin a specific function. + if let Some(tc) = req.get("tool_choice") { + if let Some(mode) = tc.as_str() { + if matches!(mode, "auto" | "none" | "required") { + cr.tool_choice = Some(ToolChoice::Mode(mode.to_string())); + } + } else if let Some(kind) = tc.get("type").and_then(Value::as_str) { + let selected = match kind { + "function" => tc + .get("name") + .and_then(Value::as_str) + .or_else(|| { + tc.get("function") + .and_then(|f| f.get("name")) + .and_then(Value::as_str) + }) + .map(|name| { + tool_context.chat_name_for_response_tool( + name, + tc.get("namespace").and_then(Value::as_str), + ) + }), + "custom" => tc + .get("name") + .and_then(Value::as_str) + .map(|name| tool_context.chat_name_for_custom_tool(name)), + "tool_search" => Some(tool_context.chat_name_for_tool_search()), + _ => None, + }; + if let Some(name) = selected { + cr.tool_choice = Some(ToolChoice::function(name)); + } + } + } + // Preserve both representations: Anthropic-family encoders consume the thinking budget, + // while OpenAI-compatible Chat encoders consume reasoning_effort. Higher Responses tiers do + // not exist in the connector enum, so xhigh/max/ultra intentionally collapse to High there. + if let Some(effort) = req + .get("reasoning") + .and_then(|r| r.get("effort")) + .and_then(|v| v.as_str()) + { + cr = cr + .with_enable_thinking(true) + .with_thinking_budget(effort_to_budget(effort)) + .with_reasoning_effort(effort_to_reasoning_effort(effort)); + } + Ok((cr, tool_context)) +} diff --git a/src-tauri/src/protocol/openai_responses/decode_response.rs b/src-tauri/src/protocol/openai_responses/decode_response.rs new file mode 100644 index 0000000..0b9e9fa --- /dev/null +++ b/src-tauri/src/protocol/openai_responses/decode_response.rs @@ -0,0 +1,93 @@ +// OpenAI Responses RESPONSE → IR (provider side: parsing what a Responses upstream returned). + +use llm_connector::core::Protocol; +use llm_connector::protocols::adapters::openai::OpenAIProtocol; +use llm_connector::types::ChatResponse; +use serde_json::{json, Value}; + +/// OpenAI Responses RESPONSE (buffered) → IR. We reshape the Responses reply into an OpenAI Chat +/// completion and let the crate's parse_response build the IR — reusing its battle-tested mapping. +pub fn decode_response(text: &str) -> Result { + let v: Value = serde_json::from_str(text).map_err(|e| format!("responses parse: {}", e))?; + if v.get("status").and_then(Value::as_str) == Some("failed") { + let message = v + .pointer("/error/message") + .and_then(Value::as_str) + .unwrap_or("upstream Responses request failed"); + return Err(message.to_string()); + } + let output = v + .get("output") + .and_then(|o| o.as_array()) + .cloned() + .unwrap_or_default(); + + let mut content = String::new(); + let mut tool_calls: Vec = vec![]; + let mut had_tool = false; + for item in &output { + match item.get("type").and_then(|t| t.as_str()) { + Some("message") => { + if let Some(cs) = item.get("content").and_then(|c| c.as_array()) { + for c in cs { + if let Some(t) = c.get("text").and_then(|v| v.as_str()) { + content.push_str(t); + } + } + } + } + Some("function_call") => { + had_tool = true; + tool_calls.push(json!({ + "id": item.get("call_id").or_else(|| item.get("id")).cloned().unwrap_or(json!("")), + "type": "function", + "function": { + "name": item.get("name").cloned().unwrap_or(json!("")), + "arguments": item.get("arguments").and_then(|v| v.as_str()).unwrap_or("{}"), + } + })); + } + _ => {} + } + } + // fall back to the flattened output_text if no message items carried content + if content.is_empty() { + if let Some(t) = v.get("output_text").and_then(|v| v.as_str()) { + content = t.to_string(); + } + } + + let usage = v.get("usage").cloned().unwrap_or(json!({})); + let mut message = json!({ "role": "assistant", "content": content }); + if !tool_calls.is_empty() { + message["tool_calls"] = json!(tool_calls); + } + let finish_reason = if v.get("status").and_then(Value::as_str) == Some("incomplete") { + match v + .pointer("/incomplete_details/reason") + .and_then(Value::as_str) + { + Some("content_filter") => "content_filter", + _ => "length", + } + } else if had_tool { + "tool_calls" + } else { + "stop" + }; + let chat = json!({ + "id": v.get("id").cloned().unwrap_or(json!("resp")), + "object": "chat.completion", + "created": 0, + "model": v.get("model").cloned().unwrap_or(json!("")), + "choices": [{ "index": 0, "finish_reason": finish_reason, "message": message }], + "usage": { + "prompt_tokens": usage.get("input_tokens").and_then(|v| v.as_i64()).unwrap_or(0), + "completion_tokens": usage.get("output_tokens").and_then(|v| v.as_i64()).unwrap_or(0), + "total_tokens": usage.get("total_tokens").and_then(|v| v.as_i64()).unwrap_or(0), + } + }); + OpenAIProtocol::new("") + .parse_response(&chat.to_string()) + .map_err(|e| e.to_string()) +} diff --git a/src-tauri/src/protocol/openai_responses/encode_request.rs b/src-tauri/src/protocol/openai_responses/encode_request.rs new file mode 100644 index 0000000..c930003 --- /dev/null +++ b/src-tauri/src/protocol/openai_responses/encode_request.rs @@ -0,0 +1,105 @@ +// IR → OpenAI Responses REQUEST (provider side: the gateway talking to a Responses upstream). + +use super::helpers::budget_to_effort; +use llm_connector::types::{ChatRequest, Role}; +use serde_json::{json, Value}; + +/// IR (ChatRequest) → OpenAI Responses request BODY. `outgoing_model` is the provider's real model. +pub fn encode_request(ir: &ChatRequest, outgoing_model: &str, stream: bool) -> Value { + let mut instructions: Option = None; + let mut input: Vec = vec![]; + + for m in &ir.messages { + match m.role { + Role::System => { + // Responses carries the system prompt in `instructions`, not the input array. + let t = m.content_as_text(); + if !t.trim().is_empty() { + instructions = Some(match instructions.take() { + Some(prev) => format!("{}\n{}", prev, t), + None => t, + }); + } + } + Role::Tool => { + // a tool result → function_call_output item + input.push(json!({ + "type": "function_call_output", + "call_id": m.tool_call_id.clone().unwrap_or_default(), + "output": m.content_as_text(), + })); + } + Role::User => { + let text = m.content_as_text(); + let mut content: Vec = vec![]; + if !text.is_empty() { + content.push(json!({ "type": "input_text", "text": text })); + } + for b64 in m.content_as_images_base64() { + content.push(json!({ "type": "input_image", "image_url": format!("data:image/png;base64,{}", b64) })); + } + if !content.is_empty() { + input.push(json!({ "type": "message", "role": "user", "content": content })); + } + } + Role::Assistant => { + let text = m.content_as_text(); + if !text.is_empty() { + input.push(json!({ "type": "message", "role": "assistant", + "content": [{ "type": "output_text", "text": text }] })); + } + if let Some(calls) = &m.tool_calls { + for tc in calls { + input.push(json!({ + "type": "function_call", + "call_id": tc.id, + "name": tc.function.name, + "arguments": tc.function.arguments, + })); + } + } + } + } + } + + let mut body = json!({ + "model": outgoing_model, + "input": input, + "stream": stream, + }); + if let Some(instr) = instructions { + body["instructions"] = json!(instr); + } + if let Some(mt) = ir.max_tokens { + body["max_output_tokens"] = json!(mt); + } + if let Some(t) = ir.temperature { + body["temperature"] = json!(t); + } + if let Some(p) = ir.top_p { + body["top_p"] = json!(p); + } + // tools → Responses function tools (fields flattened at the item level, not nested under + // "function" like Chat Completions). + if let Some(tools) = &ir.tools { + let arr: Vec = tools + .iter() + .map(|t| { + json!({ + "type": "function", + "name": t.function.name, + "description": t.function.description, + "parameters": t.function.parameters, + }) + }) + .collect(); + if !arr.is_empty() { + body["tools"] = json!(arr); + } + } + // Anthropic extended thinking → Responses reasoning effort. + if ir.enable_thinking == Some(true) { + body["reasoning"] = json!({ "effort": budget_to_effort(ir.thinking_budget) }); + } + body +} diff --git a/src-tauri/src/protocol/openai_responses/encode_response.rs b/src-tauri/src/protocol/openai_responses/encode_response.rs new file mode 100644 index 0000000..3b32b9d --- /dev/null +++ b/src-tauri/src/protocol/openai_responses/encode_response.rs @@ -0,0 +1,103 @@ +// IR → OpenAI Responses RESPONSE json (client side: the buffered body Codex consumes). + +use super::helpers::{response_incomplete_reason, response_scoped_call_id}; +use super::tools::CodexToolContext; +use llm_connector::types::ChatResponse; +use serde_json::{json, Value}; + +/// Encode the IR response back into an OpenAI Responses RESPONSE json. `client_model` is the name +/// the client asked for (so Codex sees its own model, not the upstream's). Unlike the crate's +/// chat_response_to_responses_response this maps tool_calls → function_call items and provider +/// reasoning → a reasoning item — both load-bearing for Codex's agent loop. +pub fn encode_response(resp: &ChatResponse, client_model: &str) -> Value { + encode_response_with_context(resp, client_model, &CodexToolContext::default()) +} + +pub fn encode_response_with_context( + resp: &ChatResponse, + client_model: &str, + tool_context: &CodexToolContext, +) -> Value { + let choice = resp.choices.first(); + let msg = choice.map(|c| &c.message); + // Same fallback as anthropic.rs: when a turn has tool_calls the crate parks the prose only in + // the top-level ChatResponse.content. + let text = { + let t = msg.map(|m| m.content_as_text()).unwrap_or_default(); + if t.is_empty() { + resp.content.clone() + } else { + t + } + }; + // never a constant fallback — item ids derive from this and land in client history + let rid = if resp.id.is_empty() { + super::super::uid("ccbud") + } else { + resp.id.clone() + }; + + let mut output: Vec = vec![]; + if let Some(reasoning) = msg.and_then(|m| m.reasoning_any()) { + if !reasoning.trim().is_empty() { + output.push(json!({ "type": "reasoning", "id": format!("rs_{}", rid), + "summary": [{ "type": "summary_text", "text": reasoning }] })); + } + } + if !text.is_empty() { + output.push( + json!({ "type": "message", "id": format!("msg_{}", rid), "status": "completed", + "role": "assistant", + "content": [{ "type": "output_text", "annotations": [], "text": text }] }), + ); + } + if let Some(m) = msg { + if let Some(calls) = &m.tool_calls { + for (i, tc) in calls.iter().enumerate() { + let call_id = response_scoped_call_id(&format!("resp_{}", rid), i); + let item_id = tool_context.response_item_id(&tc.function.name, &rid, i); + output.push(tool_context.response_tool_item_with_reasoning( + &item_id, + "completed", + &call_id, + &tc.function.name, + &tc.function.arguments, + m.reasoning_any(), + )); + } + } + } + if output.is_empty() { + // Codex builds the turn from output items; an empty message beats an empty array. + output.push(json!({ "type": "message", "id": format!("msg_{}", rid), "status": "completed", + "role": "assistant", "content": [{ "type": "output_text", "annotations": [], "text": "" }] })); + } + + let usage = resp.usage.as_ref(); + let input_tokens = usage.map(|u| u.prompt_tokens).unwrap_or(0) as i64; + let output_tokens = usage.map(|u| u.completion_tokens).unwrap_or(0) as i64; + let total = + (usage.map(|u| u.total_tokens).unwrap_or(0) as i64).max(input_tokens + output_tokens); + let incomplete_reason = + response_incomplete_reason(choice.and_then(|choice| choice.finish_reason.as_deref())); + let mut response = json!({ + "id": format!("resp_{}", rid), + "object": "response", + "created_at": resp.created, + "status": if incomplete_reason.is_some() { "incomplete" } else { "completed" }, + "model": client_model, + "output": output, + "output_text": text, + "usage": { + "input_tokens": input_tokens, + "input_tokens_details": { "cached_tokens": 0 }, + "output_tokens": output_tokens, + "output_tokens_details": { "reasoning_tokens": 0 }, + "total_tokens": total, + } + }); + if let Some(reason) = incomplete_reason { + response["incomplete_details"] = json!({ "reason": reason }); + } + response +} diff --git a/src-tauri/src/protocol/openai_responses/encode_sse.rs b/src-tauri/src/protocol/openai_responses/encode_sse.rs new file mode 100644 index 0000000..c15b501 --- /dev/null +++ b/src-tauri/src/protocol/openai_responses/encode_sse.rs @@ -0,0 +1,149 @@ +// IR → a synthesized OpenAI Responses SSE event sequence (client side: what Codex reads when the +// upstream was translated buffered). + +use super::encode_response::encode_response_with_context; +use super::tools::CodexToolContext; +use llm_connector::types::ChatResponse; +use serde_json::{json, Value}; + +fn sse_ev(data: &Value) -> String { + let t = data + .get("type") + .and_then(|v| v.as_str()) + .unwrap_or("message"); + format!( + "event: {}\ndata: {}\n\n", + t, + serde_json::to_string(data).unwrap_or_default() + ) +} + +/// Synthesize an OpenAI Responses SSE event sequence from a finished IR response. Used when the +/// client (Codex) asked to stream but the upstream was translated buffered — the client still gets +/// a valid `response.created → output_item.added/delta/done per item → terminal event` stream, just +/// delivered at once. Codex materializes items only from `response.output_item.done`; truncations +/// terminate with `response.incomplete` instead of being mislabeled completed. +pub fn encode_response_sse(resp: &ChatResponse, client_model: &str) -> String { + encode_response_sse_with_context(resp, client_model, &CodexToolContext::default()) +} + +pub fn encode_response_sse_with_context( + resp: &ChatResponse, + client_model: &str, + tool_context: &CodexToolContext, +) -> String { + let full = encode_response_with_context(resp, client_model, tool_context); + let rid = full + .get("id") + .and_then(|v| v.as_str()) + .unwrap_or("resp_ccbud") + .to_string(); + let mut out = String::new(); + out.push_str(&sse_ev(&json!({ "type": "response.created", + "response": { "id": rid, "object": "response", "status": "in_progress", "model": client_model } }))); + + let items = full + .get("output") + .and_then(|v| v.as_array()) + .cloned() + .unwrap_or_default(); + for (idx, item) in items.iter().enumerate() { + let item_id = item + .get("id") + .and_then(|v| v.as_str()) + .unwrap_or("item") + .to_string(); + match item.get("type").and_then(|v| v.as_str()).unwrap_or("") { + "message" => { + let text = item["content"][0]["text"].as_str().unwrap_or(""); + out.push_str(&sse_ev(&json!({ "type": "response.output_item.added", "output_index": idx, + "item": { "type": "message", "id": item_id, "status": "in_progress", "role": "assistant", "content": [] } }))); + out.push_str(&sse_ev( + &json!({ "type": "response.content_part.added", "item_id": item_id, + "output_index": idx, "content_index": 0, + "part": { "type": "output_text", "annotations": [], "text": "" } }), + )); + if !text.is_empty() { + out.push_str(&sse_ev( + &json!({ "type": "response.output_text.delta", "item_id": item_id, + "output_index": idx, "content_index": 0, "delta": text }), + )); + } + out.push_str(&sse_ev( + &json!({ "type": "response.output_text.done", "item_id": item_id, + "output_index": idx, "content_index": 0, "text": text }), + )); + out.push_str(&sse_ev( + &json!({ "type": "response.content_part.done", "item_id": item_id, + "output_index": idx, "content_index": 0, + "part": { "type": "output_text", "annotations": [], "text": text } }), + )); + out.push_str(&sse_ev(&json!({ "type": "response.output_item.done", "output_index": idx, "item": item }))); + } + "function_call" => { + let args = item + .get("arguments") + .and_then(|v| v.as_str()) + .unwrap_or("{}"); + let mut added = item.clone(); + added["status"] = json!("in_progress"); + added["arguments"] = json!(""); + out.push_str(&sse_ev(&json!({ "type": "response.output_item.added", "output_index": idx, "item": added }))); + out.push_str(&sse_ev( + &json!({ "type": "response.function_call_arguments.delta", "item_id": item_id, + "output_index": idx, "delta": args }), + )); + out.push_str(&sse_ev( + &json!({ "type": "response.function_call_arguments.done", "item_id": item_id, + "output_index": idx, "arguments": args }), + )); + out.push_str(&sse_ev(&json!({ "type": "response.output_item.done", "output_index": idx, "item": item }))); + } + "custom_tool_call" => { + let input = item.get("input").and_then(Value::as_str).unwrap_or(""); + let mut added = item.clone(); + added["status"] = json!("in_progress"); + added["input"] = json!(""); + out.push_str(&sse_ev(&json!({ "type": "response.output_item.added", "output_index": idx, "item": added }))); + if !input.is_empty() { + out.push_str(&sse_ev(&json!({ "type": "response.custom_tool_call_input.delta", + "item_id": item_id, "call_id": item.get("call_id").cloned().unwrap_or(json!("")), + "output_index": idx, "delta": input }))); + } + out.push_str(&sse_ev(&json!({ "type": "response.custom_tool_call_input.done", + "item_id": item_id, "call_id": item.get("call_id").cloned().unwrap_or(json!("")), + "output_index": idx, "input": input }))); + out.push_str(&sse_ev(&json!({ "type": "response.output_item.done", "output_index": idx, "item": item }))); + } + "tool_search_call" => { + let mut added = item.clone(); + added["status"] = json!("in_progress"); + out.push_str(&sse_ev(&json!({ "type": "response.output_item.added", "output_index": idx, "item": added }))); + out.push_str(&sse_ev(&json!({ "type": "response.output_item.done", "output_index": idx, "item": item }))); + } + "reasoning" => { + let think = item["summary"][0]["text"].as_str().unwrap_or(""); + out.push_str(&sse_ev( + &json!({ "type": "response.output_item.added", "output_index": idx, + "item": { "type": "reasoning", "id": item_id, "summary": [] } }), + )); + if !think.is_empty() { + out.push_str(&sse_ev(&json!({ "type": "response.reasoning_summary_text.delta", "item_id": item_id, + "output_index": idx, "summary_index": 0, "delta": think }))); + } + out.push_str(&sse_ev(&json!({ "type": "response.output_item.done", "output_index": idx, "item": item }))); + } + _ => { + out.push_str(&sse_ev(&json!({ "type": "response.output_item.done", "output_index": idx, "item": item }))); + } + } + } + + let terminal_type = if full.get("status").and_then(Value::as_str) == Some("incomplete") { + "response.incomplete" + } else { + "response.completed" + }; + out.push_str(&sse_ev(&json!({ "type": terminal_type, "response": full }))); + out +} diff --git a/src-tauri/src/protocol/openai_responses/helpers.rs b/src-tauri/src/protocol/openai_responses/helpers.rs new file mode 100644 index 0000000..11399ee --- /dev/null +++ b/src-tauri/src/protocol/openai_responses/helpers.rs @@ -0,0 +1,87 @@ +// Small shared conversions used by both Responses halves: call-id scoping, custom-tool argument +// wrapping, and the reasoning-effort ↔ thinking-budget mapping. + +use super::tools::CUSTOM_TOOL_INPUT_FIELD; +use llm_connector::types::ReasoningEffort; +use serde_json::{json, Value}; +use sha1::{Digest, Sha1}; + +/// Client-visible call ids must be unique even when an OpenAI-compatible upstream repeats or +/// omits its own ids for parallel calls. Scope them to the response and output position; the +/// client echoes this id, so subsequent translated history remains unambiguous. +pub(crate) fn response_scoped_call_id(response_id: &str, index: usize) -> String { + let mut digest = Sha1::new(); + digest.update(response_id.as_bytes()); + let digest = format!("{:x}", digest.finalize()); + format!("call_{}_{}", &digest[..16], index) +} + +pub(crate) fn custom_tool_input_from_chat_arguments(arguments: &str) -> String { + if arguments.trim().is_empty() { + return String::new(); + } + match serde_json::from_str::(arguments) { + Ok(Value::Object(object)) => object + .get(CUSTOM_TOOL_INPUT_FIELD) + .and_then(Value::as_str) + .unwrap_or(arguments) + .to_string(), + _ => arguments.to_string(), + } +} + +pub(super) fn wrap_custom_tool_input(input: &Value) -> String { + let input = input + .as_str() + .map(ToString::to_string) + .unwrap_or_else(|| input.to_string()); + json!({ "input": input }).to_string() +} + +pub(super) fn parse_tool_arguments_object(arguments: &str) -> Value { + if arguments.trim().is_empty() { + return json!({}); + } + serde_json::from_str::(arguments) + .ok() + .filter(Value::is_object) + .unwrap_or_else(|| json!({ "query": arguments })) +} + +/// Map a thinking budget (tokens) to a Responses reasoning effort tier. +pub(super) fn budget_to_effort(budget: Option) -> &'static str { + match budget { + Some(b) if b >= 8192 => "high", + Some(b) if b >= 2048 => "medium", + _ => "low", + } +} + +/// Reverse of budget_to_effort: a Responses reasoning effort tier → a thinking budget (tokens). +pub(super) fn effort_to_budget(effort: &str) -> u32 { + match effort { + "ultra" | "max" => 32768, + "xhigh" => 24576, + "high" => 16384, + "medium" => 4096, + _ => 1024, // "low" / "minimal" + } +} + +pub(super) fn effort_to_reasoning_effort(effort: &str) -> ReasoningEffort { + match effort { + "medium" => ReasoningEffort::Medium, + "high" | "xhigh" | "max" | "ultra" => ReasoningEffort::High, + _ => ReasoningEffort::Low, + } +} + +pub(super) fn response_incomplete_reason(finish_reason: Option<&str>) -> Option<&'static str> { + match finish_reason { + Some("length" | "max_tokens" | "model_context_window_exceeded") => { + Some("max_output_tokens") + } + Some("content_filter") => Some("content_filter"), + _ => None, + } +} diff --git a/src-tauri/src/protocol/openai_responses/history.rs b/src-tauri/src/protocol/openai_responses/history.rs new file mode 100644 index 0000000..ae10b38 --- /dev/null +++ b/src-tauri/src/protocol/openai_responses/history.rs @@ -0,0 +1,131 @@ +// Turning Responses history items (tool calls and their outputs) into IR assistant/tool messages. + +use super::helpers::wrap_custom_tool_input; +use super::parts::{append_reasoning_content, parts_text, response_item_call_id}; +use super::tools::CodexToolContext; +use llm_connector::types::{FunctionCall, Message, Role, ToolCall}; +use serde_json::{json, Value}; + +pub(super) fn response_history_tool_call(item: &Value, context: &CodexToolContext) -> Option { + let ty = item.get("type").and_then(Value::as_str).unwrap_or(""); + let id = response_item_call_id(item).unwrap_or("").to_string(); + if id.is_empty() { + return None; + } + let (name, arguments) = match ty { + "function_call" => { + let original_name = item.get("name").and_then(Value::as_str).unwrap_or(""); + let namespace = item.get("namespace").and_then(Value::as_str); + let name = context.chat_name_for_response_tool(original_name, namespace); + let arguments = match item.get("arguments") { + Some(Value::String(arguments)) => arguments.clone(), + Some(arguments) if !arguments.is_null() => arguments.to_string(), + _ => "{}".to_string(), + }; + (name, arguments) + } + "custom_tool_call" => { + let original_name = item.get("name").and_then(Value::as_str).unwrap_or(""); + let name = context.chat_name_for_custom_tool(original_name); + let input = item + .get("input") + .cloned() + .unwrap_or(Value::String(String::new())); + (name, wrap_custom_tool_input(&input)) + } + "tool_search_call" => { + let arguments = item + .get("arguments") + .map(|value| { + if let Some(arguments) = value.as_str() { + arguments.to_string() + } else { + value.to_string() + } + }) + .unwrap_or_else(|| "{}".to_string()); + (context.chat_name_for_tool_search(), arguments) + } + _ => return None, + }; + if name.is_empty() { + return None; + } + Some(ToolCall { + id, + call_type: "function".to_string(), + function: FunctionCall { + name, + arguments, + thought_signature: None, + }, + index: None, + thought_signature: None, + }) +} + +pub(super) fn append_history_tool_call( + messages: &mut Vec, + pending_reasoning: &mut Option, + item_reasoning: Option<&str>, + call: ToolCall, +) { + // Codex emits a turn's prose and tool calls as sibling items. Fold the calls into the trailing + // assistant message so Chat/Anthropic upstreams receive one coherent assistant turn. + match messages.last_mut() { + Some(message) if message.role == Role::Assistant => { + if let Some(reasoning) = pending_reasoning.take() { + append_reasoning_content(message, &reasoning); + } + if let Some(reasoning) = item_reasoning { + append_reasoning_content(message, reasoning); + } + message.tool_calls.get_or_insert_with(Vec::new).push(call); + } + _ => { + let mut message = Message::new(Role::Assistant, vec![]); + if let Some(reasoning) = pending_reasoning.take() { + append_reasoning_content(&mut message, &reasoning); + } + if let Some(reasoning) = item_reasoning { + append_reasoning_content(&mut message, reasoning); + } + message.tool_calls = Some(vec![call]); + messages.push(message); + } + } +} + +pub(super) fn response_tool_output_text(item: &Value) -> String { + if item.get("type").and_then(Value::as_str) == Some("tool_search_output") { + return json!({ + "status": item.get("status").cloned().unwrap_or(json!("completed")), + "execution": item.get("execution").cloned().unwrap_or(json!("client")), + "tools": item.get("tools").cloned().unwrap_or_else(|| json!([])), + }) + .to_string(); + } + match item.get("output") { + Some(Value::String(output)) => output.clone(), + Some(output @ Value::Array(_)) => { + let text = parts_text(output); + if text.is_empty() { + output.to_string() + } else { + text + } + } + Some(Value::Object(object)) => object + .get("content") + .map(|content| { + let text = parts_text(content); + if text.is_empty() { + content.to_string() + } else { + text + } + }) + .unwrap_or_else(|| Value::Object(object.clone()).to_string()), + _ => String::new(), + } +} diff --git a/src-tauri/src/protocol/openai_responses/mod.rs b/src-tauri/src/protocol/openai_responses/mod.rs new file mode 100644 index 0000000..e858f72 --- /dev/null +++ b/src-tauri/src/protocol/openai_responses/mod.rs @@ -0,0 +1,65 @@ +// OpenAI Responses (/v1/responses) codec — BOTH halves: +// +// provider-side (gateway → a Responses upstream): +// encode_request: IR → Responses REQUEST body +// decode_response: Responses RESPONSE → IR +// +// client-side (a Responses client, i.e. Codex with wire_api="responses", → gateway): +// decode_request: Responses REQUEST → IR +// encode_response / encode_response_sse: IR → Responses RESPONSE (json / synthesized SSE) +// +// The Responses API uses an item-based `input` array (role messages + function_call / +// function_call_output items), `instructions` for the system prompt, `max_output_tokens`, and a +// `reasoning.effort` knob. Its response is an `output` array of items. Tool definitions are +// FLATTENED at the item level (`{"type":"function","name",...}`), unlike Chat Completions. +// +// The client-side halves are hand-rolled rather than reusing llm-connector's +// responses_request_to_chat_request / chat_response_to_responses_response: the crate's versions +// silently DROP function_call / function_call_output / assistant output_text history items and +// tool_calls in responses, and reject the flattened tool form — all fatal for Codex, whose agent +// loop is tool calls end-to-end. +// +// Codex reads the turn's items ONLY from `response.output_item.done` SSE events (text deltas are +// cosmetic; the stream MUST end with `response.completed` carrying id + usage), so the synthesized +// stream emits the full added → delta → done sequence per item. + +mod decode_request; +mod decode_response; +mod encode_request; +mod encode_response; +mod encode_sse; +mod helpers; +mod history; +mod parts; +mod tool_collect; +mod tool_items; +mod tool_names; +mod tool_registry; +mod tools; +mod validate; +#[cfg(test)] +mod tests_aliases; +#[cfg(test)] +mod tests_collisions; +#[cfg(test)] +mod tests_extended; +#[cfg(test)] +mod tests_lite; +#[cfg(test)] +mod tests_request; +#[cfg(test)] +mod tests_response; +#[cfg(test)] +mod tests_validate; + +pub use decode_request::{decode_request, decode_request_with_context}; +pub use decode_response::decode_response; +pub use encode_request::encode_request; +pub use encode_response::{encode_response, encode_response_with_context}; +pub use encode_sse::{encode_response_sse, encode_response_sse_with_context}; +pub use tools::{CodexToolContext, CodexToolKind}; +// CodexToolSpec is part of this module's public surface (kept resolving at +// crate::protocol::openai_responses::CodexToolSpec) but only referenced internally today. +#[allow(unused_imports)] +pub use tools::CodexToolSpec; +pub(crate) use helpers::{custom_tool_input_from_chat_arguments, response_scoped_call_id}; diff --git a/src-tauri/src/protocol/openai_responses/parts.rs b/src-tauri/src/protocol/openai_responses/parts.rs new file mode 100644 index 0000000..fec6576 --- /dev/null +++ b/src-tauri/src/protocol/openai_responses/parts.rs @@ -0,0 +1,118 @@ +// Reading the content of Responses items: text / image parts, reasoning summaries, and call ids. + +use llm_connector::types::{Message, MessageBlock}; +use serde_json::Value; + +// --------------------------------------------------------------------------- +// client-side half: a Responses client (Codex) in front of the gateway +// --------------------------------------------------------------------------- + +/// Pull text out of a Responses content value (string, or array of typed parts). Accepts the +/// input_text / output_text / text / summary_text part flavors. +pub(super) fn parts_text(content: &Value) -> String { + if let Some(s) = content.as_str() { + return s.to_string(); + } + let arr = match content.as_array() { + Some(a) => a, + None => return String::new(), + }; + let mut out: Vec = vec![]; + for p in arr { + match p.get("type").and_then(|t| t.as_str()) { + Some("input_text") | Some("output_text") | Some("text") | Some("summary_text") => { + if let Some(t) = p.get("text").and_then(|v| v.as_str()) { + out.push(t.to_string()); + } + } + _ => {} + } + } + out.join("\n") +} + +/// input_image parts → IR image blocks. Codex sends `image_url` as a data URI (screenshots / +/// attached images); a plain URL is also accepted per the OpenAI spec. +pub(super) fn parts_images(content: &Value) -> Vec { + let arr = match content.as_array() { + Some(a) => a, + None => return vec![], + }; + let mut out = vec![]; + for p in arr { + if p.get("type").and_then(|t| t.as_str()) != Some("input_image") { + continue; + } + let url = p.get("image_url").and_then(|v| v.as_str()).or_else(|| { + p.get("image_url") + .and_then(|v| v.get("url")) + .and_then(|v| v.as_str()) + }); + let Some(u) = url else { continue }; + if let Some(rest) = u.strip_prefix("data:") { + if let Some((meta, data)) = rest.split_once(";base64,") { + if !data.is_empty() { + out.push(MessageBlock::image_base64( + if meta.is_empty() { "image/png" } else { meta }, + data, + )); + } + continue; + } + } + out.push(MessageBlock::image_url(u)); + } + out +} + +/// Reasoning text carried by a Responses `reasoning` item: the summary parts (what a transcoded +/// stream emits and Codex echoes back), falling back to full `content` parts. +pub(super) fn reasoning_item_text(item: &Value) -> Option { + for key in ["summary", "content"] { + let Some(parts) = item.get(key).and_then(|v| v.as_array()) else { + continue; + }; + let text = parts + .iter() + .filter_map(|p| { + p.get("text") + .and_then(|v| v.as_str()) + .or_else(|| p.as_str()) + }) + .filter(|t| !t.is_empty()) + .collect::>() + .join("\n\n"); + if !text.trim().is_empty() { + return Some(text); + } + } + None +} + +/// Append reasoning text onto a message's `reasoning_content` (the OpenAI-chat wire field). +pub(super) fn append_reasoning_content(message: &mut Message, text: &str) { + let text = text.trim(); + if text.is_empty() { + return; + } + match &mut message.reasoning_content { + // Transcoded Responses output deliberately carries the same reasoning both + // as a sibling `reasoning` item and on each call item, so either surviving + // history representation is sufficient. Do not multiply it when both (or + // several parallel calls) are present. + Some(existing) if existing.trim() == text => {} + Some(existing) if !existing.is_empty() => { + existing.push_str("\n\n"); + existing.push_str(text); + } + slot => *slot = Some(text.to_string()), + } +} + +pub(super) fn response_item_call_id(item: &Value) -> Option<&str> { + item.get("call_id") + .or_else(|| item.get("id")) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) +} diff --git a/src-tauri/src/protocol/openai_responses/tests_aliases.rs b/src-tauri/src/protocol/openai_responses/tests_aliases.rs new file mode 100644 index 0000000..8865ada --- /dev/null +++ b/src-tauri/src/protocol/openai_responses/tests_aliases.rs @@ -0,0 +1,110 @@ +use super::tool_names::is_valid_chat_tool_name; +use super::tools::CHAT_TOOL_NAME_MAX_LEN; +use super::*; +use serde_json::json; + +pub(super) fn assert_valid_chat_tool_alias(alias: &str) { + assert!( + is_valid_chat_tool_name(alias), + "invalid Chat tool alias: {alias:?}" + ); + assert!(alias.is_ascii()); + assert!(alias.len() <= CHAT_TOOL_NAME_MAX_LEN); +} + +#[test] +fn aliases_illegal_tool_names_and_restores_exact_response_identities() { + let req = json!({ + "model": "gpt-5.4", + "input": [ + { "type": "function_call", "call_id": "call_fn", "name": "read file/现在", "arguments": "{}" }, + { "type": "custom_tool_call", "call_id": "call_custom", "name": "apply.patch/β", "input": "raw" }, + { "type": "function_call", "call_id": "call_ns", "namespace": "multi agent/一", + "name": "spawn.agent?", "arguments": "{}" } + ], + "tools": [ + { "type": "function", "name": "read file/现在" }, + { "type": "custom", "name": "apply.patch/β" }, + { "type": "namespace", "name": "multi agent/一", "tools": [ + { "type": "function", "name": "spawn.agent?" } + ] } + ] + }); + let (ir, context) = decode_request_with_context(&req).unwrap(); + let tools = ir.tools.as_ref().unwrap(); + assert_eq!(tools.len(), 3); + for tool in tools { + assert_valid_chat_tool_alias(&tool.function.name); + } + for call in ir + .messages + .iter() + .filter_map(|message| message.tool_calls.as_ref()) + .flatten() + { + assert_valid_chat_tool_alias(&call.function.name); + } + + let function_alias = context.chat_name_for_response_tool("read file/现在", None); + let function_item = + context.response_tool_item("fc_fn", "completed", "call_fn", &function_alias, "{}"); + assert_eq!(function_item["type"], "function_call"); + assert_eq!(function_item["name"], "read file/现在"); + + let custom_alias = context.chat_name_for_custom_tool("apply.patch/β"); + let custom_item = context.response_tool_item( + "ctc_custom", + "completed", + "call_custom", + &custom_alias, + r#"{"input":"raw"}"#, + ); + assert_eq!(custom_item["type"], "custom_tool_call"); + assert_eq!(custom_item["name"], "apply.patch/β"); + assert_eq!(custom_item["input"], "raw"); + + let namespace_alias = + context.chat_name_for_response_tool("spawn.agent?", Some("multi agent/一")); + let namespace_item = + context.response_tool_item("fc_ns", "completed", "call_ns", &namespace_alias, "{}"); + assert_eq!(namespace_item["type"], "function_call"); + assert_eq!(namespace_item["namespace"], "multi agent/一"); + assert_eq!(namespace_item["name"], "spawn.agent?"); +} + +#[test] +fn aliases_long_utf8_names_deterministically_within_64_bytes() { + let first_name = format!("读取工具-{}-甲", "界".repeat(40)); + let second_name = format!("读取工具-{}-乙", "界".repeat(40)); + let req = json!({ + "tools": [ + { "type": "function", "name": first_name }, + { "type": "function", "name": second_name } + ] + }); + let first_context = CodexToolContext::from_request(&req); + let second_context = CodexToolContext::from_request(&req); + let first_alias = first_context.chat_name_for_response_tool(&first_name, None); + let second_alias = first_context.chat_name_for_response_tool(&second_name, None); + assert_valid_chat_tool_alias(&first_alias); + assert_valid_chat_tool_alias(&second_alias); + assert_ne!(first_alias, second_alias); + assert_eq!( + first_alias, + second_context.chat_name_for_response_tool(&first_name, None) + ); + assert_eq!( + second_alias, + second_context.chat_name_for_response_tool(&second_name, None) + ); + + let restored = first_context.response_tool_item( + "fc_long", + "completed", + "call_long", + &first_alias, + "{}", + ); + assert_eq!(restored["name"], first_name); +} + diff --git a/src-tauri/src/protocol/openai_responses/tests_collisions.rs b/src-tauri/src/protocol/openai_responses/tests_collisions.rs new file mode 100644 index 0000000..f55315e --- /dev/null +++ b/src-tauri/src/protocol/openai_responses/tests_collisions.rs @@ -0,0 +1,121 @@ +use super::tests_aliases::assert_valid_chat_tool_alias; +use super::*; +use serde_json::json; +use std::collections::HashSet; + +#[test] +fn keeps_colliding_function_custom_search_and_namespace_identities_distinct() { + let definitions = vec![ + json!({ "type": "function", "name": "same" }), + json!({ "type": "custom", "name": "same" }), + json!({ "type": "function", "name": "tool_search" }), + json!({ "type": "tool_search" }), + json!({ "type": "function", "name": "a__b" }), + json!({ "type": "namespace", "name": "a", "tools": [ + { "type": "function", "name": "b" } + ] }), + ]; + let req = json!({ "tools": definitions }); + let mut reversed_definitions = req["tools"].as_array().unwrap().clone(); + reversed_definitions.reverse(); + let reversed_req = json!({ "tools": reversed_definitions }); + let context = CodexToolContext::from_request(&req); + let reversed_context = CodexToolContext::from_request(&reversed_req); + let specs = vec![ + CodexToolSpec { + kind: CodexToolKind::Function, + name: "same".into(), + namespace: None, + }, + CodexToolSpec { + kind: CodexToolKind::Custom, + name: "same".into(), + namespace: None, + }, + CodexToolSpec { + kind: CodexToolKind::Function, + name: "tool_search".into(), + namespace: None, + }, + CodexToolSpec { + kind: CodexToolKind::ToolSearch, + name: "tool_search".into(), + namespace: None, + }, + CodexToolSpec { + kind: CodexToolKind::Function, + name: "a__b".into(), + namespace: None, + }, + CodexToolSpec { + kind: CodexToolKind::Namespace, + name: "b".into(), + namespace: Some("a".into()), + }, + ]; + + assert_eq!(context.ir_tools().len(), specs.len()); + let aliases = specs + .iter() + .map(|spec| { + let alias = context.chat_name_for_spec(spec); + assert_valid_chat_tool_alias(&alias); + assert_eq!(context.lookup_chat_name(&alias), Some(spec)); + assert_eq!(alias, reversed_context.chat_name_for_spec(spec)); + alias + }) + .collect::>(); + assert_eq!(aliases.len(), specs.len()); + + let function_search_alias = context.chat_name_for_spec(&specs[2]); + let function_search = context.response_tool_item( + "fc_search", + "completed", + "call_fn_search", + &function_search_alias, + "{}", + ); + assert_eq!(function_search["type"], "function_call"); + assert_eq!(function_search["name"], "tool_search"); + + let actual_search_alias = context.chat_name_for_spec(&specs[3]); + let actual_search = context.response_tool_item( + "tsc_search", + "completed", + "call_search", + &actual_search_alias, + r#"{"query":"x"}"#, + ); + assert_eq!(actual_search["type"], "tool_search_call"); +} + +#[test] +fn custom_tool_description_omits_full_definition_and_lark_grammar() { + let req = json!({ + "tools": [{ + "type": "custom", + "name": "apply_patch", + "description": "Apply a patch to the workspace.", + "format": { + "type": "grammar", + "syntax": "lark", + "definition": "start: patch+\npatch: /.+/" + } + }] + }); + let context = CodexToolContext::from_request(&req); + let tool = &context.ir_tools()[0]; + let description = tool.function.description.as_deref().unwrap(); + assert!(description.starts_with( + "Apply a patch to the workspace.\n\nPass the custom tool's raw input unchanged in the `input` string field." + )); + assert!(description.contains("*** Add File: path")); + assert!( + description.contains("*** Begin Patch\n*** Add File: path\n+content\n*** End Patch") + ); + assert!(description.contains("never prefix either boundary marker")); + assert!(!description.contains("Original Responses custom-tool definition")); + assert!(!description.contains("definition")); + assert!(!description.contains("start: patch")); + assert!(!description.contains("lark")); +} diff --git a/src-tauri/src/protocol/openai_responses/tests_extended.rs b/src-tauri/src/protocol/openai_responses/tests_extended.rs new file mode 100644 index 0000000..a16588a --- /dev/null +++ b/src-tauri/src/protocol/openai_responses/tests_extended.rs @@ -0,0 +1,145 @@ +use super::*; +use llm_connector::protocols::adapters::openai::OpenAIProtocol; +use serde_json::{json, Value}; + +fn codex_request_with_extended_tools() -> Value { + json!({ + "model": "gpt-5.4", + "input": [ + { "type": "reasoning", "summary": [{ "type": "summary_text", "text": "use tools" }] }, + { "type": "custom_tool_call", "id": "ctc_1", "call_id": "call_custom", + "name": "apply_patch", "input": "*** Begin Patch\n*** End Patch" }, + { "type": "custom_tool_call_output", "call_id": "call_custom", "output": "Done!" }, + { "type": "function_call", "id": "fc_1", "call_id": "call_spawn", + "namespace": "multi_agent_v1", "name": "spawn_agent", + "arguments": "{\"task_name\":\"audit\"}" }, + { "type": "function_call_output", "call_id": "call_spawn", "output": "spawned" }, + { "type": "tool_search_call", "call_id": "call_search", "status": "completed", + "execution": "client", "arguments": { "query": "browser", "limit": 3 } }, + { "type": "tool_search_output", "call_id": "call_search", "status": "completed", + "execution": "client", "tools": [ + { "type": "custom", "name": "exec", "description": "Run JavaScript" } + ] } + ], + "tools": [ + { "type": "custom", "name": "apply_patch", "description": "Apply a patch", + "format": { "type": "grammar", "syntax": "lark", "definition": "start: /.+/" } }, + { "type": "namespace", "name": "multi_agent_v1", "tools": [ + { "type": "function", "name": "spawn_agent", "description": "Spawn an agent", + "parameters": { "type": "object", "properties": { + "task_name": { "type": "string" } + }, "required": ["task_name"] } } + ] }, + { "type": "tool_search", "execution": "client", + "description": "Search deferred tools from Drive and MCP; always prefer this over MCP listing.", + "parameters": { "type": "object", "properties": { + "query": { "type": "string" }, "limit": { "type": "number" } + }, "required": ["query"], "additionalProperties": false } } + ] + }) +} + +#[test] +fn preserves_custom_namespace_and_tool_search_request_semantics() { + let (ir, context) = + decode_request_with_context(&codex_request_with_extended_tools()).unwrap(); + let tools = ir.tools.as_ref().unwrap(); + let names = tools + .iter() + .map(|tool| tool.function.name.as_str()) + .collect::>(); + assert!(names.contains(&"apply_patch")); + assert!(names.contains(&"multi_agent_v1__spawn_agent")); + assert!(names.contains(&"tool_search")); + assert!(names.contains(&"exec")); + let search = tools + .iter() + .find(|tool| tool.function.name == "tool_search") + .unwrap(); + assert!(search + .function + .description + .as_deref() + .unwrap() + .contains("always prefer this")); + + let calls = ir + .messages + .iter() + .filter_map(|message| message.tool_calls.as_ref()) + .flatten() + .collect::>(); + let custom = calls + .iter() + .find(|call| call.function.name == "apply_patch") + .unwrap(); + assert_eq!( + serde_json::from_str::(&custom.function.arguments).unwrap()["input"], + "*** Begin Patch\n*** End Patch" + ); + assert!(calls + .iter() + .any(|call| call.function.name == "multi_agent_v1__spawn_agent")); + assert!(calls.iter().any(|call| call.function.name == "tool_search")); + assert_eq!( + context + .lookup_chat_name("multi_agent_v1__spawn_agent") + .unwrap() + .kind, + CodexToolKind::Namespace + ); +} + +#[test] +fn restores_extended_tool_types_in_buffered_response_and_sse() { + use llm_connector::core::Protocol; + + let (_, context) = + decode_request_with_context(&codex_request_with_extended_tools()).unwrap(); + let chat = r#"{ + "id":"chatcmpl-tools","object":"chat.completion","created":1,"model":"up", + "choices":[{"index":0,"finish_reason":"tool_calls","message":{ + "role":"assistant","content":null,"reasoning_content":"pick the right tools", + "tool_calls":[ + {"id":"call_custom","type":"function","function":{"name":"apply_patch","arguments":"{\"input\":\"*** Begin Patch\\n*** End Patch\"}"}}, + {"id":"call_spawn","type":"function","function":{"name":"multi_agent_v1__spawn_agent","arguments":"{\"task_name\":\"audit\"}"}}, + {"id":"call_search","type":"function","function":{"name":"tool_search","arguments":"{\"query\":\"browser\",\"limit\":3}"}} + ]}}], + "usage":{"prompt_tokens":9,"completion_tokens":4,"total_tokens":13} + }"#; + let ir = OpenAIProtocol::new("").parse_response(chat).unwrap(); + let response = encode_response_with_context(&ir, "gpt-5.4", &context); + let output = response["output"].as_array().unwrap(); + + let custom = output + .iter() + .find(|item| item["type"] == "custom_tool_call") + .unwrap(); + assert_eq!(custom["name"], "apply_patch"); + assert_eq!(custom["input"], "*** Begin Patch\n*** End Patch"); + assert_eq!(custom["reasoning_content"], "pick the right tools"); + + let namespaced = output + .iter() + .find(|item| item["namespace"] == "multi_agent_v1") + .unwrap(); + assert_eq!(namespaced["type"], "function_call"); + assert_eq!(namespaced["name"], "spawn_agent"); + assert_eq!(namespaced["namespace"], "multi_agent_v1"); + + let search = output + .iter() + .find(|item| item["type"] == "tool_search_call") + .unwrap(); + assert!(search.get("id").is_none()); + assert_eq!(search["arguments"]["query"], "browser"); + assert_eq!(search["arguments"]["limit"], 3); + + let sse = encode_response_sse_with_context(&ir, "gpt-5.4", &context); + assert!(sse.contains("event: response.custom_tool_call_input.delta")); + assert!(sse.contains("event: response.custom_tool_call_input.done")); + assert!(sse.contains(r#""type":"custom_tool_call""#)); + assert!(sse.contains(r#""type":"tool_search_call""#)); + assert!(sse.contains(r#""namespace":"multi_agent_v1""#)); +} + diff --git a/src-tauri/src/protocol/openai_responses/tests_lite.rs b/src-tauri/src/protocol/openai_responses/tests_lite.rs new file mode 100644 index 0000000..4255096 --- /dev/null +++ b/src-tauri/src/protocol/openai_responses/tests_lite.rs @@ -0,0 +1,148 @@ +use super::*; +use llm_connector::core::Protocol; +use llm_connector::protocols::adapters::openai::OpenAIProtocol; +use llm_connector::types::ReasoningEffort; +use serde_json::{json, Value}; + +fn codex_responses_lite_request() -> Value { + json!({ + "model": "gpt-5.6-sol-pro", + "input": [ + { + "type": "additional_tools", + "role": "developer", + "tools": [ + { + "type": "custom", + "name": "exec", + "description": "Run JavaScript that can call nested Codex tools.", + "format": { + "type": "grammar", + "syntax": "lark", + "definition": "start: /[\\s\\S]+/" + } + }, + { + "type": "function", + "name": "wait", + "description": "Wait for a yielded exec cell.", + "parameters": { + "type": "object", + "properties": { "cell_id": { "type": "string" } }, + "required": ["cell_id"], + "additionalProperties": false + } + }, + { + "type": "function", + "name": "request_user_input", + "description": "Ask the user a question.", + "parameters": { + "type": "object", + "properties": { "question": { "type": "string" } }, + "required": ["question"] + } + }, + { + "type": "namespace", + "name": "collaboration", + "tools": [{ + "type": "function", + "name": "spawn_agent", + "description": "Spawn a sub-agent.", + "parameters": { + "type": "object", + "properties": { "task_name": { "type": "string" } }, + "required": ["task_name"] + } + }] + } + ] + }, + { + "type": "message", + "role": "developer", + "content": [{ "type": "input_text", "text": "You are Codex." }] + }, + { + "type": "message", + "role": "user", + "content": [{ "type": "input_text", "text": "Inspect the project." }] + } + ], + "tool_choice": "auto", + "parallel_tool_calls": false, + "reasoning": { "effort": "ultra", "summary": "none", "context": "all_turns" }, + "stream": true + }) +} + +#[test] +fn decodes_responses_lite_additional_tools_and_restores_custom_calls() { + let (ir, context) = + decode_request_with_context(&codex_responses_lite_request()).unwrap(); + + let roles = ir + .messages + .iter() + .map(|message| format!("{:?}", message.role)) + .collect::>(); + assert_eq!(roles, vec!["System", "User"]); + assert_eq!(ir.messages[0].content_as_text(), "You are Codex."); + + let tools = ir.tools.as_ref().unwrap(); + let names = tools + .iter() + .map(|tool| tool.function.name.as_str()) + .collect::>(); + assert_eq!( + names, + vec![ + "exec", + "wait", + "request_user_input", + "collaboration__spawn_agent" + ] + ); + assert_eq!( + context.lookup_chat_name("exec").map(|spec| spec.kind), + Some(CodexToolKind::Custom) + ); + assert_eq!(ir.enable_thinking, Some(true)); + assert_eq!(ir.thinking_budget, Some(32768)); + assert_eq!(ir.reasoning_effort, Some(ReasoningEffort::High)); + + let chat_request = OpenAIProtocol::new("") + .build_chat_request_body(&ir) + .unwrap(); + assert_eq!(chat_request["tools"].as_array().map(Vec::len), Some(4)); + assert_eq!(chat_request["reasoning_effort"], "high"); + + let chat_response = r#"{ + "id":"chatcmpl-lite","object":"chat.completion","created":1,"model":"up", + "choices":[{"index":0,"finish_reason":"tool_calls","message":{ + "role":"assistant","content":null, + "tool_calls":[{"id":"call_exec","type":"function","function":{ + "name":"exec","arguments":"{\"input\":\"const result = await tools.exec_command({cmd: \\\"pwd\\\"});\"}" + }}] + }}], + "usage":{"prompt_tokens":9,"completion_tokens":4,"total_tokens":13} + }"#; + let response_ir = OpenAIProtocol::new("") + .parse_response(chat_response) + .unwrap(); + let response = + encode_response_with_context(&response_ir, "gpt-5.6-sol-pro", &context); + let exec = response["output"] + .as_array() + .unwrap() + .iter() + .find(|item| item["type"] == "custom_tool_call") + .unwrap(); + assert_eq!(exec["name"], "exec"); + assert_eq!( + exec["input"], + "const result = await tools.exec_command({cmd: \"pwd\"});" + ); +} + diff --git a/src-tauri/src/protocol/openai_responses/tests_request.rs b/src-tauri/src/protocol/openai_responses/tests_request.rs new file mode 100644 index 0000000..030b564 --- /dev/null +++ b/src-tauri/src/protocol/openai_responses/tests_request.rs @@ -0,0 +1,154 @@ +use super::*; +use serde_json::{json, Value}; + +#[test] +fn encodes_ir_to_responses_request() { + let anthropic = json!({ + "model": "claude-x", "max_tokens": 500, + "system": "be terse", + "tools": [{ "name": "grep", "description": "search", "input_schema": { "type": "object" } }], + "thinking": { "type": "enabled", "budget_tokens": 4096 }, + "messages": [ + { "role": "user", "content": "find foo" }, + { "role": "assistant", "content": [{ "type": "tool_use", "id": "c1", "name": "grep", "input": { "q": "foo" } }] }, + { "role": "user", "content": [{ "type": "tool_result", "tool_use_id": "c1", "content": "found" }] } + ] + }); + let ir = crate::protocol::anthropic::decode_request(&anthropic).unwrap(); + let body = encode_request(&ir, "gpt-5.5", false); + + assert_eq!(body["model"], "gpt-5.5"); + assert_eq!(body["instructions"], "be terse"); + assert_eq!(body["max_output_tokens"], 500); + assert_eq!(body["reasoning"]["effort"], "medium"); // 4096 → medium + // tools flattened (name at item level, not under "function") + assert_eq!(body["tools"][0]["type"], "function"); + assert_eq!(body["tools"][0]["name"], "grep"); + // input items: user message, function_call, function_call_output + let input = body["input"].as_array().unwrap(); + assert!(input.iter().any(|i| i["type"] == "message" + && i["role"] == "user" + && i["content"][0]["type"] == "input_text" + && i["content"][0]["text"] == "find foo")); + let fc = input.iter().find(|i| i["type"] == "function_call").unwrap(); + assert_eq!(fc["name"], "grep"); + assert_eq!(fc["call_id"], "c1"); + let fco = input + .iter() + .find(|i| i["type"] == "function_call_output") + .unwrap(); + assert_eq!(fco["call_id"], "c1"); + assert_eq!(fco["output"], "found"); +} + +#[test] +fn decodes_responses_reply_to_ir_then_anthropic() { + // A Responses reply with an assistant message + a function_call output item. + let resp = json!({ + "id": "resp_1", "object": "response", "created_at": 1, "model": "gpt-5.5", "status": "completed", + "output": [ + { "type": "message", "role": "assistant", "content": [{ "type": "output_text", "text": "Working on it." }] }, + { "type": "function_call", "call_id": "call_7", "name": "grep", "arguments": "{\"q\":\"foo\"}" } + ], + "usage": { "input_tokens": 15, "output_tokens": 8, "total_tokens": 23 } + }); + let ir = decode_response(&resp.to_string()).unwrap(); + // reuse the Anthropic response encoder → verify the round-trip surfaces text + tool_use + usage + let out = crate::protocol::anthropic::encode_response(&ir, "claude-x"); + assert_eq!(out["stop_reason"], "tool_use"); + assert_eq!(out["usage"]["input_tokens"], 15); + assert_eq!(out["usage"]["output_tokens"], 8); + let content = out["content"].as_array().unwrap(); + assert!(content + .iter() + .any(|b| b["type"] == "text" && b["text"] == "Working on it.")); + let tu = content.iter().find(|b| b["type"] == "tool_use").unwrap(); + assert_eq!(tu["name"], "grep"); + assert_eq!(tu["input"]["q"], "foo"); +} + +// A representative Codex request (wire_api="responses"): instructions, flattened function +// tools, and an agentic history — user message, assistant prose + function_call, its +// function_call_output, and a reasoning item bridged onto the assistant turn. +fn codex_request() -> Value { + json!({ + "model": "z-ai/glm-5.2", + "instructions": "You are Codex.", + "input": [ + { "type": "message", "role": "user", "content": [{ "type": "input_text", "text": "list files" }] }, + { "type": "reasoning", "id": "rs_x", "summary": [{ "type": "summary_text", "text": "thinking…" }] }, + { "type": "message", "role": "assistant", "content": [{ "type": "output_text", "text": "Running ls." }] }, + { "type": "function_call", "call_id": "call_1", "name": "shell", "arguments": "{\"command\":[\"ls\"]}" }, + { "type": "function_call_output", "call_id": "call_1", "output": "a.txt\nb.txt" }, + { "type": "message", "role": "user", "content": [{ "type": "input_text", "text": "read a.txt" }] } + ], + "tools": [ + { "type": "function", "name": "shell", "description": "run a command", "strict": false, + "parameters": { "type": "object", "properties": { "command": { "type": "array" } } } }, + { "type": "web_search" } + ], + "tool_choice": "auto", + "parallel_tool_calls": false, + "reasoning": { "effort": "medium", "summary": "auto" }, + "store": false, + "stream": true + }) +} + +#[test] +fn decodes_codex_responses_request_to_ir() { + let ir = decode_request(&codex_request()).unwrap(); + let roles: Vec<_> = ir + .messages + .iter() + .map(|m| format!("{:?}", m.role)) + .collect(); + // instructions → System; assistant prose + function_call folded into ONE assistant turn; + // function_call_output → Tool; reasoning item bridged onto the assistant turn. + assert_eq!(roles, vec!["System", "User", "Assistant", "Tool", "User"]); + assert_eq!(ir.messages[0].content_as_text(), "You are Codex."); + assert_eq!(ir.messages[1].content_as_text(), "list files"); + assert_eq!(ir.messages[2].content_as_text(), "Running ls."); + assert_eq!( + ir.messages[2].reasoning_content.as_deref(), + Some("thinking…") + ); + let calls = ir.messages[2].tool_calls.as_ref().unwrap(); + assert_eq!(calls[0].id, "call_1"); + assert_eq!(calls[0].function.name, "shell"); + assert!(calls[0].function.arguments.contains("ls")); + assert_eq!(ir.messages[3].tool_call_id.as_deref(), Some("call_1")); + assert_eq!(ir.messages[3].content_as_text(), "a.txt\nb.txt"); + // flattened function tool recognized, non-function web_search dropped + let tools = ir.tools.as_ref().unwrap(); + assert_eq!(tools.len(), 1); + assert_eq!(tools[0].function.name, "shell"); + assert_eq!(ir.stream, Some(true)); + // reasoning.effort medium → thinking budget for an Anthropic upstream + assert_eq!(ir.enable_thinking, Some(true)); + assert_eq!(ir.thinking_budget, Some(4096)); + + // The crate encodes the IR to a real Anthropic Messages body — proves the reused half + // works end-to-end (responses client → anthropic upstream). + use llm_connector::core::Protocol; + use llm_connector::protocols::adapters::anthropic::AnthropicProtocol; + let body = AnthropicProtocol::new("") + .build_chat_request_body(&ir) + .unwrap(); + let msgs = body.get("messages").and_then(|v| v.as_array()).unwrap(); + // assistant turn carries a tool_use block; tool output became a user tool_result turn + assert!(msgs.iter().any(|m| m["role"] == "assistant" + && m["content"] + .as_array() + .unwrap() + .iter() + .any(|b| b["type"] == "tool_use" && b["id"] == "call_1"))); + assert!(msgs.iter().any(|m| m["role"] == "user" + && m["content"] + .as_array() + .unwrap() + .iter() + .any(|b| b["type"] == "tool_result" && b["tool_use_id"] == "call_1"))); + assert_eq!(body["system"], "You are Codex."); +} + diff --git a/src-tauri/src/protocol/openai_responses/tests_response.rs b/src-tauri/src/protocol/openai_responses/tests_response.rs new file mode 100644 index 0000000..026007c --- /dev/null +++ b/src-tauri/src/protocol/openai_responses/tests_response.rs @@ -0,0 +1,156 @@ +use super::*; +use llm_connector::protocols::adapters::openai::OpenAIProtocol; +use serde_json::{json, Value}; + +#[test] +fn encodes_ir_to_responses_response_with_tool_calls() { + // A chat upstream reply with prose + a tool call → the Responses body Codex consumes. + use llm_connector::core::Protocol; + let chat = r#"{ + "id":"chatcmpl-9","object":"chat.completion","created":1,"model":"gpt-4o", + "choices":[{"index":0,"finish_reason":"tool_calls","message":{ + "role":"assistant","content":"Checking.", + "tool_calls":[{"id":"call_9","type":"function", + "function":{"name":"shell","arguments":"{\"command\":[\"ls\"]}"}}]}}], + "usage":{"prompt_tokens":11,"completion_tokens":7,"total_tokens":18} + }"#; + let ir = OpenAIProtocol::new("").parse_response(chat).unwrap(); + let out = encode_response(&ir, "z-ai/glm-5.2"); + assert_eq!(out["object"], "response"); + assert_eq!(out["status"], "completed"); + assert_eq!(out["model"], "z-ai/glm-5.2"); + assert_eq!(out["usage"]["input_tokens"], 11); + assert_eq!(out["usage"]["output_tokens"], 7); + assert_eq!(out["usage"]["total_tokens"], 18); + let output = out["output"].as_array().unwrap(); + let m = output.iter().find(|i| i["type"] == "message").unwrap(); + assert_eq!(m["content"][0]["type"], "output_text"); + assert_eq!(m["content"][0]["text"], "Checking."); + let fc = output + .iter() + .find(|i| i["type"] == "function_call") + .unwrap(); + assert_eq!( + fc["call_id"], + response_scoped_call_id(out["id"].as_str().unwrap(), 0) + ); + assert_eq!(fc["name"], "shell"); + assert_eq!(fc["arguments"], "{\"command\":[\"ls\"]}"); +} + +#[test] +fn synthesized_responses_sse_carries_items_and_completed() { + use llm_connector::core::Protocol; + let chat = r#"{ + "id":"c1","object":"chat.completion","created":1,"model":"up", + "choices":[{"index":0,"finish_reason":"tool_calls","message":{ + "role":"assistant","content":"On it.", + "tool_calls":[{"id":"call_2","type":"function", + "function":{"name":"apply_patch","arguments":"{\"p\":1}"}}]}}], + "usage":{"prompt_tokens":5,"completion_tokens":3,"total_tokens":8} + }"#; + let ir = OpenAIProtocol::new("").parse_response(chat).unwrap(); + let sse = encode_response_sse(&ir, "alias-model"); + // ordered: created → message item events → function_call item events → completed + let created = sse.find("\"type\":\"response.created\"").unwrap(); + let item_done = sse.find("response.output_item.done").unwrap(); + let completed = sse.find("\"type\":\"response.completed\"").unwrap(); + assert!(created < item_done && item_done < completed); + // Codex reads items exclusively from output_item.done: both items must appear there. + assert!(sse.contains(r#""delta":"On it.""#)); + assert!(sse.contains(&format!( + r#""call_id":"{}""#, + response_scoped_call_id("resp_c1", 0) + ))); + assert!(sse.contains(r#""name":"apply_patch""#)); + assert!(sse.contains(r#""arguments":"{\"p\":1}""#)); + // completed carries id + usage (codex errors without them) + assert!(sse.contains(r#""input_tokens":5"#)); + assert!(sse.contains(r#""output_tokens":3"#)); + assert!(sse.contains(r#""id":"resp_c1""#)); +} + +#[test] +fn buffered_duplicate_upstream_call_ids_become_unique_and_response_scoped() { + use llm_connector::core::Protocol; + + let parse = |response_id: &str| { + OpenAIProtocol::new("") + .parse_response( + &json!({ + "id":response_id, + "object":"chat.completion", + "created":1, + "model":"up", + "choices":[{"index":0,"finish_reason":"tool_calls","message":{ + "role":"assistant","content":"", + "tool_calls":[ + {"id":"same","type":"function","function":{"name":"first","arguments":"{}"}}, + {"id":"same","type":"function","function":{"name":"second","arguments":"{}"}} + ] + }}] + }) + .to_string(), + ) + .unwrap() + }; + let first = encode_response(&parse("turn-1"), "alias"); + let second = encode_response(&parse("turn-2"), "alias"); + let call_ids = |response: &Value| { + response["output"] + .as_array() + .unwrap() + .iter() + .filter_map(|item| item.get("call_id").and_then(Value::as_str)) + .map(str::to_string) + .collect::>() + }; + let first_ids = call_ids(&first); + let second_ids = call_ids(&second); + + assert_eq!(first_ids.len(), 2); + assert_ne!(first_ids[0], first_ids[1]); + assert_ne!(first_ids[0], second_ids[0]); + assert_eq!( + first_ids[0], + response_scoped_call_id(first["id"].as_str().unwrap(), 0) + ); + assert_eq!( + first_ids[1], + response_scoped_call_id(first["id"].as_str().unwrap(), 1) + ); +} + +#[test] +fn buffered_truncation_stays_incomplete_across_responses_encoding() { + use llm_connector::core::Protocol; + let chat = r#"{ + "id":"c-length","object":"chat.completion","created":1,"model":"up", + "choices":[{"index":0,"finish_reason":"length","message":{ + "role":"assistant","content":"partial"}}], + "usage":{"prompt_tokens":5,"completion_tokens":3,"total_tokens":8} + }"#; + let ir = OpenAIProtocol::new("").parse_response(chat).unwrap(); + let response = encode_response(&ir, "alias-model"); + assert_eq!(response["status"], "incomplete"); + assert_eq!( + response["incomplete_details"]["reason"], + "max_output_tokens" + ); + + let sse = encode_response_sse(&ir, "alias-model"); + assert!(sse.contains("event: response.incomplete")); + assert!(!sse.contains("event: response.completed")); + + let decoded = decode_response(&response.to_string()).unwrap(); + assert_eq!(decoded.choices[0].finish_reason.as_deref(), Some("length")); + let failed = json!({ + "id":"resp_failed","status":"failed", + "error":{"message":"provider failed"} + }); + assert_eq!( + decode_response(&failed.to_string()).unwrap_err(), + "provider failed" + ); +} + diff --git a/src-tauri/src/protocol/openai_responses/tests_validate.rs b/src-tauri/src/protocol/openai_responses/tests_validate.rs new file mode 100644 index 0000000..34c5b89 --- /dev/null +++ b/src-tauri/src/protocol/openai_responses/tests_validate.rs @@ -0,0 +1,146 @@ +use super::*; +use llm_connector::types::Role; +use serde_json::json; + +#[test] +fn rejects_call_outputs_without_a_preceding_matching_call() { + for input in [ + json!([{ + "type":"function_call_output","call_id":"missing_call","output":"done" + }]), + json!([ + {"type":"custom_tool_call_output","call_id":"late_call","output":"done"}, + {"type":"custom_tool_call","call_id":"late_call","name":"apply_patch","input":"patch"} + ]), + json!([{"type":"tool_search_output","tools":[]}]), + json!([ + {"type":"function_call","call_id":"duplicate_output","name":"shell","arguments":"{}"}, + {"type":"function_call_output","call_id":"duplicate_output","output":"one"}, + {"type":"function_call_output","call_id":"duplicate_output","output":"two"} + ]), + json!([ + {"type":"function_call","call_id":"wrong_kind","name":"shell","arguments":"{}"}, + {"type":"custom_tool_call_output","call_id":"wrong_kind","output":"done"} + ]), + json!([ + {"type":"function_call","call_id":"stale_call","name":"shell","arguments":"{}"}, + {"type":"message","role":"user","content":"start another turn"}, + {"type":"function_call_output","call_id":"stale_call","output":"done"} + ]), + json!([ + {"type":"function_call","call_id":"stale_bare","name":"shell","arguments":"{}"}, + {"role":"user","content":"start another turn"}, + {"type":"function_call_output","call_id":"stale_bare","output":"done"} + ]), + ] { + let error = decode_request(&json!({ "model":"m", "input":input })).unwrap_err(); + assert!( + error.contains("no preceding matching call"), + "unexpected validation error: {error}" + ); + } +} + +#[test] +fn rejects_ambiguous_duplicate_call_ids_and_interleaved_call_groups() { + for input in [ + json!([ + {"type":"function_call","call_id":"same","name":"first","arguments":"{}"}, + {"type":"custom_tool_call","call_id":"same","name":"second","input":"x"} + ]), + json!([ + {"type":"function_call","call_id":"c1","name":"first","arguments":"{}"}, + {"type":"function_call","call_id":"c2","name":"second","arguments":"{}"}, + {"type":"function_call_output","call_id":"c1","output":"one"}, + {"type":"function_call","call_id":"c3","name":"third","arguments":"{}"} + ]), + json!([ + {"type":"function_call","call_id":"reused","name":"first","arguments":"{}"}, + {"type":"function_call_output","call_id":"reused","output":"one"}, + {"role":"user","content":"next turn"}, + {"type":"function_call","call_id":"reused","name":"second","arguments":"{}"} + ]), + ] { + let error = decode_request(&json!({"model":"m","input":input})).unwrap_err(); + assert!( + error.contains("ambiguous"), + "unexpected validation error: {error}" + ); + } +} + +// Thinking chat upstreams reject tool-call history without reasoning, and MiniMax rejects +// `role:system` anywhere but the head — the decoder must bridge reasoning items onto their +// assistant turn and merge all system/developer text into one leading system message. +#[test] +fn bridges_reasoning_and_collapses_system_into_head() { + let req = json!({ + "model": "m", + "instructions": "You are Codex.", + "input": [ + { "type": "message", "role": "user", "content": [{ "type": "input_text", "text": "run ls" }] }, + { "type": "reasoning", "summary": [{ "type": "summary_text", "text": "need to list" }] }, + { "type": "function_call", "call_id": "c1", "name": "shell", "arguments": "{}" }, + { "type": "function_call_output", "call_id": "c1", "output": "a.txt" }, + { "type": "message", "role": "developer", "content": [{ "type": "input_text", "text": "be careful" }] }, + { "type": "message", "role": "user", "content": [{ "type": "input_text", "text": "again" }] } + ] + }); + let ir = decode_request(&req).unwrap(); + let roles: Vec<_> = ir + .messages + .iter() + .map(|m| format!("{:?}", m.role)) + .collect(); + // exactly ONE system message, at the head, carrying instructions + the developer item + assert_eq!(roles, vec!["System", "User", "Assistant", "Tool", "User"]); + assert_eq!( + ir.messages[0].content_as_text(), + "You are Codex.\n\nbe careful" + ); + // the reasoning that produced the tool call rides the tool-call assistant turn + assert_eq!( + ir.messages[2].reasoning_content.as_deref(), + Some("need to list") + ); + assert_eq!(ir.messages[2].tool_calls.as_ref().unwrap()[0].id, "c1"); +} + +#[test] +fn bridges_call_item_reasoning_without_duplicate_parallel_copies() { + let req = json!({ + "model":"m", + "input":[ + {"type":"reasoning","summary":[{"type":"summary_text","text":"inspect both"}]}, + {"type":"function_call","call_id":"c1","name":"first","arguments":"{}", + "reasoning_content":"inspect both"}, + {"type":"function_call","call_id":"c2","name":"second","arguments":"{}", + "reasoning_content":"inspect both"}, + {"type":"function_call_output","call_id":"c1","output":"one"}, + {"type":"function_call_output","call_id":"c2","output":"two"} + ] + }); + + let ir = decode_request(&req).unwrap(); + assert_eq!(ir.messages[0].role, Role::Assistant); + assert_eq!( + ir.messages[0].reasoning_content.as_deref(), + Some("inspect both") + ); + assert_eq!(ir.messages[0].tool_calls.as_ref().unwrap().len(), 2); + + let item_only = json!({ + "model":"m", + "input":[ + {"type":"function_call","call_id":"c3","name":"third","arguments":"{}", + "reasoning_content":"cached reasoning"}, + {"type":"function_call_output","call_id":"c3","output":"three"} + ] + }); + let ir = decode_request(&item_only).unwrap(); + assert_eq!( + ir.messages[0].reasoning_content.as_deref(), + Some("cached reasoning") + ); +} + diff --git a/src-tauri/src/protocol/openai_responses/tool_collect.rs b/src-tauri/src/protocol/openai_responses/tool_collect.rs new file mode 100644 index 0000000..948bc15 --- /dev/null +++ b/src-tauri/src/protocol/openai_responses/tool_collect.rs @@ -0,0 +1,157 @@ +// Walking a Codex Responses request for tool definitions and tool identities that are not in the +// top-level `tools` array (Responses Lite `additional_tools`, `tool_search_output`, history calls). + +use super::tools::{CodexToolContext, CodexToolKind, CodexToolSpec, TOOL_SEARCH_CHAT_NAME}; +use serde_json::Value; + +pub(super) fn collect_additional_tools(value: &Value, context: &mut CodexToolContext) { + match value { + Value::Array(items) => { + for item in items { + collect_additional_tools(item, context); + } + } + Value::Object(object) => { + if object.get("type").and_then(Value::as_str) == Some("additional_tools") { + if let Some(tools) = object.get("tools").and_then(Value::as_array) { + for tool in tools { + context.add_response_tool(tool); + } + } + } + for child in object.values() { + collect_additional_tools(child, context); + } + } + _ => {} + } +} + +pub(super) fn collect_tool_search_output_tools(value: &Value, context: &mut CodexToolContext) { + match value { + Value::Array(items) => { + for item in items { + collect_tool_search_output_tools(item, context); + } + } + Value::Object(object) => { + if object.get("type").and_then(Value::as_str) == Some("tool_search_output") { + if let Some(tools) = object.get("tools").and_then(Value::as_array) { + for tool in tools { + context.add_response_tool(tool); + } + } + } + for child in object.values() { + collect_tool_search_output_tools(child, context); + } + } + _ => {} + } +} + +pub(super) fn collect_response_tool_call_identities(value: &Value, context: &mut CodexToolContext) { + match value { + Value::Array(items) => { + for item in items { + collect_response_tool_call_identities(item, context); + } + } + Value::Object(object) => { + let spec = match object.get("type").and_then(Value::as_str) { + Some("function_call") => object + .get("name") + .and_then(Value::as_str) + .filter(|name| !name.trim().is_empty()) + .map(|name| { + let namespace = object + .get("namespace") + .and_then(Value::as_str) + .filter(|namespace| !namespace.is_empty()); + CodexToolSpec { + kind: if namespace.is_some() { + CodexToolKind::Namespace + } else { + CodexToolKind::Function + }, + name: name.to_string(), + namespace: namespace.map(ToString::to_string), + } + }), + Some("custom_tool_call") => object + .get("name") + .and_then(Value::as_str) + .filter(|name| !name.trim().is_empty()) + .map(|name| CodexToolSpec { + kind: CodexToolKind::Custom, + name: name.to_string(), + namespace: None, + }), + Some("tool_search_call") => Some(CodexToolSpec { + kind: CodexToolKind::ToolSearch, + name: TOOL_SEARCH_CHAT_NAME.to_string(), + namespace: None, + }), + _ => None, + }; + if let Some(spec) = spec { + context.register_tool_identity(spec); + } + for child in object.values() { + collect_response_tool_call_identities(child, context); + } + } + _ => {} + } +} + +pub(super) fn collect_tool_choice_identity(tool_choice: Option<&Value>, context: &mut CodexToolContext) { + let Some(tool_choice) = tool_choice.filter(|value| value.is_object()) else { + return; + }; + let spec = match tool_choice.get("type").and_then(Value::as_str) { + Some("function") => tool_choice + .get("name") + .and_then(Value::as_str) + .or_else(|| { + tool_choice + .get("function") + .and_then(|function| function.get("name")) + .and_then(Value::as_str) + }) + .filter(|name| !name.trim().is_empty()) + .map(|name| { + let namespace = tool_choice + .get("namespace") + .and_then(Value::as_str) + .filter(|namespace| !namespace.is_empty()); + CodexToolSpec { + kind: if namespace.is_some() { + CodexToolKind::Namespace + } else { + CodexToolKind::Function + }, + name: name.to_string(), + namespace: namespace.map(ToString::to_string), + } + }), + Some("custom") => tool_choice + .get("name") + .and_then(Value::as_str) + .filter(|name| !name.trim().is_empty()) + .map(|name| CodexToolSpec { + kind: CodexToolKind::Custom, + name: name.to_string(), + namespace: None, + }), + Some("tool_search") => Some(CodexToolSpec { + kind: CodexToolKind::ToolSearch, + name: TOOL_SEARCH_CHAT_NAME.to_string(), + namespace: None, + }), + _ => None, + }; + if let Some(spec) = spec { + context.register_tool_identity(spec); + } +} diff --git a/src-tauri/src/protocol/openai_responses/tool_items.rs b/src-tauri/src/protocol/openai_responses/tool_items.rs new file mode 100644 index 0000000..51da8e3 --- /dev/null +++ b/src-tauri/src/protocol/openai_responses/tool_items.rs @@ -0,0 +1,93 @@ +// Rebuilding Responses output items from a chat-shaped tool call: the item id prefix and the +// exact `function_call` / `custom_tool_call` / `tool_search_call` shape Codex expects back. + +use super::helpers::{custom_tool_input_from_chat_arguments, parse_tool_arguments_object}; +use super::tools::{CodexToolContext, CodexToolKind}; +use serde_json::{json, Value}; + +impl CodexToolContext { + pub(crate) fn response_item_id( + &self, + chat_name: &str, + response_id: &str, + index: usize, + ) -> String { + let prefix = match self.kind_for_chat_name(chat_name) { + CodexToolKind::Custom => "ctc", + CodexToolKind::ToolSearch => "tsc", + CodexToolKind::Function | CodexToolKind::Namespace => "fc", + }; + format!( + "{}_{}_{}", + prefix, + response_id.trim_start_matches("resp_"), + index + ) + } + + pub(crate) fn response_tool_item( + &self, + item_id: &str, + status: &str, + call_id: &str, + chat_name: &str, + arguments: &str, + ) -> Value { + self.response_tool_item_with_reasoning(item_id, status, call_id, chat_name, arguments, None) + } + + pub(crate) fn response_tool_item_with_reasoning( + &self, + item_id: &str, + status: &str, + call_id: &str, + chat_name: &str, + arguments: &str, + reasoning: Option<&str>, + ) -> Value { + let mut item = match self.lookup_chat_name(chat_name) { + Some(spec) if spec.kind == CodexToolKind::Custom => json!({ + "type": "custom_tool_call", + "id": item_id, + "status": status, + "call_id": call_id, + "name": spec.name, + "input": custom_tool_input_from_chat_arguments(arguments), + }), + Some(spec) if spec.kind == CodexToolKind::ToolSearch => json!({ + "type": "tool_search_call", + "status": status, + "call_id": call_id, + "execution": "client", + "arguments": parse_tool_arguments_object(arguments), + }), + Some(spec) => { + let mut item = json!({ + "type": "function_call", + "id": item_id, + "status": status, + "call_id": call_id, + "name": spec.name, + "arguments": if arguments.is_empty() { "{}" } else { arguments }, + }); + if let Some(namespace) = spec.namespace.as_deref().filter(|value| !value.is_empty()) + { + item["namespace"] = json!(namespace); + } + item + } + None => json!({ + "type": "function_call", + "id": item_id, + "status": status, + "call_id": call_id, + "name": chat_name, + "arguments": if arguments.is_empty() { "{}" } else { arguments }, + }), + }; + if let Some(reasoning) = reasoning.map(str::trim).filter(|value| !value.is_empty()) { + item["reasoning_content"] = json!(reasoning); + } + item + } +} diff --git a/src-tauri/src/protocol/openai_responses/tool_names.rs b/src-tauri/src/protocol/openai_responses/tool_names.rs new file mode 100644 index 0000000..26f7889 --- /dev/null +++ b/src-tauri/src/protocol/openai_responses/tool_names.rs @@ -0,0 +1,140 @@ +// Chat tool-name allocation: keep the Responses identity recoverable while satisfying the +// OpenAI Chat name grammar (<=64 chars, alphanumeric/_/-), aliasing collisions deterministically. + +use super::tools::{ + CodexToolContext, CodexToolKind, CodexToolSpec, CHAT_TOOL_NAME_HASH_LEN, + CHAT_TOOL_NAME_MAX_LEN, +}; +use sha1::{Digest, Sha1}; + +impl CodexToolContext { + pub(super) fn reserve_chat_name(&mut self, spec: &CodexToolSpec) -> String { + let preferred = preferred_chat_tool_name(spec); + let chat_name = if is_valid_chat_tool_name(&preferred) + && !self.colliding_preferred_names.contains(&preferred) + { + if let Some(existing_spec) = self.chat_name_to_spec.get(&preferred).cloned() { + self.colliding_preferred_names.insert(preferred.clone()); + if preferred_chat_tool_name(&existing_spec) == preferred { + self.move_identity_to_hashed_alias(&existing_spec, &preferred); + } + self.allocate_hashed_chat_name(spec) + } else { + preferred + } + } else { + self.allocate_hashed_chat_name(spec) + }; + self.seen_chat_names.insert(chat_name.clone()); + self.chat_name_to_spec + .insert(chat_name.clone(), spec.clone()); + self.spec_to_chat_name + .insert(spec.clone(), chat_name.clone()); + chat_name + } + + fn move_identity_to_hashed_alias(&mut self, spec: &CodexToolSpec, old_name: &str) { + self.seen_chat_names.remove(old_name); + self.chat_name_to_spec.remove(old_name); + let new_name = self.allocate_hashed_chat_name(spec); + self.seen_chat_names.insert(new_name.clone()); + self.chat_name_to_spec + .insert(new_name.clone(), spec.clone()); + self.spec_to_chat_name + .insert(spec.clone(), new_name.clone()); + if let Some(tool) = self + .ir_tools + .iter_mut() + .find(|tool| tool.function.name == old_name) + { + tool.function.name = new_name; + } + } + + pub(super) fn allocate_chat_name(&self, spec: &CodexToolSpec) -> String { + let preferred = preferred_chat_tool_name(spec); + if is_valid_chat_tool_name(&preferred) + && !self.colliding_preferred_names.contains(&preferred) + && !self.seen_chat_names.contains(&preferred) + { + return preferred; + } + + self.allocate_hashed_chat_name(spec) + } + + fn allocate_hashed_chat_name(&self, spec: &CodexToolSpec) -> String { + let preferred = preferred_chat_tool_name(spec); + let digest = tool_identity_digest(spec); + for attempt in 0_u64.. { + let candidate = hashed_chat_tool_name(&preferred, &digest, attempt); + if !self.seen_chat_names.contains(&candidate) { + return candidate; + } + } + unreachable!("the finite request cannot exhaust all valid Chat tool aliases") + } +} + +fn preferred_chat_tool_name(spec: &CodexToolSpec) -> String { + match spec.namespace.as_deref() { + Some(namespace) if !namespace.is_empty() => format!("{namespace}__{}", spec.name), + _ => spec.name.clone(), + } +} + +pub(super) fn is_valid_chat_tool_name(name: &str) -> bool { + !name.is_empty() + && name.len() <= CHAT_TOOL_NAME_MAX_LEN + && name + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-')) +} + +fn sanitized_chat_tool_name(name: &str) -> String { + let mut sanitized = String::with_capacity(name.len().min(CHAT_TOOL_NAME_MAX_LEN)); + for ch in name.chars() { + if ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-') { + sanitized.push(ch); + } else { + sanitized.push('_'); + } + } + if sanitized.is_empty() { + sanitized.push_str("tool"); + } + sanitized +} + +fn tool_identity_digest(spec: &CodexToolSpec) -> String { + let mut digest = Sha1::new(); + digest.update([match spec.kind { + CodexToolKind::Function => 0, + CodexToolKind::Namespace => 1, + CodexToolKind::Custom => 2, + CodexToolKind::ToolSearch => 3, + }]); + match spec.namespace.as_deref() { + Some(namespace) => { + digest.update([1]); + digest.update((namespace.len() as u64).to_be_bytes()); + digest.update(namespace.as_bytes()); + } + None => digest.update([0]), + } + digest.update((spec.name.len() as u64).to_be_bytes()); + digest.update(spec.name.as_bytes()); + format!("{:x}", digest.finalize()) +} + +fn hashed_chat_tool_name(preferred: &str, digest: &str, attempt: u64) -> String { + let suffix = if attempt == 0 { + format!("__{}", &digest[..CHAT_TOOL_NAME_HASH_LEN]) + } else { + format!("__{}_{attempt}", &digest[..CHAT_TOOL_NAME_HASH_LEN]) + }; + let prefix_len = CHAT_TOOL_NAME_MAX_LEN.saturating_sub(suffix.len()); + let mut prefix = sanitized_chat_tool_name(preferred); + prefix.truncate(prefix_len); + format!("{prefix}{suffix}") +} diff --git a/src-tauri/src/protocol/openai_responses/tool_registry.rs b/src-tauri/src/protocol/openai_responses/tool_registry.rs new file mode 100644 index 0000000..201d1a5 --- /dev/null +++ b/src-tauri/src/protocol/openai_responses/tool_registry.rs @@ -0,0 +1,185 @@ +// Registering Responses tool definitions (function / custom / tool_search / namespace) as the +// flat chat functions an OpenAI Chat upstream accepts. + +use super::tools::{ + CodexToolContext, CodexToolKind, CodexToolSpec, APPLY_PATCH_CHAT_INSTRUCTION, + CUSTOM_TOOL_INPUT_FIELD, CUSTOM_TOOL_RAW_INPUT_INSTRUCTION, TOOL_SEARCH_CHAT_NAME, +}; +use llm_connector::types::Tool; +use serde_json::{json, Value}; + +impl CodexToolContext { + pub(super) fn add_response_tool(&mut self, tool: &Value) { + match tool { + Value::String(name) => self.add_custom_tool(&json!({ + "type": "custom", + "name": name, + })), + Value::Object(_) => match tool.get("type").and_then(Value::as_str) { + Some("function") | None => self.add_function_tool(tool, None), + Some("custom") => self.add_custom_tool(tool), + Some("tool_search") => self.add_tool_search_tool(tool), + Some("namespace") => self.add_namespace_tool(tool), + _ => {} + }, + _ => {} + } + } + + fn add_function_tool(&mut self, tool: &Value, namespace: Option<&str>) { + let function = tool + .get("function") + .filter(|value| value.is_object()) + .unwrap_or(tool); + let Some(name) = function.get("name").and_then(Value::as_str) else { + return; + }; + if name.trim().is_empty() { + return; + } + let description = function + .get("description") + .and_then(Value::as_str) + .map(ToString::to_string); + let parameters = normalize_function_parameters(function.get("parameters")); + let spec = CodexToolSpec { + kind: if namespace.is_some() { + CodexToolKind::Namespace + } else { + CodexToolKind::Function + }, + name: name.to_string(), + namespace: namespace.map(ToString::to_string), + }; + self.add_chat_tool(spec, description, parameters); + } + + fn add_custom_tool(&mut self, tool: &Value) { + let Some(name) = tool.get("name").and_then(Value::as_str) else { + return; + }; + if name.trim().is_empty() { + return; + } + let mut description = tool + .get("description") + .and_then(Value::as_str) + .map(|description| format!("{description}\n\n{CUSTOM_TOOL_RAW_INPUT_INSTRUCTION}")) + .unwrap_or_else(|| CUSTOM_TOOL_RAW_INPUT_INSTRUCTION.to_string()); + if name == "apply_patch" { + description.push_str("\n\n"); + description.push_str(APPLY_PATCH_CHAT_INSTRUCTION); + } + let parameters = json!({ + "type": "object", + "properties": { + "input": { + "type": "string", + "description": "Raw string input for the original custom tool. Preserve formatting exactly." + } + }, + "required": [CUSTOM_TOOL_INPUT_FIELD], + "additionalProperties": false, + }); + self.add_chat_tool( + CodexToolSpec { + kind: CodexToolKind::Custom, + name: name.to_string(), + namespace: None, + }, + Some(description), + parameters, + ); + } + + fn add_tool_search_tool(&mut self, tool: &Value) { + let description = tool + .get("description") + .and_then(Value::as_str) + .map(ToString::to_string) + .unwrap_or_else(|| { + "Search and load Codex tools, plugins, connectors, and MCP namespaces for the current task." + .to_string() + }); + let parameters = if tool.get("parameters").is_some_and(Value::is_object) { + normalize_function_parameters(tool.get("parameters")) + } else { + json!({ + "type": "object", + "properties": { + "query": { "type": "string" }, + "limit": { "type": "integer" } + }, + "required": ["query"], + "additionalProperties": false, + }) + }; + self.add_chat_tool( + CodexToolSpec { + kind: CodexToolKind::ToolSearch, + name: TOOL_SEARCH_CHAT_NAME.to_string(), + namespace: None, + }, + Some(description), + parameters, + ); + } + + fn add_namespace_tool(&mut self, tool: &Value) { + let Some(namespace) = tool.get("name").and_then(Value::as_str) else { + return; + }; + if namespace.trim().is_empty() { + return; + } + let Some(children) = tool + .get("tools") + .or_else(|| tool.get("children")) + .and_then(Value::as_array) + else { + return; + }; + for child in children { + if child.get("type").and_then(Value::as_str) == Some("function") { + self.add_function_tool(child, Some(namespace)); + } + } + } + + fn add_chat_tool( + &mut self, + spec: CodexToolSpec, + description: Option, + parameters: Value, + ) { + if self.spec_to_chat_name.contains_key(&spec) { + return; + } + let chat_name = self.reserve_chat_name(&spec); + self.ir_tools + .push(Tool::function(chat_name.clone(), description, parameters)); + } + + pub(super) fn register_tool_identity(&mut self, spec: CodexToolSpec) { + if self.spec_to_chat_name.contains_key(&spec) { + return; + } + self.reserve_chat_name(&spec); + } +} + +fn normalize_function_parameters(parameters: Option<&Value>) -> Value { + let mut parameters = parameters + .filter(|value| value.is_object()) + .cloned() + .unwrap_or_else(|| json!({ "type": "object", "properties": {} })); + if let Some(object) = parameters.as_object_mut() { + if object.get("type").and_then(Value::as_str) != Some("object") { + object.insert("type".to_string(), json!("object")); + } + object + .entry("properties".to_string()) + .or_insert_with(|| json!({})); + } + parameters +} diff --git a/src-tauri/src/protocol/openai_responses/tools.rs b/src-tauri/src/protocol/openai_responses/tools.rs new file mode 100644 index 0000000..f36091d --- /dev/null +++ b/src-tauri/src/protocol/openai_responses/tools.rs @@ -0,0 +1,125 @@ +// Codex tool metadata shared by the Responses request decoder and the Responses response +// encoders: the tool identity types and the request-scoped registry built from a Codex request. + +use super::tool_collect::{ + collect_additional_tools, collect_response_tool_call_identities, collect_tool_choice_identity, + collect_tool_search_output_tools, +}; +use llm_connector::types::Tool; +use serde_json::Value; +use std::collections::{HashMap, HashSet}; + +pub(super) const CUSTOM_TOOL_INPUT_FIELD: &str = "input"; +pub(super) const CUSTOM_TOOL_RAW_INPUT_INSTRUCTION: &str = + "Pass the custom tool's raw input unchanged in the `input` string field."; +pub(super) const APPLY_PATCH_CHAT_INSTRUCTION: &str = "For apply_patch, the first line must be `*** Begin Patch` and the final line must be an unprefixed `*** End Patch`. Exact Add File skeleton:\n*** Begin Patch\n*** Add File: path\n+content\n*** End Patch\nPrefix every added file-content line with `+`, but never prefix either boundary marker. For updates, use `*** Update File: path` with an `@@` context hunk and ` `, `-`, or `+` line prefixes; for deletion, use `*** Delete File: path`."; +pub(super) const TOOL_SEARCH_CHAT_NAME: &str = "tool_search"; +pub(super) const CHAT_TOOL_NAME_MAX_LEN: usize = 64; +pub(super) const CHAT_TOOL_NAME_HASH_LEN: usize = 12; + +/// The Responses tool shape that a chat-compatible upstream is standing in for. +/// +/// OpenAI Chat only has flat JSON-schema functions, while current Codex requests also carry +/// freeform custom tools, tool search, and namespace tools. The translation layer flattens all of +/// them to chat functions, then uses this metadata to restore the exact Responses item type on the +/// way back to Codex. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum CodexToolKind { + Function, + Namespace, + Custom, + ToolSearch, +} + +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct CodexToolSpec { + pub kind: CodexToolKind, + pub name: String, + pub namespace: Option, +} + +/// Request-scoped tool metadata used by both buffered and streaming Responses encoders. +/// +/// Build this from the original Codex request before decoding it to the connector IR. Loaded tools +/// embedded in `tool_search_output` history are included, so subsequent calls can be translated +/// even when their definitions are not repeated in the top-level `tools` array. +#[derive(Clone, Debug, Default)] +pub struct CodexToolContext { + pub(super) ir_tools: Vec, + pub(super) seen_chat_names: HashSet, + pub(super) colliding_preferred_names: HashSet, + pub(super) chat_name_to_spec: HashMap, + pub(super) spec_to_chat_name: HashMap, +} + +impl CodexToolContext { + pub fn from_request(req: &Value) -> Self { + let mut context = Self::default(); + if let Some(tools) = req.get("tools").and_then(Value::as_array) { + for tool in tools { + context.add_response_tool(tool); + } + } + if let Some(input) = req.get("input") { + // Codex Responses Lite (used by gpt-5.6-sol*) moves the complete tool registry out + // of the top-level `tools` field and into an `additional_tools` developer item. Treat + // those definitions exactly like top-level tools; the item itself is request metadata, + // not a chat message. + collect_additional_tools(input, &mut context); + collect_tool_search_output_tools(input, &mut context); + collect_response_tool_call_identities(input, &mut context); + } + collect_tool_choice_identity(req.get("tool_choice"), &mut context); + context + } + + pub fn ir_tools(&self) -> Vec { + self.ir_tools.clone() + } + + pub fn lookup_chat_name(&self, chat_name: &str) -> Option<&CodexToolSpec> { + self.chat_name_to_spec.get(chat_name) + } + + pub fn kind_for_chat_name(&self, chat_name: &str) -> CodexToolKind { + self.lookup_chat_name(chat_name) + .map(|spec| spec.kind) + .unwrap_or(CodexToolKind::Function) + } + + pub fn chat_name_for_response_tool(&self, name: &str, namespace: Option<&str>) -> String { + let namespace = namespace.filter(|value| !value.is_empty()); + self.chat_name_for_spec(&CodexToolSpec { + kind: if namespace.is_some() { + CodexToolKind::Namespace + } else { + CodexToolKind::Function + }, + name: name.to_string(), + namespace: namespace.map(ToString::to_string), + }) + } + + pub(super) fn chat_name_for_custom_tool(&self, name: &str) -> String { + self.chat_name_for_spec(&CodexToolSpec { + kind: CodexToolKind::Custom, + name: name.to_string(), + namespace: None, + }) + } + + pub(super) fn chat_name_for_tool_search(&self) -> String { + self.chat_name_for_spec(&CodexToolSpec { + kind: CodexToolKind::ToolSearch, + name: TOOL_SEARCH_CHAT_NAME.to_string(), + namespace: None, + }) + } + + pub(super) fn chat_name_for_spec(&self, spec: &CodexToolSpec) -> String { + self.spec_to_chat_name + .get(spec) + .cloned() + .unwrap_or_else(|| self.allocate_chat_name(spec)) + } +} diff --git a/src-tauri/src/protocol/openai_responses/validate.rs b/src-tauri/src/protocol/openai_responses/validate.rs new file mode 100644 index 0000000..e353a98 --- /dev/null +++ b/src-tauri/src/protocol/openai_responses/validate.rs @@ -0,0 +1,135 @@ +// Structural validation of a Responses request's call/output pairing, before any translation. + +use super::parts::response_item_call_id; +use serde_json::Value; +use std::collections::{HashMap, HashSet}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum ResponseCallKind { + Function, + Custom, + ToolSearch, +} + +impl ResponseCallKind { + fn call_item(item_type: &str) -> Option { + match item_type { + "function_call" => Some(Self::Function), + "custom_tool_call" => Some(Self::Custom), + "tool_search_call" => Some(Self::ToolSearch), + _ => None, + } + } + + fn output_item(item_type: &str) -> Option { + match item_type { + "function_call_output" => Some(Self::Function), + "custom_tool_call_output" => Some(Self::Custom), + "tool_search_output" => Some(Self::ToolSearch), + _ => None, + } + } + + fn label(self) -> &'static str { + match self { + Self::Function => "function", + Self::Custom => "custom tool", + Self::ToolSearch => "tool search", + } + } +} + +pub(super) fn validate_call_output_pairs(req: &Value) -> Result<(), String> { + let items = match req.get("input") { + Some(Value::Array(items)) => items.iter().collect::>(), + Some(Value::Object(_)) => req.get("input").into_iter().collect::>(), + _ => return Ok(()), + }; + let mut calls = HashMap::::new(); + let mut seen_call_ids = HashSet::new(); + let mut unresolved = Vec::new(); + let mut consumed_in_group = false; + for item in items { + let item_type = item.get("type").and_then(Value::as_str).unwrap_or(""); + if item + .get("role") + .and_then(Value::as_str) + .is_some_and(|role| matches!(role, "user" | "system" | "developer")) + { + // A new client-authored turn closes the window in which an older call can be + // satisfied. Outputs after this point are stale/out of order. + calls.clear(); + consumed_in_group = false; + continue; + } + + if let Some(kind) = ResponseCallKind::call_item(item_type) { + let Some(call_id) = response_item_call_id(item) else { + return Err("Responses call item is missing call_id".to_string()); + }; + if consumed_in_group && !calls.is_empty() { + return Err(format!( + "Responses call order is ambiguous: new call {call_id} appeared before every preceding call produced an output" + )); + } + if calls.is_empty() { + consumed_in_group = false; + } + if !seen_call_ids.insert(call_id.to_string()) + || calls.insert(call_id.to_string(), kind).is_some() + { + return Err(format!( + "Responses call id is ambiguous because it appears more than once before output: {call_id}" + )); + } + continue; + } + + if let Some(output_kind) = ResponseCallKind::output_item(item_type) { + match response_item_call_id(item) { + Some(call_id) => match calls.remove(call_id) { + Some(call_kind) if call_kind == output_kind => { + consumed_in_group = true; + } + Some(call_kind) => unresolved.push(format!( + "{call_id} ({} output cannot satisfy {} call)", + output_kind.label(), + call_kind.label() + )), + None => { + if !unresolved.iter().any(|value| value == call_id) { + unresolved.push(call_id.to_string()); + } + } + }, + None => unresolved.push("".to_string()), + } + if !unresolved.is_empty() { + // Keep collecting only adjacent invalid outputs so the client gets useful ids, + // but never let a later call retroactively legitimize an earlier output. + consumed_in_group = true; + } + continue; + } + + match item_type { + // Reasoning and assistant output items can neighbor the same model turn and do not + // make otherwise ordered call/output pairs stale. + "reasoning" | "message" | "" => {} + _ => { + if item.get("role").is_some() { + calls.clear(); + consumed_in_group = false; + } + } + } + } + if unresolved.is_empty() { + Ok(()) + } else { + Err(format!( + "Responses call output has no preceding matching call: {}", + unresolved.join(", ") + )) + } +} diff --git a/src-tauri/src/protocol/signatures.rs b/src-tauri/src/protocol/signatures.rs new file mode 100644 index 0000000..43eca2d --- /dev/null +++ b/src-tauri/src/protocol/signatures.rs @@ -0,0 +1,112 @@ +// Response-id minting plus Gemini/OpenAI "thought signature" plumbing: providers round-trip an +// opaque reasoning token on tool calls, and it must survive translation in both directions or the +// upstream rejects the follow-up turn. + +use llm_connector::types::ToolCall; +use serde_json::{json, Value}; + +/// Unique id for a synthesized response ("msg_ccbud__"). Clients persist these ids into +/// their history, and usage analytics de-dupes assistant messages BY id — a constant fallback id +/// would collapse every translated turn into a single counted request. +pub fn uid(prefix: &str) -> String { + use std::sync::atomic::{AtomicU64, Ordering}; + static N: AtomicU64 = AtomicU64::new(0); + let ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis()) + .unwrap_or(0); + format!("{}_{}_{}", prefix, ms, N.fetch_add(1, Ordering::Relaxed)) +} + +/// Extract Gemini's opaque thought signature from its OpenAI-compatible wire location, or from +/// an internal/native spelling encountered while translating. The canonical OpenAI compatibility +/// shape is `extra_content.google.thought_signature`. +pub(crate) fn json_thought_signature(value: &Value) -> Option { + value + .pointer("/extra_content/google/thought_signature") + .and_then(Value::as_str) + .filter(|s| !s.is_empty()) + .or_else(|| value.get("thought_signature").and_then(Value::as_str).filter(|s| !s.is_empty())) + .or_else(|| value.pointer("/function/thought_signature").and_then(Value::as_str).filter(|s| !s.is_empty())) + .map(str::to_string) +} + +/// Read the signature from the llm-connector IR. The crate supports both placements for native +/// Gemini, so accept either while keeping a single canonical wire representation at the edge. +pub(crate) fn tool_call_thought_signature(call: &ToolCall) -> Option { + call.thought_signature + .as_deref() + .filter(|s| !s.is_empty()) + .or_else(|| call.function.thought_signature.as_deref().filter(|s| !s.is_empty())) + .map(str::to_string) +} + +fn strip_internal_thought_signature(call: &mut Value) { + let Some(call_obj) = call.as_object_mut() else { return }; + call_obj.remove("thought_signature"); + if let Some(function) = call_obj.get_mut("function").and_then(Value::as_object_mut) { + function.remove("thought_signature"); + } +} + +fn set_google_thought_signature(call: &mut Value, signature: &str) { + strip_internal_thought_signature(call); + call["extra_content"]["google"]["thought_signature"] = json!(signature); +} + +/// llm-connector serializes its internal signature fields literally. Rewrite them into Gemini's +/// OpenAI-compatible `extra_content.google.thought_signature` before forwarding. +pub(super) fn normalize_openai_request_thought_signatures(body: &mut Value) { + let Some(messages) = body.get_mut("messages").and_then(Value::as_array_mut) else { return }; + for message in messages { + let Some(calls) = message.get_mut("tool_calls").and_then(Value::as_array_mut) else { continue }; + for call in calls { + if let Some(signature) = json_thought_signature(call) { + set_google_thought_signature(call, &signature); + } + } + } +} + +/// Thinking chat upstreams (Kimi/Moonshot, DeepSeek, …) require every assistant message that +/// carries `tool_calls` to also carry a non-empty `reasoning_content`, and answer +/// "reasoning_content is missing in assistant tool call message" otherwise. Real reasoning is +/// bridged from the client history where available (thinking blocks, Responses reasoning items); +/// this is the last-resort placeholder for turns whose reasoning didn't survive the wire. +/// Providers without the requirement ignore the extra field. +pub(super) fn ensure_chat_tool_call_reasoning_content(body: &mut Value) { + let Some(messages) = body.get_mut("messages").and_then(Value::as_array_mut) else { return }; + for message in messages { + let has_tool_calls = message.get("role").and_then(Value::as_str) == Some("assistant") + && message.get("tool_calls").and_then(Value::as_array).is_some_and(|c| !c.is_empty()); + if !has_tool_calls { + continue; + } + let missing = message + .get("reasoning_content") + .and_then(Value::as_str) + .map_or(true, |s| s.trim().is_empty()); + if missing { + message["reasoning_content"] = json!("tool call"); + } + } +} + +/// Gemini/OpenRouter/Cloudflare return provider metadata in `extra_content`, which serde ignores +/// when llm-connector parses a standard OpenAI ToolCall. Copy the opaque signature into the +/// crate's internal field before parsing; the original response remains otherwise unchanged. +pub(super) fn normalize_openai_response_thought_signatures(body: &mut Value) { + let Some(choices) = body.get_mut("choices").and_then(Value::as_array_mut) else { return }; + for choice in choices { + let Some(calls) = choice + .get_mut("message") + .and_then(|message| message.get_mut("tool_calls")) + .and_then(Value::as_array_mut) + else { continue }; + for call in calls { + if let Some(signature) = json_thought_signature(call) { + call["thought_signature"] = json!(signature); + } + } + } +} diff --git a/src-tauri/src/protocol/stream.rs b/src-tauri/src/protocol/stream.rs deleted file mode 100644 index 4c2e52f..0000000 --- a/src-tauri/src/protocol/stream.rs +++ /dev/null @@ -1,2447 +0,0 @@ -// Incremental SSE transcoders (P2). Consume an upstream provider's streaming events line-by-line -// and emit the client protocol's SSE events as they arrive — true token-by-token streaming, not the -// buffer-then-synthesize first cut. Wired pairs (see `Transcoder`): -// - OpenAI Chat `chat.completion.chunk` → Anthropic Messages events (Claude Code client) -// - OpenAI Chat `chat.completion.chunk` → OpenAI Responses events (Codex client) -// - Anthropic Messages events → OpenAI Responses events (Codex client) - -use super::openai_responses::{ - custom_tool_input_from_chat_arguments, response_scoped_call_id, CodexToolContext, CodexToolKind, -}; -use super::Wire; -use serde_json::{json, Value}; - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct CapturedToolCall { - pub call_id: String, - pub name: String, - pub arguments: String, - pub thought_signature: Option, -} - -fn ev(event: &str, data: Value) -> String { - format!( - "event: {}\ndata: {}\n\n", - event, - serde_json::to_string(&data).unwrap_or_default() - ) -} - -fn upstream_error_message(event: &Value) -> Option<&str> { - let error = event.get("error").filter(|value| !value.is_null()); - let is_error = error.is_some() || event.get("type").and_then(Value::as_str) == Some("error"); - if !is_error { - return None; - } - error - .and_then(|value| value.get("message").and_then(Value::as_str)) - .or_else(|| error.and_then(Value::as_str)) - .or_else(|| event.get("message").and_then(Value::as_str)) - .or(Some("upstream error")) -} - -/// Dispatcher over the wired (provider → client) incremental transcoders, so gateway.rs holds one -/// value regardless of the pair. `supports` is the single source of truth behind -/// `protocol::can_transcode_stream`. -pub enum Transcoder { - ChatToAnthropic(ChatToAnthropic), - ChatToResponses(ChatToResponses), - AnthropicToResponses(AnthropicToResponses), -} - -impl Transcoder { - pub fn supports(provider: Wire, client: Wire) -> bool { - matches!( - (provider, client), - (Wire::OpenAiChat, Wire::Anthropic) - | (Wire::OpenAiChat, Wire::OpenAiResponses) - | (Wire::Anthropic, Wire::OpenAiResponses) - ) - } - - pub fn new(provider: Wire, client: Wire, client_model: &str) -> Option { - Self::new_with_context(provider, client, client_model, CodexToolContext::default()) - } - - pub fn new_with_context( - provider: Wire, - client: Wire, - client_model: &str, - tool_context: CodexToolContext, - ) -> Option { - match (provider, client) { - (Wire::OpenAiChat, Wire::Anthropic) => { - Some(Self::ChatToAnthropic(ChatToAnthropic::new(client_model))) - } - (Wire::OpenAiChat, Wire::OpenAiResponses) => Some(Self::ChatToResponses( - ChatToResponses::new_with_context(client_model, tool_context), - )), - (Wire::Anthropic, Wire::OpenAiResponses) => Some(Self::AnthropicToResponses( - AnthropicToResponses::new_with_context(client_model, tool_context), - )), - _ => None, - } - } - - pub fn push(&mut self, line: &str) -> String { - match self { - Self::ChatToAnthropic(t) => t.push(line), - Self::ChatToResponses(t) => t.push(line), - Self::AnthropicToResponses(t) => t.push(line), - } - } - - pub fn finish(&mut self) -> String { - match self { - Self::ChatToAnthropic(t) => t.finish(), - Self::ChatToResponses(t) => t.finish(), - Self::AnthropicToResponses(t) => t.finish(), - } - } - - /// Terminate a translated stream without allowing EOF finalization to synthesize success. - pub fn fail(&mut self, message: &str) -> String { - match self { - Self::ChatToAnthropic(t) => t.fail(message), - Self::ChatToResponses(t) => t.fail(message), - Self::AnthropicToResponses(t) => t.fail(message), - } - } - - pub fn input_tokens(&self) -> i64 { - match self { - Self::ChatToAnthropic(t) => t.input_tokens(), - Self::ChatToResponses(t) => t.input_tokens(), - Self::AnthropicToResponses(t) => t.input_tokens(), - } - } - - pub fn output_tokens(&self) -> i64 { - match self { - Self::ChatToAnthropic(t) => t.output_tokens(), - Self::ChatToResponses(t) => t.output_tokens(), - Self::AnthropicToResponses(t) => t.output_tokens(), - } - } - - pub fn captured_tool_calls(&self) -> Vec { - match self { - Self::ChatToAnthropic(t) => t.captured_tool_calls(), - Self::ChatToResponses(t) => t.captured_tool_calls(), - _ => vec![], - } - } - - /// True once the terminal client event (`message_stop` / `response.completed` / - /// `response.incomplete` / `response.failed`) has been emitted: the turn is semantically - /// complete even though the upstream socket may not have hit EOF yet — Responses clients - /// (Codex) hang up exactly at this point, so the gateway must not treat that disconnect as an - /// abort. - pub fn done(&self) -> bool { - match self { - Self::ChatToAnthropic(t) => t.stopped, - Self::ChatToResponses(t) => t.stopped, - Self::AnthropicToResponses(t) => t.stopped, - } - } - - pub fn succeeded(&self) -> bool { - match self { - Self::ChatToAnthropic(t) => t.stopped && !t.failed, - Self::ChatToResponses(t) => t.stopped && !t.failed, - Self::AnthropicToResponses(t) => t.stopped && !t.failed, - } - } -} - -fn map_stop(finish: Option<&str>, had_tool: bool) -> &'static str { - match finish { - Some("length") => "max_tokens", - Some("tool_calls") | Some("function_call") => "tool_use", - _ if had_tool => "tool_use", - _ => "end_turn", - } -} - -/// Stateful OpenAI-Chat-stream → Anthropic-stream transcoder. Feed each raw upstream SSE line to -/// `push`; call `finish` at end. Anthropic requires an ordered `message_start`, then content blocks -/// (each `content_block_start`/`_delta`/`_stop`), then `message_delta` + `message_stop`. We open a -/// text block on the first text delta and one tool_use block per OpenAI tool_call index, assigning -/// Anthropic block indices in first-appearance order. -pub struct ChatToAnthropic { - client_model: String, - started: bool, - // message id sent in message_start — from the upstream chunk id when it has one, else a - // generated unique id. Clients persist this id; it must never repeat across turns (usage - // analytics de-dupes assistant messages by id). - msg_id: Option, - next_index: usize, - // text block - text_index: Option, - // openai tool_call index → (anthropic block index, open?) - tools: Vec, - input_tokens: i64, - output_tokens: i64, - finish_reason: Option, - stopped: bool, - failed: bool, -} - -struct ToolSlot { - oa_index: u64, - an_index: usize, - open: bool, - id: String, - name: String, - thought_signature: Option, - arguments: String, -} - -impl ChatToAnthropic { - pub fn new(client_model: &str) -> Self { - Self { - client_model: client_model.to_string(), - started: false, - msg_id: None, - next_index: 0, - text_index: None, - tools: vec![], - input_tokens: 0, - output_tokens: 0, - finish_reason: None, - stopped: false, - failed: false, - } - } - - fn ensure_started(&mut self, out: &mut String) { - if self.started { - return; - } - self.started = true; - let id = self - .msg_id - .get_or_insert_with(|| super::uid("msg_ccbud")) - .clone(); - out.push_str(&ev( - "message_start", - json!({ "type": "message_start", "message": { - "id": id, "type": "message", "role": "assistant", "model": self.client_model, - "content": [], "stop_reason": Value::Null, "stop_sequence": Value::Null, - "usage": { "input_tokens": self.input_tokens.max(0), "output_tokens": 0 }, - }}), - )); - } - - fn open_text(&mut self, out: &mut String) -> usize { - if let Some(i) = self.text_index { - return i; - } - let idx = self.next_index; - self.next_index += 1; - self.text_index = Some(idx); - out.push_str(&ev("content_block_start", json!({ "type": "content_block_start", "index": idx, "content_block": { "type": "text", "text": "" } }))); - idx - } - - fn captured_tool_calls(&self) -> Vec { - self.tools - .iter() - .map(|slot| CapturedToolCall { - call_id: slot.id.clone(), - name: slot.name.clone(), - arguments: slot.arguments.clone(), - thought_signature: slot.thought_signature.clone(), - }) - .collect() - } - - /// Feed one raw upstream SSE line (e.g. "data: {...}\n" or "data: [DONE]\n"). Returns the - /// Anthropic SSE text to forward (possibly empty). - pub fn push(&mut self, line: &str) -> String { - let mut out = String::new(); - if self.stopped { - return out; - } - let t = line.trim(); - let payload = match t.strip_prefix("data:") { - Some(p) => p.trim(), - None => return out, // ignore "event:" lines / blanks; chat SSE carries data-only - }; - if payload.is_empty() { - return out; - } - if payload == "[DONE]" { - out.push_str(&self.complete()); - return out; - } - let chunk: Value = match serde_json::from_str(payload) { - Ok(v) => v, - Err(_) => return out, - }; - if let Some(message) = upstream_error_message(&chunk) { - return self.fail(message); - } - if self.msg_id.is_none() { - if let Some(id) = chunk - .get("id") - .and_then(|v| v.as_str()) - .filter(|s| !s.is_empty()) - { - self.msg_id = Some(format!("msg_{}", id)); - } - } - // usage may ride the final chunk (stream_options.include_usage) - if let Some(u) = chunk.get("usage").filter(|u| !u.is_null()) { - self.input_tokens = u - .get("prompt_tokens") - .and_then(|v| v.as_i64()) - .unwrap_or(self.input_tokens); - self.output_tokens = u - .get("completion_tokens") - .and_then(|v| v.as_i64()) - .unwrap_or(self.output_tokens); - } - let choice = chunk - .get("choices") - .and_then(|c| c.as_array()) - .and_then(|a| a.first()); - let choice = match choice { - Some(c) => c, - None => return out, - }; - self.ensure_started(&mut out); - let delta = choice.get("delta").cloned().unwrap_or(Value::Null); - - // text delta - if let Some(txt) = delta.get("content").and_then(|v| v.as_str()) { - if !txt.is_empty() { - let idx = self.open_text(&mut out); - out.push_str(&ev("content_block_delta", json!({ "type": "content_block_delta", "index": idx, "delta": { "type": "text_delta", "text": txt } }))); - } - } - - // tool_call deltas (streamed in fragments, keyed by their OpenAI index) - if let Some(tcs) = delta.get("tool_calls").and_then(|v| v.as_array()) { - // A no-index Gemini chunk can contain multiple parallel calls. Even if a provider - // repeats the same id, each array item in this delta must claim a distinct slot. - let mut claimed_slots: Vec = vec![]; - for (fallback_index, tc) in tcs.iter().enumerate() { - let explicit_index = tc.get("index").and_then(|v| v.as_u64()); - let oa_index = explicit_index.unwrap_or(fallback_index as u64); - let name = tc - .get("function") - .and_then(|f| f.get("name")) - .and_then(|v| v.as_str()) - .unwrap_or(""); - let id = tc.get("id").and_then(|v| v.as_str()).unwrap_or(""); - let args = tc - .get("function") - .and_then(|f| f.get("arguments")) - .and_then(|v| v.as_str()) - .unwrap_or(""); - let thought_signature = super::json_thought_signature(tc); - - // Standard OpenAI chunks carry `index`; Gemini-compatible streams may omit it. - // In that case prefer the stable call id, then the call's position in this delta. - let pos = explicit_index - .and_then(|_| { - self.tools - .iter() - .enumerate() - .find(|(index, slot)| { - slot.oa_index == oa_index && !claimed_slots.contains(index) - }) - .map(|(index, _)| index) - }) - .or_else(|| { - (!id.is_empty()) - .then(|| { - self.tools - .iter() - .enumerate() - .find(|(index, slot)| { - slot.id == id && !claimed_slots.contains(index) - }) - .map(|(index, _)| index) - }) - .flatten() - }) - .or_else(|| { - self.tools - .iter() - .enumerate() - .find(|(index, slot)| { - slot.oa_index == oa_index && !claimed_slots.contains(index) - }) - .map(|(index, _)| index) - }); - let slot_idx = match pos { - Some(i) => i, - None => { - let an_index = self.next_index; - self.next_index += 1; - self.tools.push(ToolSlot { - oa_index, - an_index, - open: false, - id: String::new(), - name: String::new(), - thought_signature: None, - arguments: String::new(), - }); - self.tools.len() - 1 - } - }; - claimed_slots.push(slot_idx); - let (an_index, should_open, open_id, open_name) = { - let slot = &mut self.tools[slot_idx]; - if !id.is_empty() { - slot.id = id.to_string(); - } - if !name.is_empty() { - slot.name = name.to_string(); - } - if thought_signature.is_some() { - slot.thought_signature = thought_signature; - } - if !args.is_empty() { - slot.arguments.push_str(args); - } - let should_open = !slot.open; - if should_open { - slot.open = true; - } - ( - slot.an_index, - should_open, - slot.id.clone(), - slot.name.clone(), - ) - }; - if should_open { - out.push_str(&ev( - "content_block_start", - json!({ "type": "content_block_start", - "index": an_index, "content_block": { "type": "tool_use", - "id": open_id, "name": open_name, "input": {} } }), - )); - } - if !args.is_empty() { - out.push_str(&ev("content_block_delta", json!({ "type": "content_block_delta", - "index": an_index, "delta": { "type": "input_json_delta", "partial_json": args } }))); - } - } - } - - if let Some(fr) = choice.get("finish_reason").and_then(|v| v.as_str()) { - self.finish_reason = Some(fr.to_string()); - } - out - } - - /// Close any open blocks and emit message_delta + message_stop. Idempotent. - fn complete(&mut self) -> String { - if self.stopped { - return String::new(); - } - self.stopped = true; - let mut out = String::new(); - self.ensure_started(&mut out); - // close blocks in ascending Anthropic index order - let mut closes: Vec = vec![]; - if let Some(i) = self.text_index { - closes.push(i); - } - for s in &self.tools { - if s.open { - closes.push(s.an_index); - } - } - closes.sort_unstable(); - for i in closes { - out.push_str(&ev( - "content_block_stop", - json!({ "type": "content_block_stop", "index": i }), - )); - } - let had_tool = self.tools.iter().any(|s| s.open); - out.push_str(&ev( - "message_delta", - json!({ "type": "message_delta", - "delta": { "stop_reason": map_stop(self.finish_reason.as_deref(), had_tool), "stop_sequence": Value::Null }, - "usage": { "output_tokens": self.output_tokens.max(0) } }), - )); - out.push_str(&ev("message_stop", json!({ "type": "message_stop" }))); - out - } - - /// Finalize a clean upstream EOF only when a Chat finish reason was observed. `[DONE]` calls - /// `complete` directly; an EOF without either signal is a truncated stream. - pub fn finish(&mut self) -> String { - if self.stopped { - return String::new(); - } - if self.finish_reason.is_some() { - self.complete() - } else { - self.fail("upstream stream ended before [DONE] or a finish reason") - } - } - - fn fail(&mut self, message: &str) -> String { - if self.stopped { - return String::new(); - } - self.stopped = true; - self.failed = true; - ev( - "error", - json!({ "type": "error", "error": { "type": "api_error", "message": message } }), - ) - } - - pub fn input_tokens(&self) -> i64 { - self.input_tokens - } - pub fn output_tokens(&self) -> i64 { - self.output_tokens - } -} - -// ---- shared Responses-side item builders (final `output_item.done` / `completed` payloads) ---- - -fn resp_message_item(id: &str, text: &str) -> Value { - json!({ "type": "message", "id": id, "status": "completed", "role": "assistant", - "content": [{ "type": "output_text", "annotations": [], "text": text }] }) -} - -fn resp_function_call_item(id: &str, call_id: &str, name: &str, args: &str) -> Value { - json!({ "type": "function_call", "id": id, "status": "completed", "call_id": call_id, - "name": name, "arguments": if args.is_empty() { "{}" } else { args } }) -} - -fn resp_in_progress_tool_item( - context: &CodexToolContext, - id: &str, - call_id: &str, - name: &str, - reasoning: Option<&str>, -) -> Value { - let mut item = - context.response_tool_item_with_reasoning(id, "in_progress", call_id, name, "", reasoning); - if item.get("type").and_then(Value::as_str) == Some("function_call") { - item["arguments"] = json!(""); - } - item -} - -fn normalized_tool_arguments(arguments: &str) -> &str { - if arguments.trim().is_empty() { - "{}" - } else { - arguments - } -} - -fn resp_reasoning_item(id: &str, text: &str) -> Value { - json!({ "type": "reasoning", "id": id, "summary": [{ "type": "summary_text", "text": text }] }) -} - -fn response_scoped_item_id(prefix: &str, response_id: &str, index: usize) -> String { - format!( - "{}_{}_{}", - prefix, - response_id.trim_start_matches("resp_"), - index - ) -} - -/// The terminal `response.completed` event. Codex parses `response.id` + `response.usage` from it -/// and treats a stream that closes without it as an error, so every Responses-emitting transcoder -/// must end with this exactly once. -fn resp_completed( - id: &str, - model: &str, - output: Vec, - input: i64, - cached: i64, - output_tokens: i64, -) -> String { - ev( - "response.completed", - json!({ "type": "response.completed", "response": { - "id": id, "object": "response", "status": "completed", "model": model, - "output": output, - "usage": { - "input_tokens": input.max(0), - "input_tokens_details": { "cached_tokens": cached.max(0) }, - "output_tokens": output_tokens.max(0), - "output_tokens_details": { "reasoning_tokens": 0 }, - "total_tokens": (input + output_tokens).max(0), - } } }), - ) -} - -fn resp_incomplete( - id: &str, - model: &str, - output: Vec, - input: i64, - cached: i64, - output_tokens: i64, - reason: &str, -) -> String { - ev( - "response.incomplete", - json!({ "type": "response.incomplete", "response": { - "id": id, "object": "response", "status": "incomplete", "model": model, - "output": output, - "incomplete_details": { "reason": reason }, - "usage": { - "input_tokens": input.max(0), - "input_tokens_details": { "cached_tokens": cached.max(0) }, - "output_tokens": output_tokens.max(0), - "output_tokens_details": { "reasoning_tokens": 0 }, - "total_tokens": (input + output_tokens).max(0), - } } }), - ) -} - -fn incomplete_reason(stop_reason: Option<&str>) -> Option<&'static str> { - match stop_reason { - Some("length" | "max_tokens" | "model_context_window_exceeded") => { - Some("max_output_tokens") - } - Some("content_filter") => Some("content_filter"), - _ => None, - } -} - -fn resp_failed(id: &str, message: &str) -> String { - ev( - "response.failed", - json!({ "type": "response.failed", "response": { - "id": id, "object": "response", "status": "failed", - "error": { "code": "upstream_error", "message": message } - } }), - ) -} - -/// Stateful OpenAI-Chat-stream → OpenAI-Responses-stream transcoder (Codex client, chat upstream). -/// Text deltas stream through as `response.output_text.delta`; provider reasoning deltas -/// (`reasoning_content` / `reasoning`) as `response.reasoning_summary_text.delta`; tool-call -/// fragments accumulate per OpenAI index (with the same no-index Gemini slot handling as -/// ChatToAnthropic, including thought-signature capture) and surface whole in -/// `response.output_item.done` — the only place Codex materializes items from. -pub struct ChatToResponses { - client_model: String, - tool_context: CodexToolContext, - // Construction-time fallback. An upstream id may replace it only until response.created is - // emitted; afterward this id is immutable so every event in the response agrees. - resp_id: String, - created: bool, - next_index: usize, - reasoning: Option, - reasoning_open: bool, - message: Option, - tools: Vec, - input_tokens: i64, - cached_tokens: i64, - output_tokens: i64, - finish_reason: Option, - stopped: bool, - failed: bool, -} - -struct TextItemAcc { - index: usize, - id: String, - acc: String, -} - -struct RespToolAcc { - oa_index: u64, - index: usize, - id: String, - upstream_call_id: String, - call_id: String, - name: String, - args: String, - thought_signature: Option, - announced: bool, - emitted_args_len: usize, -} - -impl ChatToResponses { - pub fn new(client_model: &str) -> Self { - Self::new_with_context(client_model, CodexToolContext::default()) - } - - pub fn new_with_context(client_model: &str, tool_context: CodexToolContext) -> Self { - Self { - client_model: client_model.to_string(), - tool_context, - resp_id: super::uid("resp_ccbud"), - created: false, - next_index: 0, - reasoning: None, - reasoning_open: false, - message: None, - tools: vec![], - input_tokens: 0, - cached_tokens: 0, - output_tokens: 0, - finish_reason: None, - stopped: false, - failed: false, - } - } - - fn rid(&self) -> String { - self.resp_id.clone() - } - - /// The turn's tool calls (with any Gemini thought signatures sniffed from the chat stream), - /// keyed by the call_id the Responses client will echo back — feeds the gateway's - /// session-scoped signature cache exactly like ChatToAnthropic. Nameless slots are excluded, - /// matching what finish() emits (and therefore what the client can echo). - pub fn captured_tool_calls(&self) -> Vec { - self.tools - .iter() - .filter(|slot| !slot.name.is_empty()) - .map(|slot| CapturedToolCall { - call_id: slot.call_id.clone(), - name: slot.name.clone(), - arguments: slot.args.clone(), - thought_signature: slot.thought_signature.clone(), - }) - .collect() - } - - fn ensure_created(&mut self, out: &mut String) { - if self.created { - return; - } - self.created = true; - let id = self.rid(); - out.push_str(&ev( - "response.created", - json!({ "type": "response.created", - "response": { "id": id, "object": "response", "status": "in_progress", "model": self.client_model } }), - )); - } - - fn close_reasoning(&mut self, out: &mut String) { - if !self.reasoning_open { - return; - } - self.reasoning_open = false; - if let Some(r) = &self.reasoning { - out.push_str(&ev( - "response.output_item.done", - json!({ "type": "response.output_item.done", "output_index": r.index, - "item": resp_reasoning_item(&r.id, &r.acc) }), - )); - } - } - - fn announce_tool_if_ready(&mut self, pos: usize, out: &mut String) { - let Some(slot) = self.tools.get(pos) else { - return; - }; - if slot.announced || slot.name.is_empty() { - return; - } - - let id = self - .tool_context - .response_item_id(&slot.name, &self.rid(), slot.index); - let item = resp_in_progress_tool_item( - &self.tool_context, - &id, - &slot.call_id, - &slot.name, - self.reasoning - .as_ref() - .map(|reasoning| reasoning.acc.as_str()), - ); - let index = slot.index; - out.push_str(&ev( - "response.output_item.added", - json!({ "type": "response.output_item.added", "output_index": index, "item": item }), - )); - - let slot = &mut self.tools[pos]; - slot.id = id; - slot.announced = true; - self.emit_pending_tool_arguments(pos, out); - } - - fn emit_pending_tool_arguments(&mut self, pos: usize, out: &mut String) { - let Some(slot) = self.tools.get_mut(pos) else { - return; - }; - if !slot.announced - || slot.name.is_empty() - || self.tool_context.kind_for_chat_name(&slot.name) == CodexToolKind::Custom - || slot.emitted_args_len >= slot.args.len() - { - return; - } - let delta = slot.args[slot.emitted_args_len..].to_string(); - slot.emitted_args_len = slot.args.len(); - out.push_str(&ev( - "response.function_call_arguments.delta", - json!({ "type": "response.function_call_arguments.delta", "item_id": slot.id, - "output_index": slot.index, "delta": delta }), - )); - } - - fn close_tool_events(&self, slot: &RespToolAcc) -> String { - let mut out = String::new(); - let arguments = normalized_tool_arguments(&slot.args); - let item = self.tool_context.response_tool_item_with_reasoning( - &slot.id, - "completed", - &slot.call_id, - &slot.name, - arguments, - self.reasoning - .as_ref() - .map(|reasoning| reasoning.acc.as_str()), - ); - match self.tool_context.kind_for_chat_name(&slot.name) { - CodexToolKind::Custom => { - let input = custom_tool_input_from_chat_arguments(arguments); - if !input.is_empty() { - out.push_str(&ev( - "response.custom_tool_call_input.delta", - json!({ "type": "response.custom_tool_call_input.delta", "item_id": slot.id, - "call_id": slot.call_id, "output_index": slot.index, "delta": input }), - )); - } - out.push_str(&ev( - "response.custom_tool_call_input.done", - json!({ "type": "response.custom_tool_call_input.done", "item_id": slot.id, - "call_id": slot.call_id, "output_index": slot.index, "input": input }), - )); - } - CodexToolKind::Function | CodexToolKind::Namespace | CodexToolKind::ToolSearch => { - out.push_str(&ev( - "response.function_call_arguments.done", - json!({ "type": "response.function_call_arguments.done", "item_id": slot.id, - "output_index": slot.index, "arguments": arguments }), - )); - } - } - out.push_str(&ev( - "response.output_item.done", - json!({ "type": "response.output_item.done", "output_index": slot.index, "item": item }), - )); - out - } - - /// Feed one raw upstream SSE line ("data: {...}" or "data: [DONE]"). Returns Responses SSE - /// text to forward (possibly empty). - pub fn push(&mut self, line: &str) -> String { - let mut out = String::new(); - if self.stopped { - return out; - } - let t = line.trim(); - let payload = match t.strip_prefix("data:") { - Some(p) => p.trim(), - None => return out, // chat SSE is data-only; ignore blanks/event: lines - }; - if payload.is_empty() { - return out; - } - if payload == "[DONE]" { - out.push_str(&self.complete()); - return out; - } - let chunk: Value = match serde_json::from_str(payload) { - Ok(v) => v, - Err(_) => return out, - }; - if let Some(message) = upstream_error_message(&chunk) { - return self.fail(message); - } - if !self.created { - if let Some(id) = chunk - .get("id") - .and_then(|v| v.as_str()) - .filter(|s| !s.is_empty()) - { - self.resp_id = format!("resp_{}", id); - } - } - // usage rides the final chunk (stream_options.include_usage) - if let Some(u) = chunk.get("usage").filter(|u| !u.is_null()) { - self.input_tokens = u - .get("prompt_tokens") - .and_then(|v| v.as_i64()) - .unwrap_or(self.input_tokens); - self.output_tokens = u - .get("completion_tokens") - .and_then(|v| v.as_i64()) - .unwrap_or(self.output_tokens); - if let Some(c) = u - .pointer("/prompt_tokens_details/cached_tokens") - .and_then(|v| v.as_i64()) - { - self.cached_tokens = c; - } - } - let choice = match chunk - .get("choices") - .and_then(|c| c.as_array()) - .and_then(|a| a.first()) - { - Some(c) => c, - None => return out, - }; - self.ensure_created(&mut out); - let delta = choice.get("delta").cloned().unwrap_or(Value::Null); - - // provider reasoning stream (DeepSeek/GLM-style `reasoning_content`, or `reasoning`) - let think = delta - .get("reasoning_content") - .and_then(|v| v.as_str()) - .or_else(|| delta.get("reasoning").and_then(|v| v.as_str())) - .unwrap_or(""); - if !think.is_empty() { - if self.reasoning.is_none() { - let index = self.next_index; - self.next_index += 1; - let id = response_scoped_item_id("rs", &self.rid(), index); - out.push_str(&ev( - "response.output_item.added", - json!({ "type": "response.output_item.added", "output_index": index, - "item": { "type": "reasoning", "id": id, "summary": [] } }), - )); - self.reasoning = Some(TextItemAcc { - index, - id, - acc: String::new(), - }); - self.reasoning_open = true; - } - let r = self.reasoning.as_mut().unwrap(); - r.acc.push_str(think); - out.push_str(&ev( - "response.reasoning_summary_text.delta", - json!({ "type": "response.reasoning_summary_text.delta", "item_id": r.id, - "output_index": r.index, "summary_index": 0, "delta": think }), - )); - } - - // text delta - if let Some(txt) = delta.get("content").and_then(|v| v.as_str()) { - if !txt.is_empty() { - self.close_reasoning(&mut out); - if self.message.is_none() { - let index = self.next_index; - self.next_index += 1; - let id = response_scoped_item_id("msg", &self.rid(), index); - out.push_str(&ev( - "response.output_item.added", - json!({ "type": "response.output_item.added", "output_index": index, - "item": { "type": "message", "id": id, "status": "in_progress", "role": "assistant", "content": [] } }), - )); - out.push_str(&ev( - "response.content_part.added", - json!({ "type": "response.content_part.added", "item_id": id, "output_index": index, - "content_index": 0, "part": { "type": "output_text", "annotations": [], "text": "" } }), - )); - self.message = Some(TextItemAcc { - index, - id, - acc: String::new(), - }); - } - let m = self.message.as_mut().unwrap(); - m.acc.push_str(txt); - out.push_str(&ev( - "response.output_text.delta", - json!({ "type": "response.output_text.delta", "item_id": m.id, "output_index": m.index, - "content_index": 0, "delta": txt }), - )); - } - } - - // tool_call deltas (streamed in fragments, keyed by their OpenAI index) - if let Some(tcs) = delta.get("tool_calls").and_then(|v| v.as_array()) { - if !tcs.is_empty() { - self.close_reasoning(&mut out); - } - // A no-index Gemini chunk can contain multiple parallel calls. Even if a provider - // repeats the same id, each array item in this delta must claim a distinct slot - // (mirrors ChatToAnthropic). - let mut claimed_slots: Vec = vec![]; - for (fallback_index, tc) in tcs.iter().enumerate() { - let explicit_index = tc.get("index").and_then(|v| v.as_u64()); - let oa_index = explicit_index.unwrap_or(fallback_index as u64); - let frag_id = tc.get("id").and_then(|v| v.as_str()).unwrap_or(""); - let frag_name = tc - .get("function") - .and_then(|f| f.get("name")) - .and_then(|v| v.as_str()) - .unwrap_or(""); - let args = tc - .get("function") - .and_then(|f| f.get("arguments")) - .and_then(|v| v.as_str()) - .unwrap_or(""); - let thought_signature = super::json_thought_signature(tc); - - // Standard OpenAI chunks carry `index`; Gemini-compatible streams may omit it. - // In that case prefer the stable call id, then the call's position in this delta. - let pos = explicit_index - .and_then(|_| { - self.tools - .iter() - .enumerate() - .find(|(index, slot)| { - slot.oa_index == oa_index && !claimed_slots.contains(index) - }) - .map(|(index, _)| index) - }) - .or_else(|| { - (!frag_id.is_empty()) - .then(|| { - self.tools - .iter() - .enumerate() - .find(|(index, slot)| { - slot.upstream_call_id == frag_id - && !claimed_slots.contains(index) - }) - .map(|(index, _)| index) - }) - .flatten() - }) - .or_else(|| { - self.tools - .iter() - .enumerate() - .find(|(index, slot)| { - slot.oa_index == oa_index && !claimed_slots.contains(index) - }) - .map(|(index, _)| index) - }); - let pos = match pos { - Some(p) => p, - None => { - let index = self.next_index; - self.next_index += 1; - let call_id = response_scoped_call_id(&self.rid(), index); - let slot = RespToolAcc { - oa_index, - index, - id: String::new(), - upstream_call_id: frag_id.to_string(), - call_id, - name: frag_name.to_string(), - args: String::new(), - thought_signature: None, - announced: false, - emitted_args_len: 0, - }; - self.tools.push(slot); - self.tools.len() - 1 - } - }; - claimed_slots.push(pos); - // stray late fragments may carry the id/name the opener lacked; the done item wins - if !frag_id.is_empty() { - self.tools[pos].upstream_call_id = frag_id.to_string(); - } - if !frag_name.is_empty() && self.tools[pos].name.is_empty() { - self.tools[pos].name = frag_name.to_string(); - } - if thought_signature.is_some() { - self.tools[pos].thought_signature = thought_signature; - } - if !args.is_empty() { - self.tools[pos].args.push_str(args); - } - self.announce_tool_if_ready(pos, &mut out); - self.emit_pending_tool_arguments(pos, &mut out); - } - } - if let Some(reason) = choice.get("finish_reason").and_then(Value::as_str) { - self.finish_reason = Some(reason.to_string()); - } - out - } - - /// Close open items in index order and emit the appropriate terminal Responses event. - fn complete(&mut self) -> String { - if self.stopped { - return String::new(); - } - self.stopped = true; - let mut out = String::new(); - self.ensure_created(&mut out); - self.close_reasoning(&mut out); - if let Some(m) = &self.message { - out.push_str(&ev( - "response.output_text.done", - json!({ "type": "response.output_text.done", "item_id": m.id, "output_index": m.index, - "content_index": 0, "text": m.acc }), - )); - out.push_str(&ev( - "response.content_part.done", - json!({ "type": "response.content_part.done", "item_id": m.id, "output_index": m.index, - "content_index": 0, "part": { "type": "output_text", "annotations": [], "text": m.acc } }), - )); - out.push_str(&ev( - "response.output_item.done", - json!({ "type": "response.output_item.done", "output_index": m.index, - "item": resp_message_item(&m.id, &m.acc) }), - )); - } - for pos in 0..self.tools.len() { - self.announce_tool_if_ready(pos, &mut out); - } - // A slot whose name never arrived is model garbage the client cannot execute — and a - // nameless function_call echoed into the next request is rejected upstream. Skip it. - for slot in self - .tools - .iter() - .filter(|slot| slot.announced && !slot.name.is_empty()) - { - out.push_str(&self.close_tool_events(slot)); - } - let mut items: Vec<(usize, Value)> = vec![]; - if let Some(r) = &self.reasoning { - items.push((r.index, resp_reasoning_item(&r.id, &r.acc))); - } - if let Some(m) = &self.message { - items.push((m.index, resp_message_item(&m.id, &m.acc))); - } - for slot in self - .tools - .iter() - .filter(|slot| slot.announced && !slot.name.is_empty()) - { - items.push(( - slot.index, - self.tool_context.response_tool_item_with_reasoning( - &slot.id, - "completed", - &slot.call_id, - &slot.name, - normalized_tool_arguments(&slot.args), - self.reasoning - .as_ref() - .map(|reasoning| reasoning.acc.as_str()), - ), - )); - } - items.sort_by_key(|(i, _)| *i); - let output: Vec = items.into_iter().map(|(_, v)| v).collect(); - if let Some(reason) = incomplete_reason(self.finish_reason.as_deref()) { - self.failed = true; - out.push_str(&resp_incomplete( - &self.rid(), - &self.client_model, - output, - self.input_tokens, - self.cached_tokens, - self.output_tokens, - reason, - )); - } else { - out.push_str(&resp_completed( - &self.rid(), - &self.client_model, - output, - self.input_tokens, - self.cached_tokens, - self.output_tokens, - )); - } - out - } - - /// Finalize a clean upstream EOF only when a Chat finish reason was observed. `[DONE]` calls - /// `complete` directly; an EOF without either signal is a truncated stream. - pub fn finish(&mut self) -> String { - if self.stopped { - return String::new(); - } - if self.finish_reason.is_some() { - self.complete() - } else { - self.fail("upstream stream ended before [DONE] or a finish reason") - } - } - - fn fail(&mut self, message: &str) -> String { - if self.stopped { - return String::new(); - } - let mut out = String::new(); - self.ensure_created(&mut out); - let id = self.rid(); - out.push_str(&resp_failed(&id, message)); - self.stopped = true; - self.failed = true; - out - } - - pub fn input_tokens(&self) -> i64 { - self.input_tokens - } - pub fn output_tokens(&self) -> i64 { - self.output_tokens - } -} - -/// Stateful Anthropic-Messages-stream → OpenAI-Responses-stream transcoder (Codex client, -/// Anthropic upstream). Anthropic blocks map 1:1 onto Responses output items: text → -/// message/output_text, tool_use → function_call (input_json_delta fragments accumulate into the -/// arguments string), thinking → reasoning summary. Upstream `error` events surface as -/// `response.failed` so Codex aborts cleanly instead of timing out. -pub struct AnthropicToResponses { - client_model: String, - tool_context: CodexToolContext, - // Construction-time fallback. An upstream id may replace it only until response.created is - // emitted; afterward this id is immutable so every event in the response agrees. - resp_id: String, - created: bool, - next_index: usize, - blocks: Vec, - input_tokens: i64, - cached_tokens: i64, - output_tokens: i64, - stop_reason: Option, - stopped: bool, - failed: bool, -} - -struct ABlock { - a_index: u64, - index: usize, - id: String, - kind: AKind, - open: bool, -} - -enum AKind { - Text { - acc: String, - }, - Tool { - call_id: String, - name: String, - args: String, - start_args: String, - }, - Think { - acc: String, - }, -} - -fn ablock_tool_arguments(args: &str, start_args: &str) -> String { - if !args.trim().is_empty() { - args.to_string() - } else if !start_args.trim().is_empty() { - start_args.to_string() - } else { - "{}".to_string() - } -} - -/// The closing event sequence for one finished block (its `*.done` events + `output_item.done`). -fn close_ablock_events( - b: &ABlock, - tool_context: &CodexToolContext, - reasoning: Option<&str>, -) -> String { - let mut out = String::new(); - match &b.kind { - AKind::Text { acc } => { - out.push_str(&ev( - "response.output_text.done", - json!({ "type": "response.output_text.done", "item_id": b.id, "output_index": b.index, - "content_index": 0, "text": acc }), - )); - out.push_str(&ev( - "response.content_part.done", - json!({ "type": "response.content_part.done", "item_id": b.id, "output_index": b.index, - "content_index": 0, "part": { "type": "output_text", "annotations": [], "text": acc } }), - )); - out.push_str(&ev( - "response.output_item.done", - json!({ "type": "response.output_item.done", "output_index": b.index, "item": resp_message_item(&b.id, acc) }), - )); - } - AKind::Tool { - call_id, - name, - args, - start_args, - } => { - let arguments = ablock_tool_arguments(args, start_args); - let item = tool_context.response_tool_item_with_reasoning( - &b.id, - "completed", - call_id, - name, - &arguments, - reasoning, - ); - match tool_context.kind_for_chat_name(name) { - CodexToolKind::Custom => { - let input = custom_tool_input_from_chat_arguments(&arguments); - if !input.is_empty() { - out.push_str(&ev( - "response.custom_tool_call_input.delta", - json!({ "type": "response.custom_tool_call_input.delta", "item_id": b.id, - "call_id": call_id, "output_index": b.index, "delta": input }), - )); - } - out.push_str(&ev( - "response.custom_tool_call_input.done", - json!({ "type": "response.custom_tool_call_input.done", "item_id": b.id, - "call_id": call_id, "output_index": b.index, "input": input }), - )); - } - CodexToolKind::Function | CodexToolKind::Namespace | CodexToolKind::ToolSearch => { - out.push_str(&ev( - "response.function_call_arguments.done", - json!({ "type": "response.function_call_arguments.done", "item_id": b.id, - "output_index": b.index, "arguments": arguments }), - )); - } - } - out.push_str(&ev( - "response.output_item.done", - json!({ "type": "response.output_item.done", "output_index": b.index, - "item": item }), - )); - } - AKind::Think { acc } => { - out.push_str(&ev( - "response.output_item.done", - json!({ "type": "response.output_item.done", "output_index": b.index, "item": resp_reasoning_item(&b.id, acc) }), - )); - } - } - out -} - -fn ablock_item(b: &ABlock, tool_context: &CodexToolContext, reasoning: Option<&str>) -> Value { - match &b.kind { - AKind::Text { acc } => resp_message_item(&b.id, acc), - AKind::Tool { - call_id, - name, - args, - start_args, - } => tool_context.response_tool_item_with_reasoning( - &b.id, - "completed", - call_id, - name, - &ablock_tool_arguments(args, start_args), - reasoning, - ), - AKind::Think { acc } => resp_reasoning_item(&b.id, acc), - } -} - -impl AnthropicToResponses { - pub fn new(client_model: &str) -> Self { - Self::new_with_context(client_model, CodexToolContext::default()) - } - - pub fn new_with_context(client_model: &str, tool_context: CodexToolContext) -> Self { - Self { - client_model: client_model.to_string(), - tool_context, - resp_id: super::uid("resp_ccbud"), - created: false, - next_index: 0, - blocks: vec![], - input_tokens: 0, - cached_tokens: 0, - output_tokens: 0, - stop_reason: None, - stopped: false, - failed: false, - } - } - - fn rid(&self) -> String { - self.resp_id.clone() - } - - fn reasoning_text(&self) -> Option { - let text = self - .blocks - .iter() - .filter_map(|block| match &block.kind { - AKind::Think { acc } if !acc.trim().is_empty() => Some(acc.as_str()), - _ => None, - }) - .collect::>() - .join("\n\n"); - (!text.is_empty()).then_some(text) - } - - fn ensure_created(&mut self, out: &mut String) { - if self.created { - return; - } - self.created = true; - let id = self.rid(); - out.push_str(&ev( - "response.created", - json!({ "type": "response.created", - "response": { "id": id, "object": "response", "status": "in_progress", "model": self.client_model } }), - )); - } - - /// Feed one raw upstream SSE line. Anthropic streams interleave `event:` and `data:` lines; - /// the data JSON's `type` mirrors the event name, so data lines alone drive the state machine. - pub fn push(&mut self, line: &str) -> String { - let mut out = String::new(); - if self.stopped { - return out; - } - let t = line.trim(); - let payload = match t.strip_prefix("data:") { - Some(p) => p.trim(), - None => return out, - }; - if payload.is_empty() { - return out; - } - let evt: Value = match serde_json::from_str(payload) { - Ok(v) => v, - Err(_) => return out, - }; - match evt.get("type").and_then(|v| v.as_str()) { - Some("message_start") => { - if let Some(m) = evt.get("message") { - if !self.created { - if let Some(id) = m - .get("id") - .and_then(|v| v.as_str()) - .filter(|s| !s.is_empty()) - { - self.resp_id = format!("resp_{}", id); - } - } - if let Some(u) = m.get("usage") { - // Responses-style input_tokens includes cached reads; Anthropic reports - // them separately. - let base = u.get("input_tokens").and_then(|v| v.as_i64()).unwrap_or(0); - let cr = u - .get("cache_read_input_tokens") - .and_then(|v| v.as_i64()) - .unwrap_or(0); - let cc = u - .get("cache_creation_input_tokens") - .and_then(|v| v.as_i64()) - .unwrap_or(0); - self.input_tokens = base + cr + cc; - self.cached_tokens = cr; - } - } - self.ensure_created(&mut out); - } - Some("content_block_start") => { - self.ensure_created(&mut out); - let a_index = evt.get("index").and_then(|v| v.as_u64()).unwrap_or(0); - let cb = evt.get("content_block").cloned().unwrap_or(Value::Null); - let index = self.next_index; - match cb.get("type").and_then(|v| v.as_str()) { - Some("text") => { - self.next_index += 1; - let id = response_scoped_item_id("msg", &self.rid(), index); - out.push_str(&ev( - "response.output_item.added", - json!({ "type": "response.output_item.added", "output_index": index, - "item": { "type": "message", "id": id, "status": "in_progress", "role": "assistant", "content": [] } }), - )); - out.push_str(&ev( - "response.content_part.added", - json!({ "type": "response.content_part.added", "item_id": id, "output_index": index, - "content_index": 0, "part": { "type": "output_text", "annotations": [], "text": "" } }), - )); - self.blocks.push(ABlock { - a_index, - index, - id, - kind: AKind::Text { acc: String::new() }, - open: true, - }); - } - Some("tool_use") => { - self.next_index += 1; - let call_id = response_scoped_call_id(&self.rid(), index); - let name = cb - .get("name") - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(); - let id = self - .tool_context - .response_item_id(&name, &self.rid(), index); - let start_args = cb - .get("input") - .filter(|value| { - value.as_object().is_some_and(|object| !object.is_empty()) - }) - .map(Value::to_string) - .unwrap_or_default(); - let reasoning = self.reasoning_text(); - let item = self.tool_context.response_tool_item_with_reasoning( - &id, - "in_progress", - &call_id, - &name, - "", - reasoning.as_deref(), - ); - let mut item = item; - if item.get("type").and_then(Value::as_str) == Some("function_call") { - item["arguments"] = json!(""); - } - out.push_str(&ev( - "response.output_item.added", - json!({ "type": "response.output_item.added", "output_index": index, - "item": item }), - )); - self.blocks.push(ABlock { - a_index, - index, - id, - kind: AKind::Tool { - call_id, - name, - args: String::new(), - start_args, - }, - open: true, - }); - } - Some("thinking") => { - self.next_index += 1; - let id = response_scoped_item_id("rs", &self.rid(), index); - out.push_str(&ev( - "response.output_item.added", - json!({ "type": "response.output_item.added", "output_index": index, - "item": { "type": "reasoning", "id": id, "summary": [] } }), - )); - self.blocks.push(ABlock { - a_index, - index, - id, - kind: AKind::Think { acc: String::new() }, - open: true, - }); - } - // redacted_thinking / server_tool_use / … have no Responses equivalent; their - // deltas find no block below and drop. - _ => {} - } - } - Some("content_block_delta") => { - let a_index = evt.get("index").and_then(|v| v.as_u64()).unwrap_or(0); - let delta = evt.get("delta").cloned().unwrap_or(Value::Null); - if let Some(b) = self - .blocks - .iter_mut() - .find(|b| b.a_index == a_index && b.open) - { - match (&mut b.kind, delta.get("type").and_then(|v| v.as_str())) { - (AKind::Text { acc }, Some("text_delta")) => { - if let Some(txt) = delta - .get("text") - .and_then(|v| v.as_str()) - .filter(|s| !s.is_empty()) - { - acc.push_str(txt); - out.push_str(&ev( - "response.output_text.delta", - json!({ "type": "response.output_text.delta", "item_id": b.id, - "output_index": b.index, "content_index": 0, "delta": txt }), - )); - } - } - (AKind::Tool { name, args, .. }, Some("input_json_delta")) => { - if let Some(pj) = delta - .get("partial_json") - .and_then(|v| v.as_str()) - .filter(|s| !s.is_empty()) - { - args.push_str(pj); - if self.tool_context.kind_for_chat_name(name) - != CodexToolKind::Custom - { - out.push_str(&ev( - "response.function_call_arguments.delta", - json!({ "type": "response.function_call_arguments.delta", "item_id": b.id, - "output_index": b.index, "delta": pj }), - )); - } - } - } - (AKind::Think { acc }, Some("thinking_delta")) => { - if let Some(th) = delta - .get("thinking") - .and_then(|v| v.as_str()) - .filter(|s| !s.is_empty()) - { - acc.push_str(th); - out.push_str(&ev( - "response.reasoning_summary_text.delta", - json!({ "type": "response.reasoning_summary_text.delta", "item_id": b.id, - "output_index": b.index, "summary_index": 0, "delta": th }), - )); - } - } - _ => {} // signature_delta etc. - } - } - } - Some("content_block_stop") => { - let a_index = evt.get("index").and_then(|v| v.as_u64()).unwrap_or(0); - let reasoning = self.reasoning_text(); - if let Some(b) = self - .blocks - .iter_mut() - .find(|b| b.a_index == a_index && b.open) - { - b.open = false; - out.push_str(&close_ablock_events( - b, - &self.tool_context, - reasoning.as_deref(), - )); - } - } - Some("message_delta") => { - if let Some(reason) = evt - .get("delta") - .and_then(|delta| delta.get("stop_reason")) - .and_then(Value::as_str) - { - self.stop_reason = Some(reason.to_string()); - } - if let Some(o) = evt - .get("usage") - .and_then(|u| u.get("output_tokens")) - .and_then(|v| v.as_i64()) - { - self.output_tokens = o; - } - } - Some("message_stop") => { - out.push_str(&self.complete()); - } - Some("error") => { - let msg = evt - .get("error") - .and_then(|e| e.get("message")) - .and_then(|v| v.as_str()) - .unwrap_or("upstream error"); - out.push_str(&self.fail(msg)); - } - _ => {} // ping etc. - } - out - } - - /// Close any still-open blocks and emit the appropriate terminal Responses event. - fn complete(&mut self) -> String { - if self.stopped { - return String::new(); - } - self.stopped = true; - let mut out = String::new(); - self.ensure_created(&mut out); - let reasoning = self.reasoning_text(); - self.blocks.sort_by_key(|b| b.index); - for b in &mut self.blocks { - if b.open { - b.open = false; - out.push_str(&close_ablock_events( - b, - &self.tool_context, - reasoning.as_deref(), - )); - } - } - let output: Vec = self - .blocks - .iter() - .map(|block| ablock_item(block, &self.tool_context, reasoning.as_deref())) - .collect(); - if let Some(reason) = incomplete_reason(self.stop_reason.as_deref()) { - self.failed = true; - out.push_str(&resp_incomplete( - &self.rid(), - &self.client_model, - output, - self.input_tokens, - self.cached_tokens, - self.output_tokens, - reason, - )); - } else { - out.push_str(&resp_completed( - &self.rid(), - &self.client_model, - output, - self.input_tokens, - self.cached_tokens, - self.output_tokens, - )); - } - out - } - - /// Finalize a clean upstream EOF only after Anthropic reported a stop reason. A normal - /// `message_stop` calls `complete` directly; an EOF before both signals is truncated. - pub fn finish(&mut self) -> String { - if self.stopped { - return String::new(); - } - if self.stop_reason.is_some() { - self.complete() - } else { - self.fail("upstream stream ended before message_stop or a stop reason") - } - } - - fn fail(&mut self, message: &str) -> String { - if self.stopped { - return String::new(); - } - let mut out = String::new(); - self.ensure_created(&mut out); - let id = self.rid(); - out.push_str(&resp_failed(&id, message)); - self.stopped = true; - self.failed = true; - out - } - - pub fn input_tokens(&self) -> i64 { - self.input_tokens - } - pub fn output_tokens(&self) -> i64 { - self.output_tokens - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn transcodes_text_and_split_tool_call() { - let mut tc = ChatToAnthropic::new("claude-x"); - let mut out = String::new(); - // role primer, then text, then a tool call split across two chunks, then finish + usage - out.push_str( - &tc.push("data: {\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\"}}]}"), - ); - out.push_str( - &tc.push("data: {\"choices\":[{\"index\":0,\"delta\":{\"content\":\"Let me \"}}]}"), - ); - out.push_str( - &tc.push("data: {\"choices\":[{\"index\":0,\"delta\":{\"content\":\"check.\"}}]}"), - ); - out.push_str(&tc.push("data: {\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_1\",\"type\":\"function\",\"function\":{\"name\":\"read_file\",\"arguments\":\"{\\\"pa\"},\"extra_content\":{\"google\":{\"thought_signature\":\"sig-stream-abc\"}}}]}}]}")); - out.push_str(&tc.push("data: {\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"th\\\":\\\"a.txt\\\"}\"}}]}}]}")); - out.push_str(&tc.push("data: {\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"tool_calls\"}],\"usage\":{\"prompt_tokens\":20,\"completion_tokens\":9}}")); - out.push_str(&tc.push("data: [DONE]")); - - // ordered events present (serde_json sorts object keys, so assert on substrings, not key order) - assert!(out.contains("event: message_start")); - assert!( - out.find("event: message_start").unwrap() - < out.find("event: content_block_start").unwrap() - ); - assert!( - out.find("event: content_block_start").unwrap() - < out.find("event: message_delta").unwrap() - ); - assert!( - out.find("event: message_delta").unwrap() < out.find("event: message_stop").unwrap() - ); - // text block: a text content_block_start + its two text deltas - assert!(out.contains(r#""type":"text""#)); - assert!(out.contains("text_delta") && out.contains(r#""text":"Let me ""#)); - assert!(out.contains(r#""text":"check.""#)); - // tool block: tool_use start carries id+name; args reassembled across fragments - assert!(out.contains(r#""type":"tool_use""#)); - assert!(out.contains(r#""id":"call_1""#) && out.contains(r#""name":"read_file""#)); - assert!(out.contains("input_json_delta") && out.contains(r#""partial_json":"{\"pa""#)); - assert!(out.contains(r#""partial_json":"th\":\"a.txt\"}""#)); - // closes both blocks (index 0 text, index 1 tool), tool_use stop, usage, terminal stop - assert!(out.contains(r#""index":0,"type":"content_block_stop""#)); - assert!(out.contains(r#""index":1,"type":"content_block_stop""#)); - assert!(out.contains(r#""stop_reason":"tool_use""#)); - assert!(out.contains(r#""output_tokens":9"#)); - assert!(out.contains("event: message_stop")); - assert_eq!(tc.input_tokens(), 20); - let captured = tc.captured_tool_calls(); - assert_eq!(captured.len(), 1); - assert_eq!(captured[0].call_id, "call_1"); - assert_eq!(captured[0].arguments, r#"{"path":"a.txt"}"#); - assert_eq!( - captured[0].thought_signature.as_deref(), - Some("sig-stream-abc") - ); - } - - #[test] - fn keeps_no_index_parallel_calls_with_the_same_id_distinct() { - let mut tc = ChatToAnthropic::new("claude-x"); - let mut out = String::new(); - out.push_str(&tc.push("data: {\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"id\":\"same-call\",\"type\":\"function\",\"function\":{\"name\":\"lookup\",\"arguments\":\"{\\\"query\\\":\\\"same\\\"}\"},\"extra_content\":{\"google\":{\"thought_signature\":\"sig-same-id\"}}},{\"id\":\"same-call\",\"type\":\"function\",\"function\":{\"name\":\"lookup\",\"arguments\":\"{\\\"query\\\":\\\"same\\\"}\"}}]}}]}\n")); - out.push_str(&tc.push( - "data: {\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"tool_calls\"}]}\n", - )); - out.push_str(&tc.push("data: [DONE]\n")); - - let captured = tc.captured_tool_calls(); - assert_eq!(captured.len(), 2); - assert_eq!(captured[0].arguments, r#"{"query":"same"}"#); - assert_eq!(captured[1].arguments, r#"{"query":"same"}"#); - assert_eq!( - captured[0].thought_signature.as_deref(), - Some("sig-same-id") - ); - assert!(captured[1].thought_signature.is_none()); - assert!(out.contains(r#""index":0,"type":"content_block_start""#)); - assert!(out.contains(r#""index":1,"type":"content_block_start""#)); - } - - #[test] - fn plain_text_only() { - let mut tc = ChatToAnthropic::new("claude-x"); - let mut out = String::new(); - out.push_str( - &tc.push("data: {\"choices\":[{\"index\":0,\"delta\":{\"content\":\"hello\"}}]}"), - ); - out.push_str( - &tc.push("data: {\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"stop\"}]}"), - ); - out.push_str(&tc.push("data: [DONE]")); - assert!(out.contains("text_delta") && out.contains(r#""text":"hello""#)); - assert!(out.contains(r#""stop_reason":"end_turn""#)); - assert!(out.contains("event: message_stop")); - } - - // The gateway's abort guard relies on done() flipping as soon as push() emits the terminal - // client event — that is the moment Responses clients (Codex) hang up, before upstream EOF. - #[test] - fn done_flips_on_terminal_event_before_eof() { - let mut tc = Transcoder::new(Wire::Anthropic, Wire::OpenAiResponses, "alias-x").unwrap(); - tc.push("data: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_1\",\"usage\":{\"input_tokens\":3}}}\n"); - tc.push("data: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\"}}\n"); - tc.push("data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"hi\"}}\n"); - assert!(!tc.done()); - let out = tc.push("data: {\"type\":\"message_stop\"}\n"); - assert!(out.contains("response.completed")); - assert!(tc.done()); - - let mut tc = Transcoder::new(Wire::OpenAiChat, Wire::Anthropic, "claude-x").unwrap(); - tc.push("data: {\"choices\":[{\"index\":0,\"delta\":{\"content\":\"hello\"}}]}"); - assert!(!tc.done()); - let out = tc.push("data: [DONE]"); - assert!(out.contains("event: message_stop")); - assert!(tc.done()); - } - - fn response_for_event(output: &str, event: &str) -> Value { - let event_line = format!("event: {}", event); - let frame = output - .split("\n\n") - .find(|frame| frame.lines().next() == Some(event_line.as_str())) - .unwrap_or_else(|| panic!("missing {event} event in {output}")); - let data = frame - .lines() - .find_map(|line| line.strip_prefix("data: ")) - .unwrap(); - serde_json::from_str::(data).unwrap()["response"].clone() - } - - fn response_id_for_event(output: &str, event: &str) -> String { - response_for_event(output, event)["id"] - .as_str() - .unwrap() - .to_string() - } - - #[test] - fn chat_to_responses_response_ids_are_unique_and_stable() { - let mut first = ChatToResponses::new("alias-x"); - let mut second = ChatToResponses::new("alias-x"); - let first_fallback = first.resp_id.clone(); - let second_fallback = second.resp_id.clone(); - assert!(first_fallback.starts_with("resp_ccbud_")); - assert!(second_fallback.starts_with("resp_ccbud_")); - assert_ne!(first_fallback, second_fallback); - - let mut first_out = - first.push(r#"data: {"choices":[{"index":0,"delta":{"role":"assistant"}}]}"#); - first_out.push_str(&first.push( - r#"data: {"id":"chatcmpl-too-late","choices":[{"index":0,"delta":{"content":"hi"}}]}"#, - )); - first_out.push_str(&first.push("data: [DONE]")); - assert_eq!(first.resp_id, first_fallback); - assert_eq!( - response_id_for_event(&first_out, "response.created"), - first_fallback - ); - assert_eq!( - response_id_for_event(&first_out, "response.completed"), - first_fallback - ); - - let second_out = second.push("data: [DONE]"); - assert_eq!( - response_id_for_event(&second_out, "response.created"), - second_fallback - ); - assert_eq!( - response_id_for_event(&second_out, "response.completed"), - second_fallback - ); - - let mut upstream = ChatToResponses::new("alias-x"); - let upstream_fallback = upstream.resp_id.clone(); - let mut upstream_out = upstream.push( - r#"data: {"id":"chatcmpl-early","choices":[{"index":0,"delta":{"role":"assistant"}}]}"#, - ); - upstream_out.push_str(&upstream.push("data: [DONE]")); - assert_ne!(upstream.resp_id, upstream_fallback); - assert_eq!(upstream.resp_id, "resp_chatcmpl-early"); - assert_eq!( - response_id_for_event(&upstream_out, "response.created"), - "resp_chatcmpl-early" - ); - assert_eq!( - response_id_for_event(&upstream_out, "response.completed"), - "resp_chatcmpl-early" - ); - } - - #[test] - fn anthropic_to_responses_response_ids_are_unique_and_stable() { - let mut first = AnthropicToResponses::new("alias-x"); - let mut second = AnthropicToResponses::new("alias-x"); - let first_fallback = first.resp_id.clone(); - let second_fallback = second.resp_id.clone(); - assert!(first_fallback.starts_with("resp_ccbud_")); - assert!(second_fallback.starts_with("resp_ccbud_")); - assert_ne!(first_fallback, second_fallback); - - let mut first_out = first.push( - r#"data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}"#, - ); - first_out.push_str(&first.push( - r#"data: {"type":"message_start","message":{"id":"msg_too_late","usage":{"input_tokens":1}}}"#, - )); - first_out.push_str(&first.push(r#"data: {"type":"message_stop"}"#)); - assert_eq!(first.resp_id, first_fallback); - assert_eq!( - response_id_for_event(&first_out, "response.created"), - first_fallback - ); - assert_eq!( - response_id_for_event(&first_out, "response.completed"), - first_fallback - ); - - let second_out = second.push(r#"data: {"type":"message_stop"}"#); - assert_eq!( - response_id_for_event(&second_out, "response.created"), - second_fallback - ); - assert_eq!( - response_id_for_event(&second_out, "response.completed"), - second_fallback - ); - - let mut upstream = AnthropicToResponses::new("alias-x"); - let upstream_fallback = upstream.resp_id.clone(); - let mut upstream_out = upstream.push( - r#"data: {"type":"message_start","message":{"id":"msg_early","usage":{"input_tokens":1}}}"#, - ); - upstream_out.push_str(&upstream.push(r#"data: {"type":"message_stop"}"#)); - assert_ne!(upstream.resp_id, upstream_fallback); - assert_eq!(upstream.resp_id, "resp_msg_early"); - assert_eq!( - response_id_for_event(&upstream_out, "response.created"), - "resp_msg_early" - ); - assert_eq!( - response_id_for_event(&upstream_out, "response.completed"), - "resp_msg_early" - ); - } - - #[test] - fn streaming_response_item_ids_are_scoped_to_the_response() { - let chat = |upstream_id: &str| { - let mut tc = ChatToResponses::new("alias-x"); - let mut out = tc.push(&format!( - r#"data: {{"id":"{upstream_id}","choices":[{{"index":0,"delta":{{"reasoning_content":"think","content":"answer"}}}}]}}"# - )); - out.push_str(&tc.push("data: [DONE]")); - response_for_event(&out, "response.completed")["output"] - .as_array() - .unwrap() - .iter() - .filter_map(|item| item.get("id").and_then(Value::as_str)) - .map(ToString::to_string) - .collect::>() - }; - let first_chat = chat("chatcmpl-first"); - let second_chat = chat("chatcmpl-second"); - assert_eq!(first_chat.len(), 2); - assert!(first_chat.iter().all(|id| id.contains("chatcmpl-first"))); - assert!(second_chat.iter().all(|id| id.contains("chatcmpl-second"))); - assert!(first_chat.iter().all(|id| !second_chat.contains(id))); - - let anthropic = |message_id: &str| { - let mut tc = AnthropicToResponses::new("alias-x"); - let mut out = tc.push(&format!( - r#"data: {{"type":"message_start","message":{{"id":"{message_id}","usage":{{"input_tokens":1}}}}}}"# - )); - out.push_str(&tc.push( - r#"data: {"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":""}}"#, - )); - out.push_str(&tc.push( - r#"data: {"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"think"}}"#, - )); - out.push_str(&tc.push( - r#"data: {"type":"content_block_start","index":1,"content_block":{"type":"text","text":""}}"#, - )); - out.push_str(&tc.push( - r#"data: {"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":"answer"}}"#, - )); - out.push_str(&tc.push(r#"data: {"type":"message_stop"}"#)); - response_for_event(&out, "response.completed")["output"] - .as_array() - .unwrap() - .iter() - .filter_map(|item| item.get("id").and_then(Value::as_str)) - .map(ToString::to_string) - .collect::>() - }; - let first_anthropic = anthropic("msg-first"); - let second_anthropic = anthropic("msg-second"); - assert_eq!(first_anthropic.len(), 2); - assert!(first_anthropic.iter().all(|id| id.contains("msg-first"))); - assert!(second_anthropic.iter().all(|id| id.contains("msg-second"))); - assert!(first_anthropic - .iter() - .all(|id| !second_anthropic.contains(id))); - } - - #[test] - fn chat_to_responses_text_and_split_tool_call() { - let mut tc = ChatToResponses::new("alias-x"); - let mut out = String::new(); - out.push_str(&tc.push("data: {\"id\":\"chatcmpl-1\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\"}}]}")); - out.push_str( - &tc.push("data: {\"choices\":[{\"index\":0,\"delta\":{\"content\":\"Let me \"}}]}"), - ); - out.push_str( - &tc.push("data: {\"choices\":[{\"index\":0,\"delta\":{\"content\":\"check.\"}}]}"), - ); - out.push_str(&tc.push("data: {\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_1\",\"type\":\"function\",\"function\":{\"name\":\"shell\",\"arguments\":\"{\\\"co\"}}]}}]}")); - out.push_str(&tc.push("data: {\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"mmand\\\":[\\\"ls\\\"]}\"}}]}}]}")); - out.push_str(&tc.push("data: {\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"tool_calls\"}],\"usage\":{\"prompt_tokens\":20,\"completion_tokens\":9,\"prompt_tokens_details\":{\"cached_tokens\":7}}}")); - out.push_str(&tc.push("data: [DONE]")); - - // ordered: created → text deltas → item done events → completed - let created = out.find(r#""type":"response.created""#).unwrap(); - let first_delta = out.find(r#""type":"response.output_text.delta""#).unwrap(); - let item_done = out.find(r#""type":"response.output_item.done""#).unwrap(); - let completed = out.find(r#""type":"response.completed""#).unwrap(); - assert!(created < first_delta && first_delta < item_done && item_done < completed); - // token-by-token text deltas - assert!(out.contains(r#""delta":"Let me ""#)); - assert!(out.contains(r#""delta":"check.""#)); - // Codex materializes items from output_item.done: full text + reassembled arguments - assert!(out.contains(r#""text":"Let me check.""#)); - let client_call_id = response_scoped_call_id("resp_chatcmpl-1", 1); - assert!( - out.contains(&format!(r#""call_id":"{}""#, client_call_id)) - && out.contains(r#""name":"shell""#) - ); - assert!(out.contains(r#""arguments":"{\"command\":[\"ls\"]}""#)); - // completed carries id + usage, incl. the prompt cache detail Codex reports - assert!(out.contains(r#""id":"resp_chatcmpl-1""#)); - assert!(out.contains(r#""input_tokens":20"#) && out.contains(r#""output_tokens":9"#)); - assert!(out.contains(r#""cached_tokens":7"#)); - assert_eq!(tc.input_tokens(), 20); - assert_eq!(tc.output_tokens(), 9); - // captured for the gateway's signature cache, keyed by the call_id Codex echoes back - let captured = tc.captured_tool_calls(); - assert_eq!(captured.len(), 1); - assert_eq!(captured[0].call_id, client_call_id); - assert_eq!(captured[0].arguments, r#"{"command":["ls"]}"#); - // finish is idempotent — [DONE] already closed the stream - assert_eq!(tc.finish(), ""); - } - - // Gemini's OpenAI-compatible stream omits `index` and can repeat ids across parallel calls; - // before the fix every no-index fragment collapsed into slot 0 (one garbled call), so Codex - // never received usable tool calls from a Gemini chat upstream. - #[test] - fn chat_to_responses_keeps_no_index_parallel_calls_distinct() { - let mut tc = ChatToResponses::new("alias-x"); - let mut out = String::new(); - out.push_str(&tc.push("data: {\"id\":\"chatcmpl-2\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"id\":\"same-call\",\"type\":\"function\",\"function\":{\"name\":\"lookup\",\"arguments\":\"{\\\"query\\\":\\\"a\\\"}\"},\"extra_content\":{\"google\":{\"thought_signature\":\"sig-parallel\"}}},{\"id\":\"same-call\",\"type\":\"function\",\"function\":{\"name\":\"lookup\",\"arguments\":\"{\\\"query\\\":\\\"b\\\"}\"}}]}}]}\n")); - out.push_str(&tc.push( - "data: {\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"tool_calls\"}]}\n", - )); - out.push_str(&tc.push("data: [DONE]\n")); - - // two distinct function_call items, each with its own arguments - assert!(out.contains(r#""output_index":0"#) && out.contains(r#""output_index":1"#)); - assert!(out.contains(r#""arguments":"{\"query\":\"a\"}""#)); - assert!(out.contains(r#""arguments":"{\"query\":\"b\"}""#)); - let captured = tc.captured_tool_calls(); - assert_eq!(captured.len(), 2); - assert_ne!(captured[0].call_id, captured[1].call_id); - assert_eq!( - captured[0].call_id, - response_scoped_call_id("resp_chatcmpl-2", 0) - ); - assert_eq!( - captured[1].call_id, - response_scoped_call_id("resp_chatcmpl-2", 1) - ); - assert!(out.contains(&format!(r#""call_id":"{}""#, captured[0].call_id))); - assert!(out.contains(&format!(r#""call_id":"{}""#, captured[1].call_id))); - assert_eq!(captured[0].arguments, r#"{"query":"a"}"#); - assert_eq!(captured[1].arguments, r#"{"query":"b"}"#); - // the Gemini thought signature is captured for the session cache (restore next turn) - assert_eq!( - captured[0].thought_signature.as_deref(), - Some("sig-parallel") - ); - assert!(captured[1].thought_signature.is_none()); - } - - // Some models emit tool-call fragments that never carry a function name; forwarding them - // gives Codex an unexecutable call whose echo the upstream then rejects — drop them instead. - #[test] - fn chat_to_responses_skips_nameless_tool_calls() { - let mut tc = ChatToResponses::new("alias-x"); - let mut out = String::new(); - out.push_str(&tc.push("data: {\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"{}\"}}]}}]}")); - out.push_str(&tc.push( - "data: {\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"tool_calls\"}]}", - )); - out.push_str(&tc.push("data: [DONE]")); - assert!(!out.contains("response.function_call_arguments.done")); - assert!( - out.contains(r#""output":[]"#), - "completed output stays empty: {}", - out - ); - assert!(out.contains(r#""type":"response.completed""#)); - assert!(tc.captured_tool_calls().is_empty()); - } - - #[test] - fn anthropic_to_responses_text_tool_and_thinking() { - let mut tc = AnthropicToResponses::new("alias-x"); - let mut out = String::new(); - out.push_str(&tc.push(r#"data: {"type":"message_start","message":{"id":"msg_9","usage":{"input_tokens":30,"cache_read_input_tokens":12,"cache_creation_input_tokens":0}}}"#)); - out.push_str(&tc.push(r#"data: {"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":""}}"#)); - out.push_str(&tc.push(r#"data: {"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"hmm"}}"#)); - out.push_str(&tc.push(r#"data: {"type":"content_block_stop","index":0}"#)); - out.push_str(&tc.push(r#"data: {"type":"content_block_start","index":1,"content_block":{"type":"text","text":""}}"#)); - out.push_str(&tc.push(r#"data: {"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":"Run"}}"#)); - out.push_str(&tc.push(r#"data: {"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":"ning."}}"#)); - out.push_str(&tc.push(r#"data: {"type":"content_block_stop","index":1}"#)); - out.push_str(&tc.push(r#"data: {"type":"content_block_start","index":2,"content_block":{"type":"tool_use","id":"toolu_1","name":"shell","input":{}}}"#)); - out.push_str(&tc.push(r#"data: {"type":"content_block_delta","index":2,"delta":{"type":"input_json_delta","partial_json":"{\"q\":"}}"#)); - out.push_str(&tc.push(r#"data: {"type":"content_block_delta","index":2,"delta":{"type":"input_json_delta","partial_json":"\"x\"}"}}"#)); - out.push_str(&tc.push(r#"data: {"type":"content_block_stop","index":2}"#)); - out.push_str(&tc.push(r#"data: {"type":"message_delta","delta":{"stop_reason":"tool_use"},"usage":{"output_tokens":11}}"#)); - out.push_str(&tc.push(r#"data: {"type":"message_stop"}"#)); - - let created = out.find(r#""type":"response.created""#).unwrap(); - let completed = out.find(r#""type":"response.completed""#).unwrap(); - assert!(created < completed); - // thinking → reasoning summary deltas + item - assert!( - out.contains(r#""type":"response.reasoning_summary_text.delta""#) - && out.contains(r#""delta":"hmm""#) - ); - assert!(out.contains(r#""type":"summary_text""#)); - // text streams as deltas and closes with the full text - assert!(out.contains(r#""delta":"Run""#) && out.contains(r#""delta":"ning.""#)); - assert!(out.contains(r#""text":"Running.""#)); - // tool_use → function_call item with the reassembled arguments string - assert!( - out.contains(&format!( - r#""call_id":"{}""#, - response_scoped_call_id("resp_msg_9", 2) - )) && out.contains(r#""name":"shell""#) - ); - assert!(out.contains(r#""arguments":"{\"q\":\"x\"}""#)); - // usage: cached reads fold into input_tokens, detail carries them - assert!(out.contains(r#""input_tokens":42"#)); - assert!(out.contains(r#""cached_tokens":12"#)); - assert!(out.contains(r#""output_tokens":11"#)); - assert!(out.contains(r#""id":"resp_msg_9""#)); - assert_eq!(tc.input_tokens(), 42); - assert_eq!(tc.output_tokens(), 11); - // message_stop already completed the stream - assert_eq!(tc.finish(), ""); - } - - #[test] - fn anthropic_to_responses_error_is_terminal() { - let mut tc = AnthropicToResponses::new("alias-x"); - let mut out = String::new(); - out.push_str(&tc.push( - r#"data: {"type":"message_start","message":{"id":"msg_1","usage":{"input_tokens":5}}}"#, - )); - out.push_str(&tc.push( - r#"data: {"type":"error","error":{"type":"overloaded_error","message":"Overloaded"}}"#, - )); - out.push_str(&tc.finish()); - assert!(out.contains(r#""type":"response.failed""#) && out.contains("Overloaded")); - // failed is terminal: no completed after it - assert!(!out.contains(r#""type":"response.completed""#)); - } - - #[test] - fn chat_error_events_are_terminal_for_translated_clients() { - let mut responses = - Transcoder::new(Wire::OpenAiChat, Wire::OpenAiResponses, "alias-x").unwrap(); - let mut responses_out = - responses.push(r#"data: {"choices":[{"index":0,"delta":{"content":"partial"}}]}"#); - responses_out.push_str( - &responses - .push(r#"data: {"error":{"type":"server_error","message":"upstream exploded"}}"#), - ); - responses_out.push_str(&responses.finish()); - assert!(responses_out.contains(r#""type":"response.failed""#)); - assert!(responses_out.contains("upstream exploded")); - assert!(!responses_out.contains(r#""type":"response.completed""#)); - assert!(responses.done()); - assert!(!responses.succeeded()); - - let mut anthropic = Transcoder::new(Wire::OpenAiChat, Wire::Anthropic, "claude-x").unwrap(); - let mut anthropic_out = - anthropic.push(r#"data: {"choices":[{"index":0,"delta":{"content":"partial"}}]}"#); - anthropic_out.push_str( - &anthropic - .push(r#"data: {"error":{"type":"server_error","message":"upstream exploded"}}"#), - ); - anthropic_out.push_str(&anthropic.finish()); - assert!(anthropic_out.contains("event: error")); - assert!(anthropic_out.contains("upstream exploded")); - assert!(!anthropic_out.contains("event: message_stop")); - assert!(anthropic.done()); - assert!(!anthropic.succeeded()); - } - - #[test] - fn transport_failure_cannot_be_finalized_as_success() { - for (provider, client) in [ - (Wire::OpenAiChat, Wire::Anthropic), - (Wire::OpenAiChat, Wire::OpenAiResponses), - (Wire::Anthropic, Wire::OpenAiResponses), - ] { - let mut tc = Transcoder::new(provider, client, "alias-x").unwrap(); - let mut out = tc.fail("upstream stream transport error"); - out.push_str(&tc.finish()); - assert!(tc.done()); - assert!(!tc.succeeded()); - assert!(out.contains("upstream stream transport error")); - assert!(!out.contains("response.completed")); - assert!(!out.contains("event: message_stop")); - } - } - - #[test] - fn premature_clean_eof_fails_but_reported_stop_reasons_can_finalize() { - for (provider, client) in [ - (Wire::OpenAiChat, Wire::Anthropic), - (Wire::OpenAiChat, Wire::OpenAiResponses), - (Wire::Anthropic, Wire::OpenAiResponses), - ] { - let mut tc = Transcoder::new(provider, client, "alias-x").unwrap(); - tc.push(match provider { - Wire::Anthropic => { - r#"data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}"# - } - _ => r#"data: {"choices":[{"index":0,"delta":{"content":"partial"}}]}"#, - }); - let out = tc.finish(); - assert!(tc.done()); - assert!(!tc.succeeded()); - assert!(out.contains("upstream stream ended before")); - assert!(!out.contains("response.completed")); - assert!(!out.contains("event: message_stop")); - } - - let mut chat_responses = - Transcoder::new(Wire::OpenAiChat, Wire::OpenAiResponses, "alias-x").unwrap(); - chat_responses.push( - r#"data: {"choices":[{"index":0,"delta":{"content":"done"},"finish_reason":"stop"}]}"#, - ); - let out = chat_responses.finish(); - assert!(out.contains("response.completed")); - assert!(chat_responses.succeeded()); - - let mut chat_anthropic = - Transcoder::new(Wire::OpenAiChat, Wire::Anthropic, "claude-x").unwrap(); - chat_anthropic.push( - r#"data: {"choices":[{"index":0,"delta":{"content":"done"},"finish_reason":"stop"}]}"#, - ); - let out = chat_anthropic.finish(); - assert!(out.contains("event: message_stop")); - assert!(chat_anthropic.succeeded()); - - let mut anthropic = - Transcoder::new(Wire::Anthropic, Wire::OpenAiResponses, "alias-x").unwrap(); - anthropic.push( - r#"data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":1}}"#, - ); - let out = anthropic.finish(); - assert!(out.contains("response.completed")); - assert!(anthropic.succeeded()); - } - - #[test] - fn max_token_truncation_emits_incomplete_instead_of_completed() { - let mut chat = Transcoder::new(Wire::OpenAiChat, Wire::OpenAiResponses, "alias-x").unwrap(); - let mut chat_out = chat.push( - r#"data: {"choices":[{"index":0,"delta":{"content":"partial"},"finish_reason":"length"}]}"#, - ); - chat_out.push_str(&chat.push("data: [DONE]")); - assert!(chat_out.contains("response.incomplete")); - assert!(chat_out.contains(r#""reason":"max_output_tokens""#)); - assert!(!chat_out.contains("response.completed")); - assert!(chat.done()); - assert!(!chat.succeeded()); - - let mut anthropic = - Transcoder::new(Wire::Anthropic, Wire::OpenAiResponses, "alias-x").unwrap(); - let mut anthropic_out = anthropic.push( - r#"data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}"#, - ); - anthropic_out.push_str(&anthropic.push( - r#"data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"partial"}}"#, - )); - anthropic_out.push_str(&anthropic.push( - r#"data: {"type":"message_delta","delta":{"stop_reason":"max_tokens"},"usage":{"output_tokens":8}}"#, - )); - anthropic_out.push_str(&anthropic.push(r#"data: {"type":"message_stop"}"#)); - assert!(anthropic_out.contains("response.incomplete")); - assert!(anthropic_out.contains(r#""reason":"max_output_tokens""#)); - assert!(!anthropic_out.contains("response.completed")); - assert!(anthropic.done()); - assert!(!anthropic.succeeded()); - } - - fn extended_tool_context() -> CodexToolContext { - CodexToolContext::from_request(&json!({ - "tools": [ - { "type": "custom", "name": "apply_patch", "description": "Apply a patch" }, - { "type": "namespace", "name": "multi_agent_v1", "tools": [ - { "type": "function", "name": "spawn_agent", "description": "Spawn", - "parameters": { "type": "object", "properties": { - "task_name": { "type": "string" } - }, "required": ["task_name"] } } - ] }, - { "type": "tool_search", "execution": "client", - "description": "Search deferred tools.", - "parameters": { "type": "object", "properties": { - "query": { "type": "string" } - }, "required": ["query"] } } - ] - })) - } - - #[test] - fn chat_stream_restores_custom_and_tool_search_calls() { - let mut tc = ChatToResponses::new_with_context("alias-x", extended_tool_context()); - let mut out = String::new(); - out.push_str(&tc.push( - r#"data: {"id":"chatcmpl-tools","choices":[{"index":0,"delta":{"reasoning_content":"choose tools"}}]}"#, - )); - out.push_str(&tc.push( - r#"data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_patch","type":"function","function":{"name":"apply_patch","arguments":"{\"input\":\"*** Begin"}},{"index":1,"id":"call_search","type":"function","function":{"name":"tool_search","arguments":"{\"query\":\"browser\"}"}}]}}]}"#, - )); - out.push_str(&tc.push( - r#"data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":" Patch\"}"}}]},"finish_reason":"tool_calls"}]}"#, - )); - out.push_str(&tc.push("data: [DONE]")); - - assert!(out.contains("event: response.custom_tool_call_input.delta")); - assert!(out.contains("event: response.custom_tool_call_input.done")); - assert!(out.contains(r#""type":"custom_tool_call""#)); - assert!(out.contains(r#""input":"*** Begin Patch""#)); - assert!(out.contains(r#""type":"tool_search_call""#)); - assert!(out.contains(r#""arguments":{"query":"browser"}"#)); - assert!(out.contains(r#""reasoning_content":"choose tools""#)); - assert!(out.contains(r#""type":"response.completed""#)); - } - - #[test] - fn anthropic_stream_restores_namespace_and_custom_calls() { - let mut tc = AnthropicToResponses::new_with_context("alias-x", extended_tool_context()); - let mut out = String::new(); - out.push_str(&tc.push( - r#"data: {"type":"message_start","message":{"id":"msg_tools","usage":{"input_tokens":3}}}"#, - )); - out.push_str(&tc.push( - r#"data: {"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":""}}"#, - )); - out.push_str(&tc.push( - r#"data: {"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"delegate"}}"#, - )); - out.push_str(&tc.push(r#"data: {"type":"content_block_stop","index":0}"#)); - out.push_str(&tc.push( - r#"data: {"type":"content_block_start","index":1,"content_block":{"type":"tool_use","id":"toolu_spawn","name":"multi_agent_v1__spawn_agent","input":{}}}"#, - )); - out.push_str(&tc.push( - r#"data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"{\"task_name\":\"audit\"}"}}"#, - )); - out.push_str(&tc.push(r#"data: {"type":"content_block_stop","index":1}"#)); - out.push_str(&tc.push( - r#"data: {"type":"content_block_start","index":2,"content_block":{"type":"tool_use","id":"toolu_patch","name":"apply_patch","input":{"input":"*** Begin Patch"}}}"#, - )); - out.push_str(&tc.push(r#"data: {"type":"content_block_stop","index":2}"#)); - out.push_str(&tc.push( - r#"data: {"type":"message_delta","delta":{"stop_reason":"tool_use"},"usage":{"output_tokens":5}}"#, - )); - out.push_str(&tc.push(r#"data: {"type":"message_stop"}"#)); - - assert!(out.contains(r#""type":"function_call""#)); - assert!(out.contains(r#""name":"spawn_agent""#)); - assert!(out.contains(r#""namespace":"multi_agent_v1""#)); - assert!(out.contains(r#""reasoning_content":"delegate""#)); - assert!(out.contains("event: response.custom_tool_call_input.delta")); - assert!(out.contains("event: response.custom_tool_call_input.done")); - assert!(out.contains(r#""type":"custom_tool_call""#)); - assert!(out.contains(r#""input":"*** Begin Patch""#)); - assert!(out.contains(r#""type":"response.completed""#)); - } -} diff --git a/src-tauri/src/protocol/stream/ablock.rs b/src-tauri/src/protocol/stream/ablock.rs new file mode 100644 index 0000000..178384d --- /dev/null +++ b/src-tauri/src/protocol/stream/ablock.rs @@ -0,0 +1,119 @@ +// Closing an Anthropic content block into its finished Responses item: argument reconciliation, +// the per-kind `*.done` event sequence, and the item payload itself. + +use super::anthropic_responses::{ABlock, AKind}; +use super::common::ev; +use super::resp_items::{resp_message_item, resp_reasoning_item}; +use super::super::openai_responses::{ + custom_tool_input_from_chat_arguments, CodexToolContext, CodexToolKind, +}; +use serde_json::{json, Value}; + +pub(super) fn ablock_tool_arguments(args: &str, start_args: &str) -> String { + if !args.trim().is_empty() { + args.to_string() + } else if !start_args.trim().is_empty() { + start_args.to_string() + } else { + "{}".to_string() + } +} + +/// The closing event sequence for one finished block (its `*.done` events + `output_item.done`). +pub(super) fn close_ablock_events( + b: &ABlock, + tool_context: &CodexToolContext, + reasoning: Option<&str>, +) -> String { + let mut out = String::new(); + match &b.kind { + AKind::Text { acc } => { + out.push_str(&ev( + "response.output_text.done", + json!({ "type": "response.output_text.done", "item_id": b.id, "output_index": b.index, + "content_index": 0, "text": acc }), + )); + out.push_str(&ev( + "response.content_part.done", + json!({ "type": "response.content_part.done", "item_id": b.id, "output_index": b.index, + "content_index": 0, "part": { "type": "output_text", "annotations": [], "text": acc } }), + )); + out.push_str(&ev( + "response.output_item.done", + json!({ "type": "response.output_item.done", "output_index": b.index, "item": resp_message_item(&b.id, acc) }), + )); + } + AKind::Tool { + call_id, + name, + args, + start_args, + } => { + let arguments = ablock_tool_arguments(args, start_args); + let item = tool_context.response_tool_item_with_reasoning( + &b.id, + "completed", + call_id, + name, + &arguments, + reasoning, + ); + match tool_context.kind_for_chat_name(name) { + CodexToolKind::Custom => { + let input = custom_tool_input_from_chat_arguments(&arguments); + if !input.is_empty() { + out.push_str(&ev( + "response.custom_tool_call_input.delta", + json!({ "type": "response.custom_tool_call_input.delta", "item_id": b.id, + "call_id": call_id, "output_index": b.index, "delta": input }), + )); + } + out.push_str(&ev( + "response.custom_tool_call_input.done", + json!({ "type": "response.custom_tool_call_input.done", "item_id": b.id, + "call_id": call_id, "output_index": b.index, "input": input }), + )); + } + CodexToolKind::Function | CodexToolKind::Namespace | CodexToolKind::ToolSearch => { + out.push_str(&ev( + "response.function_call_arguments.done", + json!({ "type": "response.function_call_arguments.done", "item_id": b.id, + "output_index": b.index, "arguments": arguments }), + )); + } + } + out.push_str(&ev( + "response.output_item.done", + json!({ "type": "response.output_item.done", "output_index": b.index, + "item": item }), + )); + } + AKind::Think { acc } => { + out.push_str(&ev( + "response.output_item.done", + json!({ "type": "response.output_item.done", "output_index": b.index, "item": resp_reasoning_item(&b.id, acc) }), + )); + } + } + out +} + +pub(super) fn ablock_item(b: &ABlock, tool_context: &CodexToolContext, reasoning: Option<&str>) -> Value { + match &b.kind { + AKind::Text { acc } => resp_message_item(&b.id, acc), + AKind::Tool { + call_id, + name, + args, + start_args, + } => tool_context.response_tool_item_with_reasoning( + &b.id, + "completed", + call_id, + name, + &ablock_tool_arguments(args, start_args), + reasoning, + ), + AKind::Think { acc } => resp_reasoning_item(&b.id, acc), + } +} diff --git a/src-tauri/src/protocol/stream/anthropic_responses.rs b/src-tauri/src/protocol/stream/anthropic_responses.rs new file mode 100644 index 0000000..fe3ae57 --- /dev/null +++ b/src-tauri/src/protocol/stream/anthropic_responses.rs @@ -0,0 +1,104 @@ +// Anthropic Messages stream → OpenAI Responses stream: transcoder state and construction (Codex +// client, Anthropic upstream). The event loop lives in anthropic_responses_push.rs. + +use super::common::ev; +use super::super::openai_responses::CodexToolContext; +use serde_json::json; + +/// Stateful Anthropic-Messages-stream → OpenAI-Responses-stream transcoder (Codex client, +/// Anthropic upstream). Anthropic blocks map 1:1 onto Responses output items: text → +/// message/output_text, tool_use → function_call (input_json_delta fragments accumulate into the +/// arguments string), thinking → reasoning summary. Upstream `error` events surface as +/// `response.failed` so Codex aborts cleanly instead of timing out. +pub struct AnthropicToResponses { + pub(super) client_model: String, + pub(super) tool_context: CodexToolContext, + // Construction-time fallback. An upstream id may replace it only until response.created is + // emitted; afterward this id is immutable so every event in the response agrees. + pub(super) resp_id: String, + pub(super) created: bool, + pub(super) next_index: usize, + pub(super) blocks: Vec, + pub(super) input_tokens: i64, + pub(super) cached_tokens: i64, + pub(super) output_tokens: i64, + pub(super) stop_reason: Option, + pub(super) stopped: bool, + pub(super) failed: bool, +} + +pub(super) struct ABlock { + pub(super) a_index: u64, + pub(super) index: usize, + pub(super) id: String, + pub(super) kind: AKind, + pub(super) open: bool, +} + +pub(super) enum AKind { + Text { + acc: String, + }, + Tool { + call_id: String, + name: String, + args: String, + start_args: String, + }, + Think { + acc: String, + }, +} + +impl AnthropicToResponses { + pub fn new(client_model: &str) -> Self { + Self::new_with_context(client_model, CodexToolContext::default()) + } + + pub fn new_with_context(client_model: &str, tool_context: CodexToolContext) -> Self { + Self { + client_model: client_model.to_string(), + tool_context, + resp_id: super::super::uid("resp_ccbud"), + created: false, + next_index: 0, + blocks: vec![], + input_tokens: 0, + cached_tokens: 0, + output_tokens: 0, + stop_reason: None, + stopped: false, + failed: false, + } + } + + pub(super) fn rid(&self) -> String { + self.resp_id.clone() + } + + pub(super) fn reasoning_text(&self) -> Option { + let text = self + .blocks + .iter() + .filter_map(|block| match &block.kind { + AKind::Think { acc } if !acc.trim().is_empty() => Some(acc.as_str()), + _ => None, + }) + .collect::>() + .join("\n\n"); + (!text.is_empty()).then_some(text) + } + + pub(super) fn ensure_created(&mut self, out: &mut String) { + if self.created { + return; + } + self.created = true; + let id = self.rid(); + out.push_str(&ev( + "response.created", + json!({ "type": "response.created", + "response": { "id": id, "object": "response", "status": "in_progress", "model": self.client_model } }), + )); + } +} diff --git a/src-tauri/src/protocol/stream/anthropic_responses_block.rs b/src-tauri/src/protocol/stream/anthropic_responses_block.rs new file mode 100644 index 0000000..40413ce --- /dev/null +++ b/src-tauri/src/protocol/stream/anthropic_responses_block.rs @@ -0,0 +1,108 @@ +// The `content_block_start` arm of AnthropicToResponses::push — lifted verbatim out of that +// function so no single file exceeds the module's size budget; called exactly once, from `push`. + +use super::anthropic_responses::{ABlock, AKind, AnthropicToResponses}; +use super::common::ev; +use super::resp_items::response_scoped_item_id; +use super::super::openai_responses::response_scoped_call_id; +use serde_json::{json, Value}; + +impl AnthropicToResponses { + pub(super) fn push_content_block_start(&mut self, evt: &Value, mut out: &mut String) { + self.ensure_created(&mut out); + let a_index = evt.get("index").and_then(|v| v.as_u64()).unwrap_or(0); + let cb = evt.get("content_block").cloned().unwrap_or(Value::Null); + let index = self.next_index; + match cb.get("type").and_then(|v| v.as_str()) { + Some("text") => { + self.next_index += 1; + let id = response_scoped_item_id("msg", &self.rid(), index); + out.push_str(&ev( + "response.output_item.added", + json!({ "type": "response.output_item.added", "output_index": index, + "item": { "type": "message", "id": id, "status": "in_progress", "role": "assistant", "content": [] } }), + )); + out.push_str(&ev( + "response.content_part.added", + json!({ "type": "response.content_part.added", "item_id": id, "output_index": index, + "content_index": 0, "part": { "type": "output_text", "annotations": [], "text": "" } }), + )); + self.blocks.push(ABlock { + a_index, + index, + id, + kind: AKind::Text { acc: String::new() }, + open: true, + }); + } + Some("tool_use") => { + self.next_index += 1; + let call_id = response_scoped_call_id(&self.rid(), index); + let name = cb + .get("name") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let id = self + .tool_context + .response_item_id(&name, &self.rid(), index); + let start_args = cb + .get("input") + .filter(|value| { + value.as_object().is_some_and(|object| !object.is_empty()) + }) + .map(Value::to_string) + .unwrap_or_default(); + let reasoning = self.reasoning_text(); + let item = self.tool_context.response_tool_item_with_reasoning( + &id, + "in_progress", + &call_id, + &name, + "", + reasoning.as_deref(), + ); + let mut item = item; + if item.get("type").and_then(Value::as_str) == Some("function_call") { + item["arguments"] = json!(""); + } + out.push_str(&ev( + "response.output_item.added", + json!({ "type": "response.output_item.added", "output_index": index, + "item": item }), + )); + self.blocks.push(ABlock { + a_index, + index, + id, + kind: AKind::Tool { + call_id, + name, + args: String::new(), + start_args, + }, + open: true, + }); + } + Some("thinking") => { + self.next_index += 1; + let id = response_scoped_item_id("rs", &self.rid(), index); + out.push_str(&ev( + "response.output_item.added", + json!({ "type": "response.output_item.added", "output_index": index, + "item": { "type": "reasoning", "id": id, "summary": [] } }), + )); + self.blocks.push(ABlock { + a_index, + index, + id, + kind: AKind::Think { acc: String::new() }, + open: true, + }); + } + // redacted_thinking / server_tool_use / … have no Responses equivalent; their + // deltas find no block below and drop. + _ => {} + } + } +} diff --git a/src-tauri/src/protocol/stream/anthropic_responses_finish.rs b/src-tauri/src/protocol/stream/anthropic_responses_finish.rs new file mode 100644 index 0000000..aefc03c --- /dev/null +++ b/src-tauri/src/protocol/stream/anthropic_responses_finish.rs @@ -0,0 +1,91 @@ +// Terminal handling for AnthropicToResponses: closing still-open blocks and emitting the +// completed / incomplete / failed Responses event. + +use super::ablock::{ablock_item, close_ablock_events}; +use super::anthropic_responses::AnthropicToResponses; +use super::resp_items::{incomplete_reason, resp_completed, resp_failed, resp_incomplete}; +use serde_json::Value; + +impl AnthropicToResponses { + /// Close any still-open blocks and emit the appropriate terminal Responses event. + pub(super) fn complete(&mut self) -> String { + if self.stopped { + return String::new(); + } + self.stopped = true; + let mut out = String::new(); + self.ensure_created(&mut out); + let reasoning = self.reasoning_text(); + self.blocks.sort_by_key(|b| b.index); + for b in &mut self.blocks { + if b.open { + b.open = false; + out.push_str(&close_ablock_events( + b, + &self.tool_context, + reasoning.as_deref(), + )); + } + } + let output: Vec = self + .blocks + .iter() + .map(|block| ablock_item(block, &self.tool_context, reasoning.as_deref())) + .collect(); + if let Some(reason) = incomplete_reason(self.stop_reason.as_deref()) { + self.failed = true; + out.push_str(&resp_incomplete( + &self.rid(), + &self.client_model, + output, + self.input_tokens, + self.cached_tokens, + self.output_tokens, + reason, + )); + } else { + out.push_str(&resp_completed( + &self.rid(), + &self.client_model, + output, + self.input_tokens, + self.cached_tokens, + self.output_tokens, + )); + } + out + } + + /// Finalize a clean upstream EOF only after Anthropic reported a stop reason. A normal + /// `message_stop` calls `complete` directly; an EOF before both signals is truncated. + pub fn finish(&mut self) -> String { + if self.stopped { + return String::new(); + } + if self.stop_reason.is_some() { + self.complete() + } else { + self.fail("upstream stream ended before message_stop or a stop reason") + } + } + + pub(super) fn fail(&mut self, message: &str) -> String { + if self.stopped { + return String::new(); + } + let mut out = String::new(); + self.ensure_created(&mut out); + let id = self.rid(); + out.push_str(&resp_failed(&id, message)); + self.stopped = true; + self.failed = true; + out + } + + pub fn input_tokens(&self) -> i64 { + self.input_tokens + } + pub fn output_tokens(&self) -> i64 { + self.output_tokens + } +} diff --git a/src-tauri/src/protocol/stream/anthropic_responses_push.rs b/src-tauri/src/protocol/stream/anthropic_responses_push.rs new file mode 100644 index 0000000..4dbd336 --- /dev/null +++ b/src-tauri/src/protocol/stream/anthropic_responses_push.rs @@ -0,0 +1,167 @@ +// The Anthropic-event loop of AnthropicToResponses. The `content_block_start` arm lives in +// anthropic_responses_block.rs. + +use super::ablock::close_ablock_events; +use super::anthropic_responses::{AKind, AnthropicToResponses}; +use super::common::ev; +use super::super::openai_responses::CodexToolKind; +use serde_json::{json, Value}; + +impl AnthropicToResponses { + /// Feed one raw upstream SSE line. Anthropic streams interleave `event:` and `data:` lines; + /// the data JSON's `type` mirrors the event name, so data lines alone drive the state machine. + pub fn push(&mut self, line: &str) -> String { + let mut out = String::new(); + if self.stopped { + return out; + } + let t = line.trim(); + let payload = match t.strip_prefix("data:") { + Some(p) => p.trim(), + None => return out, + }; + if payload.is_empty() { + return out; + } + let evt: Value = match serde_json::from_str(payload) { + Ok(v) => v, + Err(_) => return out, + }; + match evt.get("type").and_then(|v| v.as_str()) { + Some("message_start") => { + if let Some(m) = evt.get("message") { + if !self.created { + if let Some(id) = m + .get("id") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + { + self.resp_id = format!("resp_{}", id); + } + } + if let Some(u) = m.get("usage") { + // Responses-style input_tokens includes cached reads; Anthropic reports + // them separately. + let base = u.get("input_tokens").and_then(|v| v.as_i64()).unwrap_or(0); + let cr = u + .get("cache_read_input_tokens") + .and_then(|v| v.as_i64()) + .unwrap_or(0); + let cc = u + .get("cache_creation_input_tokens") + .and_then(|v| v.as_i64()) + .unwrap_or(0); + self.input_tokens = base + cr + cc; + self.cached_tokens = cr; + } + } + self.ensure_created(&mut out); + } + Some("content_block_start") => self.push_content_block_start(&evt, &mut out), + Some("content_block_delta") => { + let a_index = evt.get("index").and_then(|v| v.as_u64()).unwrap_or(0); + let delta = evt.get("delta").cloned().unwrap_or(Value::Null); + if let Some(b) = self + .blocks + .iter_mut() + .find(|b| b.a_index == a_index && b.open) + { + match (&mut b.kind, delta.get("type").and_then(|v| v.as_str())) { + (AKind::Text { acc }, Some("text_delta")) => { + if let Some(txt) = delta + .get("text") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + { + acc.push_str(txt); + out.push_str(&ev( + "response.output_text.delta", + json!({ "type": "response.output_text.delta", "item_id": b.id, + "output_index": b.index, "content_index": 0, "delta": txt }), + )); + } + } + (AKind::Tool { name, args, .. }, Some("input_json_delta")) => { + if let Some(pj) = delta + .get("partial_json") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + { + args.push_str(pj); + if self.tool_context.kind_for_chat_name(name) + != CodexToolKind::Custom + { + out.push_str(&ev( + "response.function_call_arguments.delta", + json!({ "type": "response.function_call_arguments.delta", "item_id": b.id, + "output_index": b.index, "delta": pj }), + )); + } + } + } + (AKind::Think { acc }, Some("thinking_delta")) => { + if let Some(th) = delta + .get("thinking") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + { + acc.push_str(th); + out.push_str(&ev( + "response.reasoning_summary_text.delta", + json!({ "type": "response.reasoning_summary_text.delta", "item_id": b.id, + "output_index": b.index, "summary_index": 0, "delta": th }), + )); + } + } + _ => {} // signature_delta etc. + } + } + } + Some("content_block_stop") => { + let a_index = evt.get("index").and_then(|v| v.as_u64()).unwrap_or(0); + let reasoning = self.reasoning_text(); + if let Some(b) = self + .blocks + .iter_mut() + .find(|b| b.a_index == a_index && b.open) + { + b.open = false; + out.push_str(&close_ablock_events( + b, + &self.tool_context, + reasoning.as_deref(), + )); + } + } + Some("message_delta") => { + if let Some(reason) = evt + .get("delta") + .and_then(|delta| delta.get("stop_reason")) + .and_then(Value::as_str) + { + self.stop_reason = Some(reason.to_string()); + } + if let Some(o) = evt + .get("usage") + .and_then(|u| u.get("output_tokens")) + .and_then(|v| v.as_i64()) + { + self.output_tokens = o; + } + } + Some("message_stop") => { + out.push_str(&self.complete()); + } + Some("error") => { + let msg = evt + .get("error") + .and_then(|e| e.get("message")) + .and_then(|v| v.as_str()) + .unwrap_or("upstream error"); + out.push_str(&self.fail(msg)); + } + _ => {} // ping etc. + } + out + } +} diff --git a/src-tauri/src/protocol/stream/chat_anthropic.rs b/src-tauri/src/protocol/stream/chat_anthropic.rs new file mode 100644 index 0000000..7e4bb92 --- /dev/null +++ b/src-tauri/src/protocol/stream/chat_anthropic.rs @@ -0,0 +1,167 @@ +// OpenAI Chat stream → Anthropic Messages stream: transcoder state and the block/lifecycle +// bookkeeping around the event loop in chat_anthropic_push.rs. + +use super::common::{ev, map_stop, CapturedToolCall}; +use serde_json::{json, Value}; + +/// Stateful OpenAI-Chat-stream → Anthropic-stream transcoder. Feed each raw upstream SSE line to +/// `push`; call `finish` at end. Anthropic requires an ordered `message_start`, then content blocks +/// (each `content_block_start`/`_delta`/`_stop`), then `message_delta` + `message_stop`. We open a +/// text block on the first text delta and one tool_use block per OpenAI tool_call index, assigning +/// Anthropic block indices in first-appearance order. +pub struct ChatToAnthropic { + pub(super) client_model: String, + pub(super) started: bool, + // message id sent in message_start — from the upstream chunk id when it has one, else a + // generated unique id. Clients persist this id; it must never repeat across turns (usage + // analytics de-dupes assistant messages by id). + pub(super) msg_id: Option, + pub(super) next_index: usize, + // text block + pub(super) text_index: Option, + // openai tool_call index → (anthropic block index, open?) + pub(super) tools: Vec, + pub(super) input_tokens: i64, + pub(super) output_tokens: i64, + pub(super) finish_reason: Option, + pub(super) stopped: bool, + pub(super) failed: bool, +} + +pub(super) struct ToolSlot { + pub(super) oa_index: u64, + pub(super) an_index: usize, + pub(super) open: bool, + pub(super) id: String, + pub(super) name: String, + pub(super) thought_signature: Option, + pub(super) arguments: String, +} + +impl ChatToAnthropic { + pub fn new(client_model: &str) -> Self { + Self { + client_model: client_model.to_string(), + started: false, + msg_id: None, + next_index: 0, + text_index: None, + tools: vec![], + input_tokens: 0, + output_tokens: 0, + finish_reason: None, + stopped: false, + failed: false, + } + } + + pub(super) fn ensure_started(&mut self, out: &mut String) { + if self.started { + return; + } + self.started = true; + let id = self + .msg_id + .get_or_insert_with(|| super::super::uid("msg_ccbud")) + .clone(); + out.push_str(&ev( + "message_start", + json!({ "type": "message_start", "message": { + "id": id, "type": "message", "role": "assistant", "model": self.client_model, + "content": [], "stop_reason": Value::Null, "stop_sequence": Value::Null, + "usage": { "input_tokens": self.input_tokens.max(0), "output_tokens": 0 }, + }}), + )); + } + + pub(super) fn open_text(&mut self, out: &mut String) -> usize { + if let Some(i) = self.text_index { + return i; + } + let idx = self.next_index; + self.next_index += 1; + self.text_index = Some(idx); + out.push_str(&ev("content_block_start", json!({ "type": "content_block_start", "index": idx, "content_block": { "type": "text", "text": "" } }))); + idx + } + + pub(super) fn captured_tool_calls(&self) -> Vec { + self.tools + .iter() + .map(|slot| CapturedToolCall { + call_id: slot.id.clone(), + name: slot.name.clone(), + arguments: slot.arguments.clone(), + thought_signature: slot.thought_signature.clone(), + }) + .collect() + } + + /// Close any open blocks and emit message_delta + message_stop. Idempotent. + pub(super) fn complete(&mut self) -> String { + if self.stopped { + return String::new(); + } + self.stopped = true; + let mut out = String::new(); + self.ensure_started(&mut out); + // close blocks in ascending Anthropic index order + let mut closes: Vec = vec![]; + if let Some(i) = self.text_index { + closes.push(i); + } + for s in &self.tools { + if s.open { + closes.push(s.an_index); + } + } + closes.sort_unstable(); + for i in closes { + out.push_str(&ev( + "content_block_stop", + json!({ "type": "content_block_stop", "index": i }), + )); + } + let had_tool = self.tools.iter().any(|s| s.open); + out.push_str(&ev( + "message_delta", + json!({ "type": "message_delta", + "delta": { "stop_reason": map_stop(self.finish_reason.as_deref(), had_tool), "stop_sequence": Value::Null }, + "usage": { "output_tokens": self.output_tokens.max(0) } }), + )); + out.push_str(&ev("message_stop", json!({ "type": "message_stop" }))); + out + } + + /// Finalize a clean upstream EOF only when a Chat finish reason was observed. `[DONE]` calls + /// `complete` directly; an EOF without either signal is a truncated stream. + pub fn finish(&mut self) -> String { + if self.stopped { + return String::new(); + } + if self.finish_reason.is_some() { + self.complete() + } else { + self.fail("upstream stream ended before [DONE] or a finish reason") + } + } + + pub(super) fn fail(&mut self, message: &str) -> String { + if self.stopped { + return String::new(); + } + self.stopped = true; + self.failed = true; + ev( + "error", + json!({ "type": "error", "error": { "type": "api_error", "message": message } }), + ) + } + + pub fn input_tokens(&self) -> i64 { + self.input_tokens + } + pub fn output_tokens(&self) -> i64 { + self.output_tokens + } +} diff --git a/src-tauri/src/protocol/stream/chat_anthropic_push.rs b/src-tauri/src/protocol/stream/chat_anthropic_push.rs new file mode 100644 index 0000000..8af39b4 --- /dev/null +++ b/src-tauri/src/protocol/stream/chat_anthropic_push.rs @@ -0,0 +1,192 @@ +// The OpenAI-Chat-chunk event loop of ChatToAnthropic: one upstream SSE line in, Anthropic +// content-block events out. + +use super::chat_anthropic::{ChatToAnthropic, ToolSlot}; +use super::common::{ev, upstream_error_message}; +use serde_json::{json, Value}; + +impl ChatToAnthropic { + /// Feed one raw upstream SSE line (e.g. "data: {...}\n" or "data: [DONE]\n"). Returns the + /// Anthropic SSE text to forward (possibly empty). + pub fn push(&mut self, line: &str) -> String { + let mut out = String::new(); + if self.stopped { + return out; + } + let t = line.trim(); + let payload = match t.strip_prefix("data:") { + Some(p) => p.trim(), + None => return out, // ignore "event:" lines / blanks; chat SSE carries data-only + }; + if payload.is_empty() { + return out; + } + if payload == "[DONE]" { + out.push_str(&self.complete()); + return out; + } + let chunk: Value = match serde_json::from_str(payload) { + Ok(v) => v, + Err(_) => return out, + }; + if let Some(message) = upstream_error_message(&chunk) { + return self.fail(message); + } + if self.msg_id.is_none() { + if let Some(id) = chunk + .get("id") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + { + self.msg_id = Some(format!("msg_{}", id)); + } + } + // usage may ride the final chunk (stream_options.include_usage) + if let Some(u) = chunk.get("usage").filter(|u| !u.is_null()) { + self.input_tokens = u + .get("prompt_tokens") + .and_then(|v| v.as_i64()) + .unwrap_or(self.input_tokens); + self.output_tokens = u + .get("completion_tokens") + .and_then(|v| v.as_i64()) + .unwrap_or(self.output_tokens); + } + let choice = chunk + .get("choices") + .and_then(|c| c.as_array()) + .and_then(|a| a.first()); + let choice = match choice { + Some(c) => c, + None => return out, + }; + self.ensure_started(&mut out); + let delta = choice.get("delta").cloned().unwrap_or(Value::Null); + + // text delta + if let Some(txt) = delta.get("content").and_then(|v| v.as_str()) { + if !txt.is_empty() { + let idx = self.open_text(&mut out); + out.push_str(&ev("content_block_delta", json!({ "type": "content_block_delta", "index": idx, "delta": { "type": "text_delta", "text": txt } }))); + } + } + + // tool_call deltas (streamed in fragments, keyed by their OpenAI index) + if let Some(tcs) = delta.get("tool_calls").and_then(|v| v.as_array()) { + // A no-index Gemini chunk can contain multiple parallel calls. Even if a provider + // repeats the same id, each array item in this delta must claim a distinct slot. + let mut claimed_slots: Vec = vec![]; + for (fallback_index, tc) in tcs.iter().enumerate() { + let explicit_index = tc.get("index").and_then(|v| v.as_u64()); + let oa_index = explicit_index.unwrap_or(fallback_index as u64); + let name = tc + .get("function") + .and_then(|f| f.get("name")) + .and_then(|v| v.as_str()) + .unwrap_or(""); + let id = tc.get("id").and_then(|v| v.as_str()).unwrap_or(""); + let args = tc + .get("function") + .and_then(|f| f.get("arguments")) + .and_then(|v| v.as_str()) + .unwrap_or(""); + let thought_signature = super::super::json_thought_signature(tc); + + // Standard OpenAI chunks carry `index`; Gemini-compatible streams may omit it. + // In that case prefer the stable call id, then the call's position in this delta. + let pos = explicit_index + .and_then(|_| { + self.tools + .iter() + .enumerate() + .find(|(index, slot)| { + slot.oa_index == oa_index && !claimed_slots.contains(index) + }) + .map(|(index, _)| index) + }) + .or_else(|| { + (!id.is_empty()) + .then(|| { + self.tools + .iter() + .enumerate() + .find(|(index, slot)| { + slot.id == id && !claimed_slots.contains(index) + }) + .map(|(index, _)| index) + }) + .flatten() + }) + .or_else(|| { + self.tools + .iter() + .enumerate() + .find(|(index, slot)| { + slot.oa_index == oa_index && !claimed_slots.contains(index) + }) + .map(|(index, _)| index) + }); + let slot_idx = match pos { + Some(i) => i, + None => { + let an_index = self.next_index; + self.next_index += 1; + self.tools.push(ToolSlot { + oa_index, + an_index, + open: false, + id: String::new(), + name: String::new(), + thought_signature: None, + arguments: String::new(), + }); + self.tools.len() - 1 + } + }; + claimed_slots.push(slot_idx); + let (an_index, should_open, open_id, open_name) = { + let slot = &mut self.tools[slot_idx]; + if !id.is_empty() { + slot.id = id.to_string(); + } + if !name.is_empty() { + slot.name = name.to_string(); + } + if thought_signature.is_some() { + slot.thought_signature = thought_signature; + } + if !args.is_empty() { + slot.arguments.push_str(args); + } + let should_open = !slot.open; + if should_open { + slot.open = true; + } + ( + slot.an_index, + should_open, + slot.id.clone(), + slot.name.clone(), + ) + }; + if should_open { + out.push_str(&ev( + "content_block_start", + json!({ "type": "content_block_start", + "index": an_index, "content_block": { "type": "tool_use", + "id": open_id, "name": open_name, "input": {} } }), + )); + } + if !args.is_empty() { + out.push_str(&ev("content_block_delta", json!({ "type": "content_block_delta", + "index": an_index, "delta": { "type": "input_json_delta", "partial_json": args } }))); + } + } + } + + if let Some(fr) = choice.get("finish_reason").and_then(|v| v.as_str()) { + self.finish_reason = Some(fr.to_string()); + } + out + } +} diff --git a/src-tauri/src/protocol/stream/chat_responses.rs b/src-tauri/src/protocol/stream/chat_responses.rs new file mode 100644 index 0000000..c6ddbd3 --- /dev/null +++ b/src-tauri/src/protocol/stream/chat_responses.rs @@ -0,0 +1,97 @@ +// OpenAI Chat stream → OpenAI Responses stream: transcoder state and construction (Codex client, +// chat upstream). The event loop lives in chat_responses_push.rs. + +use super::common::CapturedToolCall; +use super::super::openai_responses::CodexToolContext; + +/// Stateful OpenAI-Chat-stream → OpenAI-Responses-stream transcoder (Codex client, chat upstream). +/// Text deltas stream through as `response.output_text.delta`; provider reasoning deltas +/// (`reasoning_content` / `reasoning`) as `response.reasoning_summary_text.delta`; tool-call +/// fragments accumulate per OpenAI index (with the same no-index Gemini slot handling as +/// ChatToAnthropic, including thought-signature capture) and surface whole in +/// `response.output_item.done` — the only place Codex materializes items from. +pub struct ChatToResponses { + pub(super) client_model: String, + pub(super) tool_context: CodexToolContext, + // Construction-time fallback. An upstream id may replace it only until response.created is + // emitted; afterward this id is immutable so every event in the response agrees. + pub(super) resp_id: String, + pub(super) created: bool, + pub(super) next_index: usize, + pub(super) reasoning: Option, + pub(super) reasoning_open: bool, + pub(super) message: Option, + pub(super) tools: Vec, + pub(super) input_tokens: i64, + pub(super) cached_tokens: i64, + pub(super) output_tokens: i64, + pub(super) finish_reason: Option, + pub(super) stopped: bool, + pub(super) failed: bool, +} + +pub(super) struct TextItemAcc { + pub(super) index: usize, + pub(super) id: String, + pub(super) acc: String, +} + +pub(super) struct RespToolAcc { + pub(super) oa_index: u64, + pub(super) index: usize, + pub(super) id: String, + pub(super) upstream_call_id: String, + pub(super) call_id: String, + pub(super) name: String, + pub(super) args: String, + pub(super) thought_signature: Option, + pub(super) announced: bool, + pub(super) emitted_args_len: usize, +} + +impl ChatToResponses { + pub fn new(client_model: &str) -> Self { + Self::new_with_context(client_model, CodexToolContext::default()) + } + + pub fn new_with_context(client_model: &str, tool_context: CodexToolContext) -> Self { + Self { + client_model: client_model.to_string(), + tool_context, + resp_id: super::super::uid("resp_ccbud"), + created: false, + next_index: 0, + reasoning: None, + reasoning_open: false, + message: None, + tools: vec![], + input_tokens: 0, + cached_tokens: 0, + output_tokens: 0, + finish_reason: None, + stopped: false, + failed: false, + } + } + + pub(super) fn rid(&self) -> String { + self.resp_id.clone() + } + + /// The turn's tool calls (with any Gemini thought signatures sniffed from the chat stream), + /// keyed by the call_id the Responses client will echo back — feeds the gateway's + /// session-scoped signature cache exactly like ChatToAnthropic. Nameless slots are excluded, + /// matching what finish() emits (and therefore what the client can echo). + pub fn captured_tool_calls(&self) -> Vec { + self.tools + .iter() + .filter(|slot| !slot.name.is_empty()) + .map(|slot| CapturedToolCall { + call_id: slot.call_id.clone(), + name: slot.name.clone(), + arguments: slot.args.clone(), + thought_signature: slot.thought_signature.clone(), + }) + .collect() + } +} diff --git a/src-tauri/src/protocol/stream/chat_responses_finish.rs b/src-tauri/src/protocol/stream/chat_responses_finish.rs new file mode 100644 index 0000000..503f6d8 --- /dev/null +++ b/src-tauri/src/protocol/stream/chat_responses_finish.rs @@ -0,0 +1,135 @@ +// Terminal handling for ChatToResponses: closing open items and emitting the completed / +// incomplete / failed Responses event. + +use super::chat_responses::ChatToResponses; +use super::common::ev; +use super::resp_items::{ + incomplete_reason, normalized_tool_arguments, resp_completed, resp_failed, resp_incomplete, + resp_message_item, resp_reasoning_item, +}; +use serde_json::{json, Value}; + +impl ChatToResponses { + /// Close open items in index order and emit the appropriate terminal Responses event. + pub(super) fn complete(&mut self) -> String { + if self.stopped { + return String::new(); + } + self.stopped = true; + let mut out = String::new(); + self.ensure_created(&mut out); + self.close_reasoning(&mut out); + if let Some(m) = &self.message { + out.push_str(&ev( + "response.output_text.done", + json!({ "type": "response.output_text.done", "item_id": m.id, "output_index": m.index, + "content_index": 0, "text": m.acc }), + )); + out.push_str(&ev( + "response.content_part.done", + json!({ "type": "response.content_part.done", "item_id": m.id, "output_index": m.index, + "content_index": 0, "part": { "type": "output_text", "annotations": [], "text": m.acc } }), + )); + out.push_str(&ev( + "response.output_item.done", + json!({ "type": "response.output_item.done", "output_index": m.index, + "item": resp_message_item(&m.id, &m.acc) }), + )); + } + for pos in 0..self.tools.len() { + self.announce_tool_if_ready(pos, &mut out); + } + // A slot whose name never arrived is model garbage the client cannot execute — and a + // nameless function_call echoed into the next request is rejected upstream. Skip it. + for slot in self + .tools + .iter() + .filter(|slot| slot.announced && !slot.name.is_empty()) + { + out.push_str(&self.close_tool_events(slot)); + } + let mut items: Vec<(usize, Value)> = vec![]; + if let Some(r) = &self.reasoning { + items.push((r.index, resp_reasoning_item(&r.id, &r.acc))); + } + if let Some(m) = &self.message { + items.push((m.index, resp_message_item(&m.id, &m.acc))); + } + for slot in self + .tools + .iter() + .filter(|slot| slot.announced && !slot.name.is_empty()) + { + items.push(( + slot.index, + self.tool_context.response_tool_item_with_reasoning( + &slot.id, + "completed", + &slot.call_id, + &slot.name, + normalized_tool_arguments(&slot.args), + self.reasoning + .as_ref() + .map(|reasoning| reasoning.acc.as_str()), + ), + )); + } + items.sort_by_key(|(i, _)| *i); + let output: Vec = items.into_iter().map(|(_, v)| v).collect(); + if let Some(reason) = incomplete_reason(self.finish_reason.as_deref()) { + self.failed = true; + out.push_str(&resp_incomplete( + &self.rid(), + &self.client_model, + output, + self.input_tokens, + self.cached_tokens, + self.output_tokens, + reason, + )); + } else { + out.push_str(&resp_completed( + &self.rid(), + &self.client_model, + output, + self.input_tokens, + self.cached_tokens, + self.output_tokens, + )); + } + out + } + + /// Finalize a clean upstream EOF only when a Chat finish reason was observed. `[DONE]` calls + /// `complete` directly; an EOF without either signal is a truncated stream. + pub fn finish(&mut self) -> String { + if self.stopped { + return String::new(); + } + if self.finish_reason.is_some() { + self.complete() + } else { + self.fail("upstream stream ended before [DONE] or a finish reason") + } + } + + pub(super) fn fail(&mut self, message: &str) -> String { + if self.stopped { + return String::new(); + } + let mut out = String::new(); + self.ensure_created(&mut out); + let id = self.rid(); + out.push_str(&resp_failed(&id, message)); + self.stopped = true; + self.failed = true; + out + } + + pub fn input_tokens(&self) -> i64 { + self.input_tokens + } + pub fn output_tokens(&self) -> i64 { + self.output_tokens + } +} diff --git a/src-tauri/src/protocol/stream/chat_responses_items.rs b/src-tauri/src/protocol/stream/chat_responses_items.rs new file mode 100644 index 0000000..3e221b8 --- /dev/null +++ b/src-tauri/src/protocol/stream/chat_responses_items.rs @@ -0,0 +1,135 @@ +// Responses item bookkeeping for ChatToResponses: opening the response, closing the reasoning +// item, and announcing / streaming / closing each tool call item. + +use super::chat_responses::{ChatToResponses, RespToolAcc}; +use super::common::ev; +use super::resp_items::{ + normalized_tool_arguments, resp_in_progress_tool_item, resp_reasoning_item, +}; +use super::super::openai_responses::{custom_tool_input_from_chat_arguments, CodexToolKind}; +use serde_json::json; + +impl ChatToResponses { + pub(super) fn ensure_created(&mut self, out: &mut String) { + if self.created { + return; + } + self.created = true; + let id = self.rid(); + out.push_str(&ev( + "response.created", + json!({ "type": "response.created", + "response": { "id": id, "object": "response", "status": "in_progress", "model": self.client_model } }), + )); + } + + pub(super) fn close_reasoning(&mut self, out: &mut String) { + if !self.reasoning_open { + return; + } + self.reasoning_open = false; + if let Some(r) = &self.reasoning { + out.push_str(&ev( + "response.output_item.done", + json!({ "type": "response.output_item.done", "output_index": r.index, + "item": resp_reasoning_item(&r.id, &r.acc) }), + )); + } + } + + pub(super) fn announce_tool_if_ready(&mut self, pos: usize, out: &mut String) { + let Some(slot) = self.tools.get(pos) else { + return; + }; + if slot.announced || slot.name.is_empty() { + return; + } + + let id = self + .tool_context + .response_item_id(&slot.name, &self.rid(), slot.index); + let item = resp_in_progress_tool_item( + &self.tool_context, + &id, + &slot.call_id, + &slot.name, + self.reasoning + .as_ref() + .map(|reasoning| reasoning.acc.as_str()), + ); + let index = slot.index; + out.push_str(&ev( + "response.output_item.added", + json!({ "type": "response.output_item.added", "output_index": index, "item": item }), + )); + + let slot = &mut self.tools[pos]; + slot.id = id; + slot.announced = true; + self.emit_pending_tool_arguments(pos, out); + } + + pub(super) fn emit_pending_tool_arguments(&mut self, pos: usize, out: &mut String) { + let Some(slot) = self.tools.get_mut(pos) else { + return; + }; + if !slot.announced + || slot.name.is_empty() + || self.tool_context.kind_for_chat_name(&slot.name) == CodexToolKind::Custom + || slot.emitted_args_len >= slot.args.len() + { + return; + } + let delta = slot.args[slot.emitted_args_len..].to_string(); + slot.emitted_args_len = slot.args.len(); + out.push_str(&ev( + "response.function_call_arguments.delta", + json!({ "type": "response.function_call_arguments.delta", "item_id": slot.id, + "output_index": slot.index, "delta": delta }), + )); + } + + pub(super) fn close_tool_events(&self, slot: &RespToolAcc) -> String { + let mut out = String::new(); + let arguments = normalized_tool_arguments(&slot.args); + let item = self.tool_context.response_tool_item_with_reasoning( + &slot.id, + "completed", + &slot.call_id, + &slot.name, + arguments, + self.reasoning + .as_ref() + .map(|reasoning| reasoning.acc.as_str()), + ); + match self.tool_context.kind_for_chat_name(&slot.name) { + CodexToolKind::Custom => { + let input = custom_tool_input_from_chat_arguments(arguments); + if !input.is_empty() { + out.push_str(&ev( + "response.custom_tool_call_input.delta", + json!({ "type": "response.custom_tool_call_input.delta", "item_id": slot.id, + "call_id": slot.call_id, "output_index": slot.index, "delta": input }), + )); + } + out.push_str(&ev( + "response.custom_tool_call_input.done", + json!({ "type": "response.custom_tool_call_input.done", "item_id": slot.id, + "call_id": slot.call_id, "output_index": slot.index, "input": input }), + )); + } + CodexToolKind::Function | CodexToolKind::Namespace | CodexToolKind::ToolSearch => { + out.push_str(&ev( + "response.function_call_arguments.done", + json!({ "type": "response.function_call_arguments.done", "item_id": slot.id, + "output_index": slot.index, "arguments": arguments }), + )); + } + } + out.push_str(&ev( + "response.output_item.done", + json!({ "type": "response.output_item.done", "output_index": slot.index, "item": item }), + )); + out + } +} diff --git a/src-tauri/src/protocol/stream/chat_responses_push.rs b/src-tauri/src/protocol/stream/chat_responses_push.rs new file mode 100644 index 0000000..bfdf87b --- /dev/null +++ b/src-tauri/src/protocol/stream/chat_responses_push.rs @@ -0,0 +1,145 @@ +// The OpenAI-Chat-chunk event loop of ChatToResponses: usage, reasoning deltas and text deltas. +// Tool-call fragment handling lives in chat_responses_tools.rs. + +use super::chat_responses::{ChatToResponses, TextItemAcc}; +use super::common::{ev, upstream_error_message}; +use super::resp_items::response_scoped_item_id; +use serde_json::{json, Value}; + +impl ChatToResponses { + /// Feed one raw upstream SSE line ("data: {...}" or "data: [DONE]"). Returns Responses SSE + /// text to forward (possibly empty). + pub fn push(&mut self, line: &str) -> String { + let mut out = String::new(); + if self.stopped { + return out; + } + let t = line.trim(); + let payload = match t.strip_prefix("data:") { + Some(p) => p.trim(), + None => return out, // chat SSE is data-only; ignore blanks/event: lines + }; + if payload.is_empty() { + return out; + } + if payload == "[DONE]" { + out.push_str(&self.complete()); + return out; + } + let chunk: Value = match serde_json::from_str(payload) { + Ok(v) => v, + Err(_) => return out, + }; + if let Some(message) = upstream_error_message(&chunk) { + return self.fail(message); + } + if !self.created { + if let Some(id) = chunk + .get("id") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + { + self.resp_id = format!("resp_{}", id); + } + } + // usage rides the final chunk (stream_options.include_usage) + if let Some(u) = chunk.get("usage").filter(|u| !u.is_null()) { + self.input_tokens = u + .get("prompt_tokens") + .and_then(|v| v.as_i64()) + .unwrap_or(self.input_tokens); + self.output_tokens = u + .get("completion_tokens") + .and_then(|v| v.as_i64()) + .unwrap_or(self.output_tokens); + if let Some(c) = u + .pointer("/prompt_tokens_details/cached_tokens") + .and_then(|v| v.as_i64()) + { + self.cached_tokens = c; + } + } + let choice = match chunk + .get("choices") + .and_then(|c| c.as_array()) + .and_then(|a| a.first()) + { + Some(c) => c, + None => return out, + }; + self.ensure_created(&mut out); + let delta = choice.get("delta").cloned().unwrap_or(Value::Null); + + // provider reasoning stream (DeepSeek/GLM-style `reasoning_content`, or `reasoning`) + let think = delta + .get("reasoning_content") + .and_then(|v| v.as_str()) + .or_else(|| delta.get("reasoning").and_then(|v| v.as_str())) + .unwrap_or(""); + if !think.is_empty() { + if self.reasoning.is_none() { + let index = self.next_index; + self.next_index += 1; + let id = response_scoped_item_id("rs", &self.rid(), index); + out.push_str(&ev( + "response.output_item.added", + json!({ "type": "response.output_item.added", "output_index": index, + "item": { "type": "reasoning", "id": id, "summary": [] } }), + )); + self.reasoning = Some(TextItemAcc { + index, + id, + acc: String::new(), + }); + self.reasoning_open = true; + } + let r = self.reasoning.as_mut().unwrap(); + r.acc.push_str(think); + out.push_str(&ev( + "response.reasoning_summary_text.delta", + json!({ "type": "response.reasoning_summary_text.delta", "item_id": r.id, + "output_index": r.index, "summary_index": 0, "delta": think }), + )); + } + + // text delta + if let Some(txt) = delta.get("content").and_then(|v| v.as_str()) { + if !txt.is_empty() { + self.close_reasoning(&mut out); + if self.message.is_none() { + let index = self.next_index; + self.next_index += 1; + let id = response_scoped_item_id("msg", &self.rid(), index); + out.push_str(&ev( + "response.output_item.added", + json!({ "type": "response.output_item.added", "output_index": index, + "item": { "type": "message", "id": id, "status": "in_progress", "role": "assistant", "content": [] } }), + )); + out.push_str(&ev( + "response.content_part.added", + json!({ "type": "response.content_part.added", "item_id": id, "output_index": index, + "content_index": 0, "part": { "type": "output_text", "annotations": [], "text": "" } }), + )); + self.message = Some(TextItemAcc { + index, + id, + acc: String::new(), + }); + } + let m = self.message.as_mut().unwrap(); + m.acc.push_str(txt); + out.push_str(&ev( + "response.output_text.delta", + json!({ "type": "response.output_text.delta", "item_id": m.id, "output_index": m.index, + "content_index": 0, "delta": txt }), + )); + } + } + + self.push_tool_call_deltas(&delta, &mut out); + if let Some(reason) = choice.get("finish_reason").and_then(Value::as_str) { + self.finish_reason = Some(reason.to_string()); + } + out + } +} diff --git a/src-tauri/src/protocol/stream/chat_responses_tools.rs b/src-tauri/src/protocol/stream/chat_responses_tools.rs new file mode 100644 index 0000000..3fcf93e --- /dev/null +++ b/src-tauri/src/protocol/stream/chat_responses_tools.rs @@ -0,0 +1,111 @@ +// Tool-call fragment handling for ChatToResponses::push — lifted verbatim out of that function so +// no single file exceeds the module's size budget; called exactly once, from `push`. + +use super::chat_responses::{ChatToResponses, RespToolAcc}; +use super::super::openai_responses::response_scoped_call_id; +use serde_json::Value; + +impl ChatToResponses { + pub(super) fn push_tool_call_deltas(&mut self, delta: &Value, mut out: &mut String) { + // tool_call deltas (streamed in fragments, keyed by their OpenAI index) + if let Some(tcs) = delta.get("tool_calls").and_then(|v| v.as_array()) { + if !tcs.is_empty() { + self.close_reasoning(&mut out); + } + // A no-index Gemini chunk can contain multiple parallel calls. Even if a provider + // repeats the same id, each array item in this delta must claim a distinct slot + // (mirrors ChatToAnthropic). + let mut claimed_slots: Vec = vec![]; + for (fallback_index, tc) in tcs.iter().enumerate() { + let explicit_index = tc.get("index").and_then(|v| v.as_u64()); + let oa_index = explicit_index.unwrap_or(fallback_index as u64); + let frag_id = tc.get("id").and_then(|v| v.as_str()).unwrap_or(""); + let frag_name = tc + .get("function") + .and_then(|f| f.get("name")) + .and_then(|v| v.as_str()) + .unwrap_or(""); + let args = tc + .get("function") + .and_then(|f| f.get("arguments")) + .and_then(|v| v.as_str()) + .unwrap_or(""); + let thought_signature = super::super::json_thought_signature(tc); + + // Standard OpenAI chunks carry `index`; Gemini-compatible streams may omit it. + // In that case prefer the stable call id, then the call's position in this delta. + let pos = explicit_index + .and_then(|_| { + self.tools + .iter() + .enumerate() + .find(|(index, slot)| { + slot.oa_index == oa_index && !claimed_slots.contains(index) + }) + .map(|(index, _)| index) + }) + .or_else(|| { + (!frag_id.is_empty()) + .then(|| { + self.tools + .iter() + .enumerate() + .find(|(index, slot)| { + slot.upstream_call_id == frag_id + && !claimed_slots.contains(index) + }) + .map(|(index, _)| index) + }) + .flatten() + }) + .or_else(|| { + self.tools + .iter() + .enumerate() + .find(|(index, slot)| { + slot.oa_index == oa_index && !claimed_slots.contains(index) + }) + .map(|(index, _)| index) + }); + let pos = match pos { + Some(p) => p, + None => { + let index = self.next_index; + self.next_index += 1; + let call_id = response_scoped_call_id(&self.rid(), index); + let slot = RespToolAcc { + oa_index, + index, + id: String::new(), + upstream_call_id: frag_id.to_string(), + call_id, + name: frag_name.to_string(), + args: String::new(), + thought_signature: None, + announced: false, + emitted_args_len: 0, + }; + self.tools.push(slot); + self.tools.len() - 1 + } + }; + claimed_slots.push(pos); + // stray late fragments may carry the id/name the opener lacked; the done item wins + if !frag_id.is_empty() { + self.tools[pos].upstream_call_id = frag_id.to_string(); + } + if !frag_name.is_empty() && self.tools[pos].name.is_empty() { + self.tools[pos].name = frag_name.to_string(); + } + if thought_signature.is_some() { + self.tools[pos].thought_signature = thought_signature; + } + if !args.is_empty() { + self.tools[pos].args.push_str(args); + } + self.announce_tool_if_ready(pos, &mut out); + self.emit_pending_tool_arguments(pos, &mut out); + } + } + } +} diff --git a/src-tauri/src/protocol/stream/common.rs b/src-tauri/src/protocol/stream/common.rs new file mode 100644 index 0000000..f5e9404 --- /dev/null +++ b/src-tauri/src/protocol/stream/common.rs @@ -0,0 +1,42 @@ +// Pieces shared by every transcoder: the captured-tool-call record the gateway reads back, SSE +// event framing, upstream error detection, and the OpenAI→Anthropic stop-reason mapping. + +use serde_json::Value; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CapturedToolCall { + pub call_id: String, + pub name: String, + pub arguments: String, + pub thought_signature: Option, +} + +pub(super) fn ev(event: &str, data: Value) -> String { + format!( + "event: {}\ndata: {}\n\n", + event, + serde_json::to_string(&data).unwrap_or_default() + ) +} + +pub(super) fn upstream_error_message(event: &Value) -> Option<&str> { + let error = event.get("error").filter(|value| !value.is_null()); + let is_error = error.is_some() || event.get("type").and_then(Value::as_str) == Some("error"); + if !is_error { + return None; + } + error + .and_then(|value| value.get("message").and_then(Value::as_str)) + .or_else(|| error.and_then(Value::as_str)) + .or_else(|| event.get("message").and_then(Value::as_str)) + .or(Some("upstream error")) +} + +pub(super) fn map_stop(finish: Option<&str>, had_tool: bool) -> &'static str { + match finish { + Some("length") => "max_tokens", + Some("tool_calls") | Some("function_call") => "tool_use", + _ if had_tool => "tool_use", + _ => "end_turn", + } +} diff --git a/src-tauri/src/protocol/stream/mod.rs b/src-tauri/src/protocol/stream/mod.rs new file mode 100644 index 0000000..48eb331 --- /dev/null +++ b/src-tauri/src/protocol/stream/mod.rs @@ -0,0 +1,45 @@ +// Incremental SSE transcoders (P2). Consume an upstream provider's streaming events line-by-line +// and emit the client protocol's SSE events as they arrive — true token-by-token streaming, not the +// buffer-then-synthesize first cut. Wired pairs (see `Transcoder`): +// - OpenAI Chat `chat.completion.chunk` → Anthropic Messages events (Claude Code client) +// - OpenAI Chat `chat.completion.chunk` → OpenAI Responses events (Codex client) +// - Anthropic Messages events → OpenAI Responses events (Codex client) + +mod ablock; +mod anthropic_responses; +mod anthropic_responses_block; +mod anthropic_responses_finish; +mod anthropic_responses_push; +mod chat_anthropic; +mod chat_anthropic_push; +mod chat_responses; +mod chat_responses_finish; +mod chat_responses_items; +mod chat_responses_push; +mod chat_responses_tools; +mod common; +mod resp_items; +mod transcoder; +#[cfg(test)] +mod tests_anthropic_responses; +#[cfg(test)] +mod tests_chat_anthropic; +#[cfg(test)] +mod tests_chat_responses; +#[cfg(test)] +mod tests_errors; +#[cfg(test)] +mod tests_extended; +#[cfg(test)] +mod tests_ids; + +// The three concrete transcoders stay reachable at their original +// crate::protocol::stream:: paths; gateway.rs drives them through `Transcoder`. +#[allow(unused_imports)] +pub use anthropic_responses::AnthropicToResponses; +#[allow(unused_imports)] +pub use chat_anthropic::ChatToAnthropic; +#[allow(unused_imports)] +pub use chat_responses::ChatToResponses; +pub use common::CapturedToolCall; +pub use transcoder::Transcoder; diff --git a/src-tauri/src/protocol/stream/resp_items.rs b/src-tauri/src/protocol/stream/resp_items.rs new file mode 100644 index 0000000..0df492d --- /dev/null +++ b/src-tauri/src/protocol/stream/resp_items.rs @@ -0,0 +1,122 @@ +// ---- shared Responses-side item builders (final `output_item.done` / `completed` payloads) ---- + +use super::common::ev; +use super::super::openai_responses::CodexToolContext; +use serde_json::{json, Value}; + +pub(super) fn resp_message_item(id: &str, text: &str) -> Value { + json!({ "type": "message", "id": id, "status": "completed", "role": "assistant", + "content": [{ "type": "output_text", "annotations": [], "text": text }] }) +} + +pub(super) fn resp_function_call_item(id: &str, call_id: &str, name: &str, args: &str) -> Value { + json!({ "type": "function_call", "id": id, "status": "completed", "call_id": call_id, + "name": name, "arguments": if args.is_empty() { "{}" } else { args } }) +} + +pub(super) fn resp_in_progress_tool_item( + context: &CodexToolContext, + id: &str, + call_id: &str, + name: &str, + reasoning: Option<&str>, +) -> Value { + let mut item = + context.response_tool_item_with_reasoning(id, "in_progress", call_id, name, "", reasoning); + if item.get("type").and_then(Value::as_str) == Some("function_call") { + item["arguments"] = json!(""); + } + item +} + +pub(super) fn normalized_tool_arguments(arguments: &str) -> &str { + if arguments.trim().is_empty() { + "{}" + } else { + arguments + } +} + +pub(super) fn resp_reasoning_item(id: &str, text: &str) -> Value { + json!({ "type": "reasoning", "id": id, "summary": [{ "type": "summary_text", "text": text }] }) +} + +pub(super) fn response_scoped_item_id(prefix: &str, response_id: &str, index: usize) -> String { + format!( + "{}_{}_{}", + prefix, + response_id.trim_start_matches("resp_"), + index + ) +} + +/// The terminal `response.completed` event. Codex parses `response.id` + `response.usage` from it +/// and treats a stream that closes without it as an error, so every Responses-emitting transcoder +/// must end with this exactly once. +pub(super) fn resp_completed( + id: &str, + model: &str, + output: Vec, + input: i64, + cached: i64, + output_tokens: i64, +) -> String { + ev( + "response.completed", + json!({ "type": "response.completed", "response": { + "id": id, "object": "response", "status": "completed", "model": model, + "output": output, + "usage": { + "input_tokens": input.max(0), + "input_tokens_details": { "cached_tokens": cached.max(0) }, + "output_tokens": output_tokens.max(0), + "output_tokens_details": { "reasoning_tokens": 0 }, + "total_tokens": (input + output_tokens).max(0), + } } }), + ) +} + +pub(super) fn resp_incomplete( + id: &str, + model: &str, + output: Vec, + input: i64, + cached: i64, + output_tokens: i64, + reason: &str, +) -> String { + ev( + "response.incomplete", + json!({ "type": "response.incomplete", "response": { + "id": id, "object": "response", "status": "incomplete", "model": model, + "output": output, + "incomplete_details": { "reason": reason }, + "usage": { + "input_tokens": input.max(0), + "input_tokens_details": { "cached_tokens": cached.max(0) }, + "output_tokens": output_tokens.max(0), + "output_tokens_details": { "reasoning_tokens": 0 }, + "total_tokens": (input + output_tokens).max(0), + } } }), + ) +} + +pub(super) fn incomplete_reason(stop_reason: Option<&str>) -> Option<&'static str> { + match stop_reason { + Some("length" | "max_tokens" | "model_context_window_exceeded") => { + Some("max_output_tokens") + } + Some("content_filter") => Some("content_filter"), + _ => None, + } +} + +pub(super) fn resp_failed(id: &str, message: &str) -> String { + ev( + "response.failed", + json!({ "type": "response.failed", "response": { + "id": id, "object": "response", "status": "failed", + "error": { "code": "upstream_error", "message": message } + } }), + ) +} diff --git a/src-tauri/src/protocol/stream/tests_anthropic_responses.rs b/src-tauri/src/protocol/stream/tests_anthropic_responses.rs new file mode 100644 index 0000000..1d672ae --- /dev/null +++ b/src-tauri/src/protocol/stream/tests_anthropic_responses.rs @@ -0,0 +1,68 @@ +use super::*; +use super::super::openai_responses::response_scoped_call_id; + +#[test] +fn anthropic_to_responses_text_tool_and_thinking() { + let mut tc = AnthropicToResponses::new("alias-x"); + let mut out = String::new(); + out.push_str(&tc.push(r#"data: {"type":"message_start","message":{"id":"msg_9","usage":{"input_tokens":30,"cache_read_input_tokens":12,"cache_creation_input_tokens":0}}}"#)); + out.push_str(&tc.push(r#"data: {"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":""}}"#)); + out.push_str(&tc.push(r#"data: {"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"hmm"}}"#)); + out.push_str(&tc.push(r#"data: {"type":"content_block_stop","index":0}"#)); + out.push_str(&tc.push(r#"data: {"type":"content_block_start","index":1,"content_block":{"type":"text","text":""}}"#)); + out.push_str(&tc.push(r#"data: {"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":"Run"}}"#)); + out.push_str(&tc.push(r#"data: {"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":"ning."}}"#)); + out.push_str(&tc.push(r#"data: {"type":"content_block_stop","index":1}"#)); + out.push_str(&tc.push(r#"data: {"type":"content_block_start","index":2,"content_block":{"type":"tool_use","id":"toolu_1","name":"shell","input":{}}}"#)); + out.push_str(&tc.push(r#"data: {"type":"content_block_delta","index":2,"delta":{"type":"input_json_delta","partial_json":"{\"q\":"}}"#)); + out.push_str(&tc.push(r#"data: {"type":"content_block_delta","index":2,"delta":{"type":"input_json_delta","partial_json":"\"x\"}"}}"#)); + out.push_str(&tc.push(r#"data: {"type":"content_block_stop","index":2}"#)); + out.push_str(&tc.push(r#"data: {"type":"message_delta","delta":{"stop_reason":"tool_use"},"usage":{"output_tokens":11}}"#)); + out.push_str(&tc.push(r#"data: {"type":"message_stop"}"#)); + + let created = out.find(r#""type":"response.created""#).unwrap(); + let completed = out.find(r#""type":"response.completed""#).unwrap(); + assert!(created < completed); + // thinking → reasoning summary deltas + item + assert!( + out.contains(r#""type":"response.reasoning_summary_text.delta""#) + && out.contains(r#""delta":"hmm""#) + ); + assert!(out.contains(r#""type":"summary_text""#)); + // text streams as deltas and closes with the full text + assert!(out.contains(r#""delta":"Run""#) && out.contains(r#""delta":"ning.""#)); + assert!(out.contains(r#""text":"Running.""#)); + // tool_use → function_call item with the reassembled arguments string + assert!( + out.contains(&format!( + r#""call_id":"{}""#, + response_scoped_call_id("resp_msg_9", 2) + )) && out.contains(r#""name":"shell""#) + ); + assert!(out.contains(r#""arguments":"{\"q\":\"x\"}""#)); + // usage: cached reads fold into input_tokens, detail carries them + assert!(out.contains(r#""input_tokens":42"#)); + assert!(out.contains(r#""cached_tokens":12"#)); + assert!(out.contains(r#""output_tokens":11"#)); + assert!(out.contains(r#""id":"resp_msg_9""#)); + assert_eq!(tc.input_tokens(), 42); + assert_eq!(tc.output_tokens(), 11); + // message_stop already completed the stream + assert_eq!(tc.finish(), ""); +} + +#[test] +fn anthropic_to_responses_error_is_terminal() { + let mut tc = AnthropicToResponses::new("alias-x"); + let mut out = String::new(); + out.push_str(&tc.push( + r#"data: {"type":"message_start","message":{"id":"msg_1","usage":{"input_tokens":5}}}"#, + )); + out.push_str(&tc.push( + r#"data: {"type":"error","error":{"type":"overloaded_error","message":"Overloaded"}}"#, + )); + out.push_str(&tc.finish()); + assert!(out.contains(r#""type":"response.failed""#) && out.contains("Overloaded")); + // failed is terminal: no completed after it + assert!(!out.contains(r#""type":"response.completed""#)); +} diff --git a/src-tauri/src/protocol/stream/tests_chat_anthropic.rs b/src-tauri/src/protocol/stream/tests_chat_anthropic.rs new file mode 100644 index 0000000..10e9db3 --- /dev/null +++ b/src-tauri/src/protocol/stream/tests_chat_anthropic.rs @@ -0,0 +1,120 @@ +use super::*; +use super::super::Wire; + +#[test] +fn transcodes_text_and_split_tool_call() { + let mut tc = ChatToAnthropic::new("claude-x"); + let mut out = String::new(); + // role primer, then text, then a tool call split across two chunks, then finish + usage + out.push_str( + &tc.push("data: {\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\"}}]}"), + ); + out.push_str( + &tc.push("data: {\"choices\":[{\"index\":0,\"delta\":{\"content\":\"Let me \"}}]}"), + ); + out.push_str( + &tc.push("data: {\"choices\":[{\"index\":0,\"delta\":{\"content\":\"check.\"}}]}"), + ); + out.push_str(&tc.push("data: {\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_1\",\"type\":\"function\",\"function\":{\"name\":\"read_file\",\"arguments\":\"{\\\"pa\"},\"extra_content\":{\"google\":{\"thought_signature\":\"sig-stream-abc\"}}}]}}]}")); + out.push_str(&tc.push("data: {\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"th\\\":\\\"a.txt\\\"}\"}}]}}]}")); + out.push_str(&tc.push("data: {\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"tool_calls\"}],\"usage\":{\"prompt_tokens\":20,\"completion_tokens\":9}}")); + out.push_str(&tc.push("data: [DONE]")); + + // ordered events present (serde_json sorts object keys, so assert on substrings, not key order) + assert!(out.contains("event: message_start")); + assert!( + out.find("event: message_start").unwrap() + < out.find("event: content_block_start").unwrap() + ); + assert!( + out.find("event: content_block_start").unwrap() + < out.find("event: message_delta").unwrap() + ); + assert!( + out.find("event: message_delta").unwrap() < out.find("event: message_stop").unwrap() + ); + // text block: a text content_block_start + its two text deltas + assert!(out.contains(r#""type":"text""#)); + assert!(out.contains("text_delta") && out.contains(r#""text":"Let me ""#)); + assert!(out.contains(r#""text":"check.""#)); + // tool block: tool_use start carries id+name; args reassembled across fragments + assert!(out.contains(r#""type":"tool_use""#)); + assert!(out.contains(r#""id":"call_1""#) && out.contains(r#""name":"read_file""#)); + assert!(out.contains("input_json_delta") && out.contains(r#""partial_json":"{\"pa""#)); + assert!(out.contains(r#""partial_json":"th\":\"a.txt\"}""#)); + // closes both blocks (index 0 text, index 1 tool), tool_use stop, usage, terminal stop + assert!(out.contains(r#""index":0,"type":"content_block_stop""#)); + assert!(out.contains(r#""index":1,"type":"content_block_stop""#)); + assert!(out.contains(r#""stop_reason":"tool_use""#)); + assert!(out.contains(r#""output_tokens":9"#)); + assert!(out.contains("event: message_stop")); + assert_eq!(tc.input_tokens(), 20); + let captured = tc.captured_tool_calls(); + assert_eq!(captured.len(), 1); + assert_eq!(captured[0].call_id, "call_1"); + assert_eq!(captured[0].arguments, r#"{"path":"a.txt"}"#); + assert_eq!( + captured[0].thought_signature.as_deref(), + Some("sig-stream-abc") + ); +} + +#[test] +fn keeps_no_index_parallel_calls_with_the_same_id_distinct() { + let mut tc = ChatToAnthropic::new("claude-x"); + let mut out = String::new(); + out.push_str(&tc.push("data: {\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"id\":\"same-call\",\"type\":\"function\",\"function\":{\"name\":\"lookup\",\"arguments\":\"{\\\"query\\\":\\\"same\\\"}\"},\"extra_content\":{\"google\":{\"thought_signature\":\"sig-same-id\"}}},{\"id\":\"same-call\",\"type\":\"function\",\"function\":{\"name\":\"lookup\",\"arguments\":\"{\\\"query\\\":\\\"same\\\"}\"}}]}}]}\n")); + out.push_str(&tc.push( + "data: {\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"tool_calls\"}]}\n", + )); + out.push_str(&tc.push("data: [DONE]\n")); + + let captured = tc.captured_tool_calls(); + assert_eq!(captured.len(), 2); + assert_eq!(captured[0].arguments, r#"{"query":"same"}"#); + assert_eq!(captured[1].arguments, r#"{"query":"same"}"#); + assert_eq!( + captured[0].thought_signature.as_deref(), + Some("sig-same-id") + ); + assert!(captured[1].thought_signature.is_none()); + assert!(out.contains(r#""index":0,"type":"content_block_start""#)); + assert!(out.contains(r#""index":1,"type":"content_block_start""#)); +} + +#[test] +fn plain_text_only() { + let mut tc = ChatToAnthropic::new("claude-x"); + let mut out = String::new(); + out.push_str( + &tc.push("data: {\"choices\":[{\"index\":0,\"delta\":{\"content\":\"hello\"}}]}"), + ); + out.push_str( + &tc.push("data: {\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"stop\"}]}"), + ); + out.push_str(&tc.push("data: [DONE]")); + assert!(out.contains("text_delta") && out.contains(r#""text":"hello""#)); + assert!(out.contains(r#""stop_reason":"end_turn""#)); + assert!(out.contains("event: message_stop")); +} + +// The gateway's abort guard relies on done() flipping as soon as push() emits the terminal +// client event — that is the moment Responses clients (Codex) hang up, before upstream EOF. +#[test] +fn done_flips_on_terminal_event_before_eof() { + let mut tc = Transcoder::new(Wire::Anthropic, Wire::OpenAiResponses, "alias-x").unwrap(); + tc.push("data: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_1\",\"usage\":{\"input_tokens\":3}}}\n"); + tc.push("data: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\"}}\n"); + tc.push("data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"hi\"}}\n"); + assert!(!tc.done()); + let out = tc.push("data: {\"type\":\"message_stop\"}\n"); + assert!(out.contains("response.completed")); + assert!(tc.done()); + + let mut tc = Transcoder::new(Wire::OpenAiChat, Wire::Anthropic, "claude-x").unwrap(); + tc.push("data: {\"choices\":[{\"index\":0,\"delta\":{\"content\":\"hello\"}}]}"); + assert!(!tc.done()); + let out = tc.push("data: [DONE]"); + assert!(out.contains("event: message_stop")); + assert!(tc.done()); +} diff --git a/src-tauri/src/protocol/stream/tests_chat_responses.rs b/src-tauri/src/protocol/stream/tests_chat_responses.rs new file mode 100644 index 0000000..7a4a4e3 --- /dev/null +++ b/src-tauri/src/protocol/stream/tests_chat_responses.rs @@ -0,0 +1,111 @@ +use super::*; +use super::super::openai_responses::response_scoped_call_id; + +#[test] +fn chat_to_responses_text_and_split_tool_call() { + let mut tc = ChatToResponses::new("alias-x"); + let mut out = String::new(); + out.push_str(&tc.push("data: {\"id\":\"chatcmpl-1\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\"}}]}")); + out.push_str( + &tc.push("data: {\"choices\":[{\"index\":0,\"delta\":{\"content\":\"Let me \"}}]}"), + ); + out.push_str( + &tc.push("data: {\"choices\":[{\"index\":0,\"delta\":{\"content\":\"check.\"}}]}"), + ); + out.push_str(&tc.push("data: {\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_1\",\"type\":\"function\",\"function\":{\"name\":\"shell\",\"arguments\":\"{\\\"co\"}}]}}]}")); + out.push_str(&tc.push("data: {\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"mmand\\\":[\\\"ls\\\"]}\"}}]}}]}")); + out.push_str(&tc.push("data: {\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"tool_calls\"}],\"usage\":{\"prompt_tokens\":20,\"completion_tokens\":9,\"prompt_tokens_details\":{\"cached_tokens\":7}}}")); + out.push_str(&tc.push("data: [DONE]")); + + // ordered: created → text deltas → item done events → completed + let created = out.find(r#""type":"response.created""#).unwrap(); + let first_delta = out.find(r#""type":"response.output_text.delta""#).unwrap(); + let item_done = out.find(r#""type":"response.output_item.done""#).unwrap(); + let completed = out.find(r#""type":"response.completed""#).unwrap(); + assert!(created < first_delta && first_delta < item_done && item_done < completed); + // token-by-token text deltas + assert!(out.contains(r#""delta":"Let me ""#)); + assert!(out.contains(r#""delta":"check.""#)); + // Codex materializes items from output_item.done: full text + reassembled arguments + assert!(out.contains(r#""text":"Let me check.""#)); + let client_call_id = response_scoped_call_id("resp_chatcmpl-1", 1); + assert!( + out.contains(&format!(r#""call_id":"{}""#, client_call_id)) + && out.contains(r#""name":"shell""#) + ); + assert!(out.contains(r#""arguments":"{\"command\":[\"ls\"]}""#)); + // completed carries id + usage, incl. the prompt cache detail Codex reports + assert!(out.contains(r#""id":"resp_chatcmpl-1""#)); + assert!(out.contains(r#""input_tokens":20"#) && out.contains(r#""output_tokens":9"#)); + assert!(out.contains(r#""cached_tokens":7"#)); + assert_eq!(tc.input_tokens(), 20); + assert_eq!(tc.output_tokens(), 9); + // captured for the gateway's signature cache, keyed by the call_id Codex echoes back + let captured = tc.captured_tool_calls(); + assert_eq!(captured.len(), 1); + assert_eq!(captured[0].call_id, client_call_id); + assert_eq!(captured[0].arguments, r#"{"command":["ls"]}"#); + // finish is idempotent — [DONE] already closed the stream + assert_eq!(tc.finish(), ""); +} + +// Gemini's OpenAI-compatible stream omits `index` and can repeat ids across parallel calls; +// before the fix every no-index fragment collapsed into slot 0 (one garbled call), so Codex +// never received usable tool calls from a Gemini chat upstream. +#[test] +fn chat_to_responses_keeps_no_index_parallel_calls_distinct() { + let mut tc = ChatToResponses::new("alias-x"); + let mut out = String::new(); + out.push_str(&tc.push("data: {\"id\":\"chatcmpl-2\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"id\":\"same-call\",\"type\":\"function\",\"function\":{\"name\":\"lookup\",\"arguments\":\"{\\\"query\\\":\\\"a\\\"}\"},\"extra_content\":{\"google\":{\"thought_signature\":\"sig-parallel\"}}},{\"id\":\"same-call\",\"type\":\"function\",\"function\":{\"name\":\"lookup\",\"arguments\":\"{\\\"query\\\":\\\"b\\\"}\"}}]}}]}\n")); + out.push_str(&tc.push( + "data: {\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"tool_calls\"}]}\n", + )); + out.push_str(&tc.push("data: [DONE]\n")); + + // two distinct function_call items, each with its own arguments + assert!(out.contains(r#""output_index":0"#) && out.contains(r#""output_index":1"#)); + assert!(out.contains(r#""arguments":"{\"query\":\"a\"}""#)); + assert!(out.contains(r#""arguments":"{\"query\":\"b\"}""#)); + let captured = tc.captured_tool_calls(); + assert_eq!(captured.len(), 2); + assert_ne!(captured[0].call_id, captured[1].call_id); + assert_eq!( + captured[0].call_id, + response_scoped_call_id("resp_chatcmpl-2", 0) + ); + assert_eq!( + captured[1].call_id, + response_scoped_call_id("resp_chatcmpl-2", 1) + ); + assert!(out.contains(&format!(r#""call_id":"{}""#, captured[0].call_id))); + assert!(out.contains(&format!(r#""call_id":"{}""#, captured[1].call_id))); + assert_eq!(captured[0].arguments, r#"{"query":"a"}"#); + assert_eq!(captured[1].arguments, r#"{"query":"b"}"#); + // the Gemini thought signature is captured for the session cache (restore next turn) + assert_eq!( + captured[0].thought_signature.as_deref(), + Some("sig-parallel") + ); + assert!(captured[1].thought_signature.is_none()); +} + +// Some models emit tool-call fragments that never carry a function name; forwarding them +// gives Codex an unexecutable call whose echo the upstream then rejects — drop them instead. +#[test] +fn chat_to_responses_skips_nameless_tool_calls() { + let mut tc = ChatToResponses::new("alias-x"); + let mut out = String::new(); + out.push_str(&tc.push("data: {\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"{}\"}}]}}]}")); + out.push_str(&tc.push( + "data: {\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"tool_calls\"}]}", + )); + out.push_str(&tc.push("data: [DONE]")); + assert!(!out.contains("response.function_call_arguments.done")); + assert!( + out.contains(r#""output":[]"#), + "completed output stays empty: {}", + out + ); + assert!(out.contains(r#""type":"response.completed""#)); + assert!(tc.captured_tool_calls().is_empty()); +} diff --git a/src-tauri/src/protocol/stream/tests_errors.rs b/src-tauri/src/protocol/stream/tests_errors.rs new file mode 100644 index 0000000..e834f27 --- /dev/null +++ b/src-tauri/src/protocol/stream/tests_errors.rs @@ -0,0 +1,134 @@ +use super::*; +use super::super::Wire; + +#[test] +fn chat_error_events_are_terminal_for_translated_clients() { + let mut responses = + Transcoder::new(Wire::OpenAiChat, Wire::OpenAiResponses, "alias-x").unwrap(); + let mut responses_out = + responses.push(r#"data: {"choices":[{"index":0,"delta":{"content":"partial"}}]}"#); + responses_out.push_str( + &responses + .push(r#"data: {"error":{"type":"server_error","message":"upstream exploded"}}"#), + ); + responses_out.push_str(&responses.finish()); + assert!(responses_out.contains(r#""type":"response.failed""#)); + assert!(responses_out.contains("upstream exploded")); + assert!(!responses_out.contains(r#""type":"response.completed""#)); + assert!(responses.done()); + assert!(!responses.succeeded()); + + let mut anthropic = Transcoder::new(Wire::OpenAiChat, Wire::Anthropic, "claude-x").unwrap(); + let mut anthropic_out = + anthropic.push(r#"data: {"choices":[{"index":0,"delta":{"content":"partial"}}]}"#); + anthropic_out.push_str( + &anthropic + .push(r#"data: {"error":{"type":"server_error","message":"upstream exploded"}}"#), + ); + anthropic_out.push_str(&anthropic.finish()); + assert!(anthropic_out.contains("event: error")); + assert!(anthropic_out.contains("upstream exploded")); + assert!(!anthropic_out.contains("event: message_stop")); + assert!(anthropic.done()); + assert!(!anthropic.succeeded()); +} + +#[test] +fn transport_failure_cannot_be_finalized_as_success() { + for (provider, client) in [ + (Wire::OpenAiChat, Wire::Anthropic), + (Wire::OpenAiChat, Wire::OpenAiResponses), + (Wire::Anthropic, Wire::OpenAiResponses), + ] { + let mut tc = Transcoder::new(provider, client, "alias-x").unwrap(); + let mut out = tc.fail("upstream stream transport error"); + out.push_str(&tc.finish()); + assert!(tc.done()); + assert!(!tc.succeeded()); + assert!(out.contains("upstream stream transport error")); + assert!(!out.contains("response.completed")); + assert!(!out.contains("event: message_stop")); + } +} + +#[test] +fn premature_clean_eof_fails_but_reported_stop_reasons_can_finalize() { + for (provider, client) in [ + (Wire::OpenAiChat, Wire::Anthropic), + (Wire::OpenAiChat, Wire::OpenAiResponses), + (Wire::Anthropic, Wire::OpenAiResponses), + ] { + let mut tc = Transcoder::new(provider, client, "alias-x").unwrap(); + tc.push(match provider { + Wire::Anthropic => { + r#"data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}"# + } + _ => r#"data: {"choices":[{"index":0,"delta":{"content":"partial"}}]}"#, + }); + let out = tc.finish(); + assert!(tc.done()); + assert!(!tc.succeeded()); + assert!(out.contains("upstream stream ended before")); + assert!(!out.contains("response.completed")); + assert!(!out.contains("event: message_stop")); + } + + let mut chat_responses = + Transcoder::new(Wire::OpenAiChat, Wire::OpenAiResponses, "alias-x").unwrap(); + chat_responses.push( + r#"data: {"choices":[{"index":0,"delta":{"content":"done"},"finish_reason":"stop"}]}"#, + ); + let out = chat_responses.finish(); + assert!(out.contains("response.completed")); + assert!(chat_responses.succeeded()); + + let mut chat_anthropic = + Transcoder::new(Wire::OpenAiChat, Wire::Anthropic, "claude-x").unwrap(); + chat_anthropic.push( + r#"data: {"choices":[{"index":0,"delta":{"content":"done"},"finish_reason":"stop"}]}"#, + ); + let out = chat_anthropic.finish(); + assert!(out.contains("event: message_stop")); + assert!(chat_anthropic.succeeded()); + + let mut anthropic = + Transcoder::new(Wire::Anthropic, Wire::OpenAiResponses, "alias-x").unwrap(); + anthropic.push( + r#"data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":1}}"#, + ); + let out = anthropic.finish(); + assert!(out.contains("response.completed")); + assert!(anthropic.succeeded()); +} + +#[test] +fn max_token_truncation_emits_incomplete_instead_of_completed() { + let mut chat = Transcoder::new(Wire::OpenAiChat, Wire::OpenAiResponses, "alias-x").unwrap(); + let mut chat_out = chat.push( + r#"data: {"choices":[{"index":0,"delta":{"content":"partial"},"finish_reason":"length"}]}"#, + ); + chat_out.push_str(&chat.push("data: [DONE]")); + assert!(chat_out.contains("response.incomplete")); + assert!(chat_out.contains(r#""reason":"max_output_tokens""#)); + assert!(!chat_out.contains("response.completed")); + assert!(chat.done()); + assert!(!chat.succeeded()); + + let mut anthropic = + Transcoder::new(Wire::Anthropic, Wire::OpenAiResponses, "alias-x").unwrap(); + let mut anthropic_out = anthropic.push( + r#"data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}"#, + ); + anthropic_out.push_str(&anthropic.push( + r#"data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"partial"}}"#, + )); + anthropic_out.push_str(&anthropic.push( + r#"data: {"type":"message_delta","delta":{"stop_reason":"max_tokens"},"usage":{"output_tokens":8}}"#, + )); + anthropic_out.push_str(&anthropic.push(r#"data: {"type":"message_stop"}"#)); + assert!(anthropic_out.contains("response.incomplete")); + assert!(anthropic_out.contains(r#""reason":"max_output_tokens""#)); + assert!(!anthropic_out.contains("response.completed")); + assert!(anthropic.done()); + assert!(!anthropic.succeeded()); +} diff --git a/src-tauri/src/protocol/stream/tests_extended.rs b/src-tauri/src/protocol/stream/tests_extended.rs new file mode 100644 index 0000000..d208b6e --- /dev/null +++ b/src-tauri/src/protocol/stream/tests_extended.rs @@ -0,0 +1,88 @@ +use super::*; +use super::super::openai_responses::CodexToolContext; +use serde_json::json; + +fn extended_tool_context() -> CodexToolContext { + CodexToolContext::from_request(&json!({ + "tools": [ + { "type": "custom", "name": "apply_patch", "description": "Apply a patch" }, + { "type": "namespace", "name": "multi_agent_v1", "tools": [ + { "type": "function", "name": "spawn_agent", "description": "Spawn", + "parameters": { "type": "object", "properties": { + "task_name": { "type": "string" } + }, "required": ["task_name"] } } + ] }, + { "type": "tool_search", "execution": "client", + "description": "Search deferred tools.", + "parameters": { "type": "object", "properties": { + "query": { "type": "string" } + }, "required": ["query"] } } + ] + })) +} + +#[test] +fn chat_stream_restores_custom_and_tool_search_calls() { + let mut tc = ChatToResponses::new_with_context("alias-x", extended_tool_context()); + let mut out = String::new(); + out.push_str(&tc.push( + r#"data: {"id":"chatcmpl-tools","choices":[{"index":0,"delta":{"reasoning_content":"choose tools"}}]}"#, + )); + out.push_str(&tc.push( + r#"data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_patch","type":"function","function":{"name":"apply_patch","arguments":"{\"input\":\"*** Begin"}},{"index":1,"id":"call_search","type":"function","function":{"name":"tool_search","arguments":"{\"query\":\"browser\"}"}}]}}]}"#, + )); + out.push_str(&tc.push( + r#"data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":" Patch\"}"}}]},"finish_reason":"tool_calls"}]}"#, + )); + out.push_str(&tc.push("data: [DONE]")); + + assert!(out.contains("event: response.custom_tool_call_input.delta")); + assert!(out.contains("event: response.custom_tool_call_input.done")); + assert!(out.contains(r#""type":"custom_tool_call""#)); + assert!(out.contains(r#""input":"*** Begin Patch""#)); + assert!(out.contains(r#""type":"tool_search_call""#)); + assert!(out.contains(r#""arguments":{"query":"browser"}"#)); + assert!(out.contains(r#""reasoning_content":"choose tools""#)); + assert!(out.contains(r#""type":"response.completed""#)); +} + +#[test] +fn anthropic_stream_restores_namespace_and_custom_calls() { + let mut tc = AnthropicToResponses::new_with_context("alias-x", extended_tool_context()); + let mut out = String::new(); + out.push_str(&tc.push( + r#"data: {"type":"message_start","message":{"id":"msg_tools","usage":{"input_tokens":3}}}"#, + )); + out.push_str(&tc.push( + r#"data: {"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":""}}"#, + )); + out.push_str(&tc.push( + r#"data: {"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"delegate"}}"#, + )); + out.push_str(&tc.push(r#"data: {"type":"content_block_stop","index":0}"#)); + out.push_str(&tc.push( + r#"data: {"type":"content_block_start","index":1,"content_block":{"type":"tool_use","id":"toolu_spawn","name":"multi_agent_v1__spawn_agent","input":{}}}"#, + )); + out.push_str(&tc.push( + r#"data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"{\"task_name\":\"audit\"}"}}"#, + )); + out.push_str(&tc.push(r#"data: {"type":"content_block_stop","index":1}"#)); + out.push_str(&tc.push( + r#"data: {"type":"content_block_start","index":2,"content_block":{"type":"tool_use","id":"toolu_patch","name":"apply_patch","input":{"input":"*** Begin Patch"}}}"#, + )); + out.push_str(&tc.push(r#"data: {"type":"content_block_stop","index":2}"#)); + out.push_str(&tc.push( + r#"data: {"type":"message_delta","delta":{"stop_reason":"tool_use"},"usage":{"output_tokens":5}}"#, + )); + out.push_str(&tc.push(r#"data: {"type":"message_stop"}"#)); + + assert!(out.contains(r#""type":"function_call""#)); + assert!(out.contains(r#""name":"spawn_agent""#)); + assert!(out.contains(r#""namespace":"multi_agent_v1""#)); + assert!(out.contains(r#""reasoning_content":"delegate""#)); + assert!(out.contains("event: response.custom_tool_call_input.delta")); + assert!(out.contains("event: response.custom_tool_call_input.done")); + assert!(out.contains(r#""type":"custom_tool_call""#)); + assert!(out.contains(r#""input":"*** Begin Patch""#)); + assert!(out.contains(r#""type":"response.completed""#)); +} diff --git a/src-tauri/src/protocol/stream/tests_ids.rs b/src-tauri/src/protocol/stream/tests_ids.rs new file mode 100644 index 0000000..cbdf6a9 --- /dev/null +++ b/src-tauri/src/protocol/stream/tests_ids.rs @@ -0,0 +1,190 @@ +use super::*; +use serde_json::Value; + +fn response_for_event(output: &str, event: &str) -> Value { + let event_line = format!("event: {}", event); + let frame = output + .split("\n\n") + .find(|frame| frame.lines().next() == Some(event_line.as_str())) + .unwrap_or_else(|| panic!("missing {event} event in {output}")); + let data = frame + .lines() + .find_map(|line| line.strip_prefix("data: ")) + .unwrap(); + serde_json::from_str::(data).unwrap()["response"].clone() +} + +fn response_id_for_event(output: &str, event: &str) -> String { + response_for_event(output, event)["id"] + .as_str() + .unwrap() + .to_string() +} + +#[test] +fn chat_to_responses_response_ids_are_unique_and_stable() { + let mut first = ChatToResponses::new("alias-x"); + let mut second = ChatToResponses::new("alias-x"); + let first_fallback = first.resp_id.clone(); + let second_fallback = second.resp_id.clone(); + assert!(first_fallback.starts_with("resp_ccbud_")); + assert!(second_fallback.starts_with("resp_ccbud_")); + assert_ne!(first_fallback, second_fallback); + + let mut first_out = + first.push(r#"data: {"choices":[{"index":0,"delta":{"role":"assistant"}}]}"#); + first_out.push_str(&first.push( + r#"data: {"id":"chatcmpl-too-late","choices":[{"index":0,"delta":{"content":"hi"}}]}"#, + )); + first_out.push_str(&first.push("data: [DONE]")); + assert_eq!(first.resp_id, first_fallback); + assert_eq!( + response_id_for_event(&first_out, "response.created"), + first_fallback + ); + assert_eq!( + response_id_for_event(&first_out, "response.completed"), + first_fallback + ); + + let second_out = second.push("data: [DONE]"); + assert_eq!( + response_id_for_event(&second_out, "response.created"), + second_fallback + ); + assert_eq!( + response_id_for_event(&second_out, "response.completed"), + second_fallback + ); + + let mut upstream = ChatToResponses::new("alias-x"); + let upstream_fallback = upstream.resp_id.clone(); + let mut upstream_out = upstream.push( + r#"data: {"id":"chatcmpl-early","choices":[{"index":0,"delta":{"role":"assistant"}}]}"#, + ); + upstream_out.push_str(&upstream.push("data: [DONE]")); + assert_ne!(upstream.resp_id, upstream_fallback); + assert_eq!(upstream.resp_id, "resp_chatcmpl-early"); + assert_eq!( + response_id_for_event(&upstream_out, "response.created"), + "resp_chatcmpl-early" + ); + assert_eq!( + response_id_for_event(&upstream_out, "response.completed"), + "resp_chatcmpl-early" + ); +} + +#[test] +fn anthropic_to_responses_response_ids_are_unique_and_stable() { + let mut first = AnthropicToResponses::new("alias-x"); + let mut second = AnthropicToResponses::new("alias-x"); + let first_fallback = first.resp_id.clone(); + let second_fallback = second.resp_id.clone(); + assert!(first_fallback.starts_with("resp_ccbud_")); + assert!(second_fallback.starts_with("resp_ccbud_")); + assert_ne!(first_fallback, second_fallback); + + let mut first_out = first.push( + r#"data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}"#, + ); + first_out.push_str(&first.push( + r#"data: {"type":"message_start","message":{"id":"msg_too_late","usage":{"input_tokens":1}}}"#, + )); + first_out.push_str(&first.push(r#"data: {"type":"message_stop"}"#)); + assert_eq!(first.resp_id, first_fallback); + assert_eq!( + response_id_for_event(&first_out, "response.created"), + first_fallback + ); + assert_eq!( + response_id_for_event(&first_out, "response.completed"), + first_fallback + ); + + let second_out = second.push(r#"data: {"type":"message_stop"}"#); + assert_eq!( + response_id_for_event(&second_out, "response.created"), + second_fallback + ); + assert_eq!( + response_id_for_event(&second_out, "response.completed"), + second_fallback + ); + + let mut upstream = AnthropicToResponses::new("alias-x"); + let upstream_fallback = upstream.resp_id.clone(); + let mut upstream_out = upstream.push( + r#"data: {"type":"message_start","message":{"id":"msg_early","usage":{"input_tokens":1}}}"#, + ); + upstream_out.push_str(&upstream.push(r#"data: {"type":"message_stop"}"#)); + assert_ne!(upstream.resp_id, upstream_fallback); + assert_eq!(upstream.resp_id, "resp_msg_early"); + assert_eq!( + response_id_for_event(&upstream_out, "response.created"), + "resp_msg_early" + ); + assert_eq!( + response_id_for_event(&upstream_out, "response.completed"), + "resp_msg_early" + ); +} + +#[test] +fn streaming_response_item_ids_are_scoped_to_the_response() { + let chat = |upstream_id: &str| { + let mut tc = ChatToResponses::new("alias-x"); + let mut out = tc.push(&format!( + r#"data: {{"id":"{upstream_id}","choices":[{{"index":0,"delta":{{"reasoning_content":"think","content":"answer"}}}}]}}"# + )); + out.push_str(&tc.push("data: [DONE]")); + response_for_event(&out, "response.completed")["output"] + .as_array() + .unwrap() + .iter() + .filter_map(|item| item.get("id").and_then(Value::as_str)) + .map(ToString::to_string) + .collect::>() + }; + let first_chat = chat("chatcmpl-first"); + let second_chat = chat("chatcmpl-second"); + assert_eq!(first_chat.len(), 2); + assert!(first_chat.iter().all(|id| id.contains("chatcmpl-first"))); + assert!(second_chat.iter().all(|id| id.contains("chatcmpl-second"))); + assert!(first_chat.iter().all(|id| !second_chat.contains(id))); + + let anthropic = |message_id: &str| { + let mut tc = AnthropicToResponses::new("alias-x"); + let mut out = tc.push(&format!( + r#"data: {{"type":"message_start","message":{{"id":"{message_id}","usage":{{"input_tokens":1}}}}}}"# + )); + out.push_str(&tc.push( + r#"data: {"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":""}}"#, + )); + out.push_str(&tc.push( + r#"data: {"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"think"}}"#, + )); + out.push_str(&tc.push( + r#"data: {"type":"content_block_start","index":1,"content_block":{"type":"text","text":""}}"#, + )); + out.push_str(&tc.push( + r#"data: {"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":"answer"}}"#, + )); + out.push_str(&tc.push(r#"data: {"type":"message_stop"}"#)); + response_for_event(&out, "response.completed")["output"] + .as_array() + .unwrap() + .iter() + .filter_map(|item| item.get("id").and_then(Value::as_str)) + .map(ToString::to_string) + .collect::>() + }; + let first_anthropic = anthropic("msg-first"); + let second_anthropic = anthropic("msg-second"); + assert_eq!(first_anthropic.len(), 2); + assert!(first_anthropic.iter().all(|id| id.contains("msg-first"))); + assert!(second_anthropic.iter().all(|id| id.contains("msg-second"))); + assert!(first_anthropic + .iter() + .all(|id| !second_anthropic.contains(id))); +} diff --git a/src-tauri/src/protocol/stream/transcoder.rs b/src-tauri/src/protocol/stream/transcoder.rs new file mode 100644 index 0000000..d3548c3 --- /dev/null +++ b/src-tauri/src/protocol/stream/transcoder.rs @@ -0,0 +1,123 @@ +// The (provider → client) transcoder dispatcher gateway.rs holds, regardless of the wired pair. + +use super::anthropic_responses::AnthropicToResponses; +use super::chat_anthropic::ChatToAnthropic; +use super::chat_responses::ChatToResponses; +use super::common::CapturedToolCall; +use super::super::openai_responses::CodexToolContext; +use super::super::Wire; + + +/// Dispatcher over the wired (provider → client) incremental transcoders, so gateway.rs holds one +/// value regardless of the pair. `supports` is the single source of truth behind +/// `protocol::can_transcode_stream`. +pub enum Transcoder { + ChatToAnthropic(ChatToAnthropic), + ChatToResponses(ChatToResponses), + AnthropicToResponses(AnthropicToResponses), +} + +impl Transcoder { + pub fn supports(provider: Wire, client: Wire) -> bool { + matches!( + (provider, client), + (Wire::OpenAiChat, Wire::Anthropic) + | (Wire::OpenAiChat, Wire::OpenAiResponses) + | (Wire::Anthropic, Wire::OpenAiResponses) + ) + } + + pub fn new(provider: Wire, client: Wire, client_model: &str) -> Option { + Self::new_with_context(provider, client, client_model, CodexToolContext::default()) + } + + pub fn new_with_context( + provider: Wire, + client: Wire, + client_model: &str, + tool_context: CodexToolContext, + ) -> Option { + match (provider, client) { + (Wire::OpenAiChat, Wire::Anthropic) => { + Some(Self::ChatToAnthropic(ChatToAnthropic::new(client_model))) + } + (Wire::OpenAiChat, Wire::OpenAiResponses) => Some(Self::ChatToResponses( + ChatToResponses::new_with_context(client_model, tool_context), + )), + (Wire::Anthropic, Wire::OpenAiResponses) => Some(Self::AnthropicToResponses( + AnthropicToResponses::new_with_context(client_model, tool_context), + )), + _ => None, + } + } + + pub fn push(&mut self, line: &str) -> String { + match self { + Self::ChatToAnthropic(t) => t.push(line), + Self::ChatToResponses(t) => t.push(line), + Self::AnthropicToResponses(t) => t.push(line), + } + } + + pub fn finish(&mut self) -> String { + match self { + Self::ChatToAnthropic(t) => t.finish(), + Self::ChatToResponses(t) => t.finish(), + Self::AnthropicToResponses(t) => t.finish(), + } + } + + /// Terminate a translated stream without allowing EOF finalization to synthesize success. + pub fn fail(&mut self, message: &str) -> String { + match self { + Self::ChatToAnthropic(t) => t.fail(message), + Self::ChatToResponses(t) => t.fail(message), + Self::AnthropicToResponses(t) => t.fail(message), + } + } + + pub fn input_tokens(&self) -> i64 { + match self { + Self::ChatToAnthropic(t) => t.input_tokens(), + Self::ChatToResponses(t) => t.input_tokens(), + Self::AnthropicToResponses(t) => t.input_tokens(), + } + } + + pub fn output_tokens(&self) -> i64 { + match self { + Self::ChatToAnthropic(t) => t.output_tokens(), + Self::ChatToResponses(t) => t.output_tokens(), + Self::AnthropicToResponses(t) => t.output_tokens(), + } + } + + pub fn captured_tool_calls(&self) -> Vec { + match self { + Self::ChatToAnthropic(t) => t.captured_tool_calls(), + Self::ChatToResponses(t) => t.captured_tool_calls(), + _ => vec![], + } + } + + /// True once the terminal client event (`message_stop` / `response.completed` / + /// `response.incomplete` / `response.failed`) has been emitted: the turn is semantically + /// complete even though the upstream socket may not have hit EOF yet — Responses clients + /// (Codex) hang up exactly at this point, so the gateway must not treat that disconnect as an + /// abort. + pub fn done(&self) -> bool { + match self { + Self::ChatToAnthropic(t) => t.stopped, + Self::ChatToResponses(t) => t.stopped, + Self::AnthropicToResponses(t) => t.stopped, + } + } + + pub fn succeeded(&self) -> bool { + match self { + Self::ChatToAnthropic(t) => t.stopped && !t.failed, + Self::ChatToResponses(t) => t.stopped && !t.failed, + Self::AnthropicToResponses(t) => t.stopped && !t.failed, + } + } +} diff --git a/src-tauri/src/protocol/tests.rs b/src-tauri/src/protocol/tests.rs new file mode 100644 index 0000000..20d6020 --- /dev/null +++ b/src-tauri/src/protocol/tests.rs @@ -0,0 +1,153 @@ +use super::*; +use llm_connector::types::ChatRequest; +use serde_json::{json, Value}; + +#[test] +fn upstream_urls_respect_the_configured_base() { + let cases = [ + (Wire::Anthropic, "/messages"), + (Wire::OpenAiChat, "/chat/completions"), + (Wire::OpenAiResponses, "/responses"), + ]; + for (wire, endpoint) in cases { + for base in [ + "https://example.com", + "https://example.com/v1", + "https://example.com/v4", + "https://generativelanguage.googleapis.com/v1beta/openai", + ] { + assert_eq!(wire.upstream_url(base), format!("{}{}", base, endpoint)); + } + } +} + +#[test] +fn v1_fallback_is_only_offered_for_unversioned_bases() { + assert_eq!( + Wire::OpenAiChat.v1_fallback_url("https://example.com/api"), + Some("https://example.com/api/v1/chat/completions".to_string()) + ); + for base in [ + "https://example.com/v1", + "https://example.com/v4/", + "https://example.com/v1beta", + "https://example.com/V2alpha", + "https://generativelanguage.googleapis.com/v1beta/openai", + ] { + assert_eq!(Wire::OpenAiChat.v1_fallback_url(base), None, "{base}"); + } +} + +#[test] +fn canonical_request_endpoints_exclude_auxiliary_routes() { + assert_eq!(Wire::from_request_endpoint("/v1/messages"), Some(Wire::Anthropic)); + assert_eq!(Wire::from_request_endpoint("/v1/chat/completions"), Some(Wire::OpenAiChat)); + assert_eq!(Wire::from_request_endpoint("/v1/responses"), Some(Wire::OpenAiResponses)); + assert_eq!( + Wire::from_request_endpoint("/v1/responses/compact"), + Some(Wire::OpenAiResponses) + ); + assert_eq!( + Wire::OpenAiResponses.upstream_url_for_request( + "https://example.com/v1", + "/v1/responses/compact", + ), + "https://example.com/v1/responses/compact" + ); + assert_eq!(Wire::from_request_endpoint("/v1/messages/count_tokens"), None); + assert_eq!(Wire::from_request_endpoint("/v1/models"), None); +} + +#[test] +fn v1_fallback_statuses_exclude_non_path_errors() { + for status in [400, 404, 405] { + assert!(should_try_v1_fallback(status)); + } + for status in [401, 403, 413, 415, 422, 429, 500] { + assert!(!should_try_v1_fallback(status)); + } +} + +// Kimi/Moonshot and DeepSeek thinking models 400 on assistant tool-call history missing +// `reasoning_content`: real reasoning must survive the Responses→chat bridge, and turns whose +// reasoning didn't survive get the placeholder. +#[test] +fn chat_bodies_backfill_tool_call_reasoning() { + let codex = json!({ + "model": "m", + "input": [ + { "type": "message", "role": "user", "content": [{ "type": "input_text", "text": "go" }] }, + { "type": "function_call", "call_id": "c1", "name": "shell", "arguments": "{}" }, + { "type": "function_call_output", "call_id": "c1", "output": "ok" }, + { "type": "reasoning", "summary": [{ "type": "summary_text", "text": "real thoughts" }] }, + { "type": "function_call", "call_id": "c2", "name": "shell", "arguments": "{}" }, + { "type": "function_call_output", "call_id": "c2", "output": "ok" } + ] + }); + let ir = decode_client_request(Wire::OpenAiResponses, &codex).unwrap(); + let body = encode_upstream_request(Wire::OpenAiChat, &ir, "kimi-k2-thinking", true).unwrap(); + let assistants: Vec<_> = body["messages"].as_array().unwrap().iter() + .filter(|m| m["role"] == "assistant").collect(); + assert_eq!(assistants.len(), 2); + // step 1 lost its reasoning → placeholder; step 2's bridged reasoning is preserved + assert_eq!(assistants[0]["reasoning_content"], "tool call"); + assert_eq!(assistants[1]["reasoning_content"], "real thoughts"); +} + +#[test] +fn glm_chat_uses_native_thinking_switch() { + let codex = json!({ + "model": "gpt-5.4", + "input": [{ + "type": "message", + "role": "user", + "content": [{ "type": "input_text", "text": "inspect" }] + }], + "reasoning": { "effort": "ultra" } + }); + let ir = decode_client_request(Wire::OpenAiResponses, &codex).unwrap(); + let body = encode_upstream_request(Wire::OpenAiChat, &ir, "glm-5.2", true).unwrap(); + assert_eq!(body["thinking"]["type"], "enabled"); + assert!(body.get("reasoning_effort").is_none()); +} + +#[test] +fn gemini_thought_signature_maps_between_openai_wire_and_ir() { + let signature = "sig-regression-abc"; + let upstream_response = json!({ + "id": "chatcmpl-gemini", "object": "chat.completion", "created": 1, + "model": "google/gemini-3-flash-preview", + "choices": [{ "index": 0, "finish_reason": "tool_calls", "message": { + "role": "assistant", "content": Value::Null, + "tool_calls": [{ + "id": "default_api:Bash", "type": "function", + "function": { "name": "default_api:Bash", "arguments": "{\"command\":\"pwd\"}" }, + "extra_content": { "google": { "thought_signature": signature } } + }] + }}], + "usage": { "prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15 } + }); + + let ir = decode_upstream_response(Wire::OpenAiChat, &upstream_response.to_string()).unwrap(); + let call = &ir.choices[0].message.tool_calls.as_ref().unwrap()[0]; + assert_eq!(tool_call_thought_signature(call).as_deref(), Some(signature)); + assert_eq!(json_thought_signature(&json!({ + "thought_signature": "", "function": { "thought_signature": signature } + })).as_deref(), Some(signature)); + + let mut message = llm_connector::types::Message::new( + llm_connector::types::Role::Assistant, + vec![], + ); + message.tool_calls = Some(vec![call.clone()]); + let next_ir = ChatRequest::new("gemini").with_messages(vec![message]); + let outgoing = encode_upstream_request( + Wire::OpenAiChat, &next_ir, "google/gemini-3-flash-preview", false, + ).unwrap(); + let assistant = outgoing["messages"].as_array().unwrap().iter() + .find(|message| message["role"] == "assistant").unwrap(); + let outgoing_call = &assistant["tool_calls"][0]; + assert_eq!(outgoing_call["extra_content"]["google"]["thought_signature"], signature); + assert!(outgoing_call.get("thought_signature").is_none()); + assert!(outgoing_call["function"].get("thought_signature").is_none()); +} diff --git a/src-tauri/src/protocol/wire.rs b/src-tauri/src/protocol/wire.rs new file mode 100644 index 0000000..15298d0 --- /dev/null +++ b/src-tauri/src/protocol/wire.rs @@ -0,0 +1,143 @@ +// The wire protocol a request or provider speaks, its endpoint URLs, and the `/v1` compatibility +// fallback (some providers publish an unversioned base that 404s until `/v1` is appended). + +use axum::http::Uri; + +/// A wire protocol a request or provider speaks. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Wire { + Anthropic, + OpenAiChat, + OpenAiResponses, +} + +impl Wire { + /// The provider's declared protocol (config `protocol` field). Unknown / absent → Anthropic, + /// which is today's passthrough default. + pub fn from_provider(s: Option<&str>) -> Wire { + match s { + Some("openai-chat") => Wire::OpenAiChat, + Some("openai-responses") => Wire::OpenAiResponses, + _ => Wire::Anthropic, + } + } + + /// The client's protocol, inferred from the inbound request path. Claude Code hits + /// `/v1/messages`; an OpenAI/Codex client hits `/v1/chat/completions` or `/v1/responses`. + pub fn from_request_path(uri: &Uri) -> Wire { + let p = uri.path().trim_end_matches('/'); + if p.ends_with("/responses") || p.ends_with("/responses/compact") { + Wire::OpenAiResponses + } else if p.contains("/chat/completions") { + Wire::OpenAiChat + } else { + Wire::Anthropic + } + } + + /// Short human label for exchange records / monitor UI. + pub fn label(self) -> &'static str { + match self { + Wire::Anthropic => "anthropic", + Wire::OpenAiChat => "openai-chat", + Wire::OpenAiResponses => "openai-responses", + } + } + + /// The bare endpoint appended to the provider's configured baseUrl. + pub fn endpoint_path(self) -> &'static str { + match self { + Wire::Anthropic => "/messages", + Wire::OpenAiChat => "/chat/completions", + Wire::OpenAiResponses => "/responses", + } + } + + /// Full upstream URL, treating the configured baseUrl as authoritative. + pub fn upstream_url(self, base_url: &str) -> String { + let base = base_url.trim_end_matches('/'); + format!("{}{}", base, self.endpoint_path()) + } + + /// Compatibility URL for configurations created when ccbud implicitly inserted `/v1`. + /// A versioned baseUrl (`v1`, `v4`, `v1beta`, …), or Google's `/openai` compatibility root, + /// must never receive another version segment. + pub fn v1_fallback_url(self, base_url: &str) -> Option { + let base = base_url.trim_end_matches('/'); + if base_url_has_version_suffix(base_url) + || (self == Wire::OpenAiChat && base.ends_with("/openai")) + { + return None; + } + Some(format!("{}/v1{}", base, self.endpoint_path())) + } + + /// Match only the three request endpoints that may be safely rebased onto a provider URL. + /// Models, count_tokens, HEAD, and unknown routes keep the generic passthrough path. + pub fn from_request_endpoint(path: &str) -> Option { + match path.trim_end_matches('/') { + "/messages" | "/v1/messages" => Some(Wire::Anthropic), + "/chat/completions" | "/v1/chat/completions" => Some(Wire::OpenAiChat), + "/responses" | "/v1/responses" | "/responses/compact" | "/v1/responses/compact" => { + Some(Wire::OpenAiResponses) + } + _ => None, + } + } + + pub fn request_endpoint_path(self, inbound_path: &str) -> &'static str { + if self == Wire::OpenAiResponses + && inbound_path.trim_end_matches('/').ends_with("/responses/compact") + { + "/responses/compact" + } else { + self.endpoint_path() + } + } + + pub fn upstream_url_for_request(self, base_url: &str, inbound_path: &str) -> String { + let base = base_url.trim_end_matches('/'); + format!("{}{}", base, self.request_endpoint_path(inbound_path)) + } + + pub fn v1_fallback_url_for_request( + self, + base_url: &str, + inbound_path: &str, + ) -> Option { + let base = base_url.trim_end_matches('/'); + if base_url_has_version_suffix(base_url) + || (self == Wire::OpenAiChat && base.ends_with("/openai")) + { + return None; + } + Some(format!("{}/v1{}", base, self.request_endpoint_path(inbound_path))) + } +} + +fn base_url_has_version_suffix(base_url: &str) -> bool { + let clean = base_url + .split(['?', '#']) + .next() + .unwrap_or(base_url) + .trim_end_matches('/'); + let after_authority = clean + .split_once("://") + .map(|(_, rest)| rest) + .unwrap_or(clean); + let Some((_, path)) = after_authority.split_once('/') else { + return false; + }; + let Some(segment) = path.rsplit('/').find(|segment| !segment.is_empty()) else { + return false; + }; + let mut chars = segment.chars(); + matches!(chars.next(), Some('v' | 'V')) + && matches!(chars.next(), Some(c) if c.is_ascii_digit()) +} + +/// Statuses commonly used by upstreams for an unrecognized or unsupported endpoint path. +/// Authentication, validation, payload-size, and rate-limit errors intentionally do not qualify. +pub fn should_try_v1_fallback(status: u16) -> bool { + matches!(status, 400 | 404 | 405) +} diff --git a/src-tauri/src/qoder.rs b/src-tauri/src/qoder.rs deleted file mode 100644 index 27955d7..0000000 --- a/src-tauri/src/qoder.rs +++ /dev/null @@ -1,1200 +0,0 @@ -// Qoder CLI session support — Qoder writes Claude-like transcripts into its own trees -// (`~/.qoder/projects//.jsonl` and the same layout under `~/.qoderwork`, -// subagents in `/subagents/agent-*.jsonl`). Qoder streams assistant content as atomic -// wrappers and stores title/workspace/runtime metadata inline, so this module provides the small -// normalization layer needed by the normal Claude pipeline, plus root discovery, safe reads, -// path routing, and the shared foreign-CLI sidecar. The source files belong to another tool and -// are never rewritten, which also means hard-delete refuses them (history.rs). - -#![allow(dead_code)] - -use serde_json::{json, Value}; -use std::collections::HashMap; -use std::fs; -use std::io; -#[cfg(target_os = "macos")] -use std::io::Read; -use std::path::{Path, PathBuf}; -#[cfg(target_os = "macos")] -use std::process::{Command, Stdio}; -use std::sync::{Arc, Mutex, OnceLock}; -#[cfg(target_os = "macos")] -use std::time::{Duration, Instant}; - -/// Keep the privileged helper path bounded even if the on-disk file changes while it is read. -/// This is deliberately much larger than normal transcripts, while still preventing an -/// accidental/untrusted child process from filling the app's memory with stdout. -const MAX_READ_BYTES: usize = 256 * 1024 * 1024; - -/// Hard deadline for one helper invocation — generous for a MAX_READ_BYTES read, but bounded so -/// a wedged helper can never pin a sync command thread (and with it the renderer's coalesced -/// request slot for that session) until app restart. Batches get longer since they serve many -/// files in one spawn. -#[cfg(target_os = "macos")] -const HELPER_TIMEOUT: Duration = Duration::from_secs(15); -#[cfg(target_os = "macos")] -const HELPER_BATCH_TIMEOUT: Duration = Duration::from_secs(45); - -/// Byte budget for the helper-read cache (see helper_cache) — cleared wholesale when exceeded, -/// mirroring the search cache's crude-but-safe policy. -const HELPER_CACHE_BUDGET: usize = 256 * 1024 * 1024; - -const QODER_READ_SCRIPT: &str = - "const fs=require(\"fs\");process.stdout.write(fs.readFileSync(process.argv[1]))"; - -/// Batch counterpart: one line of JSON per argv file — content as base64 (`b64`) or a per-file -/// error (`err`) that must not abort the rest of the batch. -#[cfg(target_os = "macos")] -const QODER_BATCH_READ_SCRIPT: &str = "const fs=require(\"fs\");for(const p of process.argv.slice(1)){let line;try{line=JSON.stringify({p,b64:fs.readFileSync(p).toString(\"base64\")})}catch(e){line=JSON.stringify({p,err:String(e&&e.code||e)})}process.stdout.write(line+\"\\n\")}"; - -fn home() -> PathBuf { - std::env::var("HOME") - .map(PathBuf::from) - .unwrap_or_else(|_| PathBuf::from(".")) -} - -/// Qoder's two known data roots (both observed in the wild): each is a history-dir entry -/// candidate for the auto-add migration; browsing itself walks every configured dir's -/// `projects/` tree, so these only seed historyDirs. -pub fn default_root() -> PathBuf { - home().join(".qoder") -} - -pub fn work_root() -> PathBuf { - home().join(".qoderwork") -} - -/// A qoder install exists at `root` when its projects tree is on disk. -pub fn root_exists(root: &Path) -> bool { - root.join("projects").is_dir() -} - -fn denied(message: impl Into) -> io::Error { - io::Error::new(io::ErrorKind::PermissionDenied, message.into()) -} - -fn too_large() -> io::Error { - io::Error::new( - io::ErrorKind::InvalidData, - format!( - "Qoder data file exceeds the {} MiB read limit", - MAX_READ_BYTES / 1024 / 1024 - ), - ) -} - -/// Canonicalize a prospective helper target and prove that it is a JSON/JSONL data file in a -/// `projects` directory directly below a `.qoder` or `.qoderwork` root. Canonicalizing the root, -/// projects directory, and target separately prevents a symlink below `projects` from escaping -/// into an arbitrary part of the filesystem. -fn validated_qoder_data_path(path: &Path) -> io::Result<(PathBuf, PathBuf)> { - let projects = path - .ancestors() - .find(|ancestor| { - ancestor - .file_name() - .map(|name| name == "projects") - .unwrap_or(false) - && ancestor - .parent() - .and_then(Path::file_name) - .map(|name| name == ".qoder" || name == ".qoderwork") - .unwrap_or(false) - }) - .ok_or_else(|| denied("helper reads are limited to .qoder/.qoderwork projects trees"))?; - let root = projects - .parent() - .ok_or_else(|| denied("Qoder projects directory has no data root"))?; - - let canonical_root = fs::canonicalize(root)?; - let canonical_projects = fs::canonicalize(projects)?; - if canonical_projects.parent() != Some(canonical_root.as_path()) { - return Err(denied("Qoder projects directory escapes its data root")); - } - - let canonical_path = fs::canonicalize(path)?; - let relative = canonical_path - .strip_prefix(&canonical_projects) - .map_err(|_| denied("Qoder data path escapes its projects directory"))?; - if relative.as_os_str().is_empty() { - return Err(denied("Qoder data path must name a file below projects")); - } - - let is_data_file = matches!( - canonical_path - .extension() - .and_then(|extension| extension.to_str()), - Some("json") | Some("jsonl") - ); - if !is_data_file { - return Err(denied("Qoder helper only reads JSON and JSONL data files")); - } - - let metadata = fs::metadata(&canonical_path)?; - if !metadata.is_file() { - return Err(denied("Qoder data path is not a regular file")); - } - if metadata.len() > MAX_READ_BYTES as u64 { - return Err(too_large()); - } - - Ok((canonical_path, canonical_root)) -} - -#[cfg(target_os = "macos")] -fn helper_from_install_dir(install_dir: &Path) -> io::Result { - let canonical_dir = fs::canonicalize(install_dir)?; - if !fs::metadata(&canonical_dir)?.is_dir() { - return Err(io::Error::new( - io::ErrorKind::NotFound, - "Qoder CLI helper install directory is not a directory", - )); - } - - let version_path = fs::canonicalize(canonical_dir.join("version.txt"))?; - if version_path.parent() != Some(canonical_dir.as_path()) { - return Err(denied( - "Qoder CLI version file escapes its install directory", - )); - } - let version = fs::read_to_string(version_path)?; - let version = version.trim(); - if version.is_empty() - || version.len() > 64 - || !version - .bytes() - .all(|byte| byte.is_ascii_alphanumeric() || b"._-".contains(&byte)) - { - return Err(io::Error::new( - io::ErrorKind::InvalidData, - "Qoder CLI version.txt contains an invalid version", - )); - } - - let helper = fs::canonicalize(canonical_dir.join(format!("qodercli-{version}")))?; - if helper.parent() != Some(canonical_dir.as_path()) || !fs::metadata(&helper)?.is_file() { - return Err(denied("Qoder CLI helper escapes its install directory")); - } - Ok(helper) -} - -#[cfg(target_os = "macos")] -fn installed_qoder_helper(current_root: &Path) -> io::Result { - let current_install = current_root.join("bin").join("qodercli"); - let home_install = default_root().join("bin").join("qodercli"); - - let mut last_error = None; - for install_dir in [¤t_install, &home_install] { - match helper_from_install_dir(install_dir) { - Ok(helper) => return Ok(helper), - Err(error) => last_error = Some(error), - } - } - Err(last_error.unwrap_or_else(|| { - io::Error::new( - io::ErrorKind::NotFound, - "installed Qoder CLI helper was not found", - ) - })) -} - -/// The helper lives in a user-writable tree, so before executing it must prove it wasn't planted: -/// owned by the same uid as $HOME, neither it nor its directory group/world-writable, a valid -/// strict codesign signature, and a real TeamIdentifier (rejects ad-hoc-signed payloads). Same-uid -/// malware can defeat any same-uid check by definition — the goal is to stop weaker writers and -/// unsigned binaries, and to guarantee a signing-identity trail for anything that does run. -/// codesign hashes the whole (large) binary, so the verdict is memoized per (path, mtime, size). -#[cfg(target_os = "macos")] -fn verify_helper_trust(helper: &Path) -> io::Result<()> { - static VERDICTS: OnceLock>> = OnceLock::new(); - let meta = fs::metadata(helper)?; - let (mt, size) = (mtime_ms_of(&meta), meta.len()); - let cache = VERDICTS.get_or_init(|| Mutex::new(HashMap::new())); - if let Ok(guard) = cache.lock() { - if let Some((cmt, csz, ok)) = guard.get(helper) { - if *cmt == mt && *csz == size { - return if *ok { - Ok(()) - } else { - Err(denied("Qoder CLI helper previously failed trust verification")) - }; - } - } - } - let verdict = helper_trust_checks(helper, &meta); - if let Ok(mut guard) = cache.lock() { - guard.insert(helper.to_path_buf(), (mt, size, verdict.is_ok())); - } - verdict -} - -#[cfg(target_os = "macos")] -fn helper_trust_checks(helper: &Path, meta: &fs::Metadata) -> io::Result<()> { - use std::os::unix::fs::MetadataExt; - let home_uid = fs::metadata(home())?.uid(); - let dir_meta = match helper.parent() { - Some(dir) => fs::metadata(dir)?, - None => return Err(denied("Qoder CLI helper has no install directory")), - }; - for (what, m) in [("helper", meta), ("helper directory", &dir_meta)] { - if m.uid() != home_uid { - return Err(denied(format!("Qoder CLI {what} is not owned by the current user"))); - } - if m.mode() & 0o022 != 0 { - return Err(denied(format!("Qoder CLI {what} is group/world writable"))); - } - } - // codesign is Apple's own bounded tool — a plain blocking call is fine here. - let valid = Command::new("/usr/bin/codesign") - .args(["--verify", "--strict", "--"]) - .arg(helper) - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status()?; - if !valid.success() { - return Err(denied("Qoder CLI helper has no valid code signature")); - } - let display = Command::new("/usr/bin/codesign") - .args(["-d", "--verbose=2", "--"]) - .arg(helper) - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::piped()) - .output()?; // codesign -d prints its details on stderr - let info = String::from_utf8_lossy(&display.stderr); - let has_team = info.lines().any(|line| { - let line = line.trim_start(); - line.starts_with("TeamIdentifier=") && line != "TeamIdentifier=not set" - }); - if !has_team { - return Err(denied("Qoder CLI helper is not signed with a developer Team ID")); - } - Ok(()) -} - -/// Poll the child for exit until `deadline`; None = still running when time ran out. -#[cfg(target_os = "macos")] -fn wait_deadline( - child: &mut std::process::Child, - deadline: Instant, -) -> io::Result> { - loop { - if let Some(status) = child.try_wait()? { - return Ok(Some(status)); - } - if Instant::now() >= deadline { - return Ok(None); - } - std::thread::sleep(Duration::from_millis(10)); - } -} - -/// Run a prepared helper command with a hard deadline. stdout drains on its own thread (bounded -/// at `stdout_cap`), stderr on another (an 8 KiB diagnostic tail, then discarded so a chatty -/// child can't deadlock on a full pipe), the child is killed at the deadline or on an oversized -/// stream, and exit is polled — a helper that closes stdout but never exits still can't pin the -/// calling thread. -#[cfg(target_os = "macos")] -fn run_helper_bounded( - mut cmd: Command, - stdout_cap: usize, - timeout: Duration, - expected_len: usize, -) -> io::Result> { - use std::sync::mpsc; - let timed_out = || io::Error::new(io::ErrorKind::TimedOut, "Qoder CLI helper timed out"); - let mut child = cmd - .stdin(Stdio::null()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn()?; - let deadline = Instant::now() + timeout; - let mut stdout = child - .stdout - .take() - .ok_or_else(|| io::Error::new(io::ErrorKind::Other, "Qoder CLI stdout was not captured"))?; - let (tx, rx) = mpsc::channel(); - // Pre-allocate for the expected payload, capped — metadata could claim MAX_READ_BYTES and an - // upfront 256 MiB allocation per read is needless; Vec growth covers honest large files. - let prealloc = expected_len.min(8 * 1024 * 1024); - let cap = stdout_cap as u64; - let reader = std::thread::spawn(move || { - let mut bytes = Vec::with_capacity(prealloc); - let result = stdout.by_ref().take(cap + 1).read_to_end(&mut bytes); - let _ = tx.send((result, bytes)); - }); - let (etx, erx) = mpsc::channel(); - let stderr_reader = child.stderr.take().map(|mut pipe| { - std::thread::spawn(move || { - let mut tail = Vec::with_capacity(1024); - let _ = pipe.by_ref().take(8192).read_to_end(&mut tail); - let _ = io::copy(&mut pipe, &mut io::sink()); - let _ = etx.send(tail); - }) - }); - let kill = |child: &mut std::process::Child| { - let _ = child.kill(); - let _ = child.wait(); - }; - let remaining = deadline.saturating_duration_since(Instant::now()); - let (read_result, bytes) = match rx.recv_timeout(remaining) { - Ok(outcome) => outcome, - Err(_) => { - kill(&mut child); // pipe closes → reader threads unblock and exit - let _ = reader.join(); - return Err(timed_out()); - } - }; - let _ = reader.join(); - if let Err(error) = read_result { - kill(&mut child); - return Err(error); - } - if bytes.len() > stdout_cap { - kill(&mut child); - return Err(too_large()); - } - let status = match wait_deadline(&mut child, deadline)? { - Some(status) => status, - None => { - kill(&mut child); - return Err(timed_out()); - } - }; - let stderr_tail = stderr_reader - .and_then(|_| erx.recv_timeout(Duration::from_millis(200)).ok()) - .unwrap_or_default(); - if !status.success() { - let detail = String::from_utf8_lossy(&stderr_tail); - let detail = detail.trim(); - return Err(io::Error::new( - io::ErrorKind::Other, - if detail.is_empty() { - format!("Qoder CLI helper exited with status {status}") - } else { - format!("Qoder CLI helper exited with status {status}: {detail}") - }, - )); - } - Ok(bytes) -} - -#[cfg(target_os = "macos")] -fn read_with_qoder_helper(path: &Path) -> io::Result> { - let (path, root) = validated_qoder_data_path(path)?; - let helper = installed_qoder_helper(&root)?; - verify_helper_trust(&helper)?; - let expected_len = fs::metadata(&path)?.len().min(MAX_READ_BYTES as u64) as usize; - - // Qoder CLI is a Bun executable. Passing the fixed program and target as distinct argv - // entries is important: never interpolate a path into JavaScript or a shell command. - let mut cmd = Command::new(helper); - cmd.env("BUN_BE_BUN", "1").arg("-e").arg(QODER_READ_SCRIPT).arg(&path); - run_helper_bounded(cmd, MAX_READ_BYTES, HELPER_TIMEOUT, expected_len) -} - -/// Read a local history file. The normal filesystem path is always attempted first; macOS may -/// fall back to Qoder's already-installed CLI only for a permission denial, and only after the -/// helper target passes the strict projects-tree validation above. -pub(crate) fn read_bytes(path: &Path) -> io::Result> { - match fs::read(path) { - Ok(bytes) if bytes.len() <= MAX_READ_BYTES => Ok(bytes), - Ok(_) => Err(too_large()), - Err(error) if error.kind() == io::ErrorKind::PermissionDenied => { - #[cfg(target_os = "macos")] - { - // Serve repeat reads of the same file version from the helper cache — stat still - // works on content-protected files, so (mtime, size) is a valid freshness key. - let stamp = file_stamp(path).ok(); - if let Some((mt, size)) = stamp { - if let Some(hit) = helper_cache_get(path, mt, size) { - return Ok(hit); - } - } - read_with_qoder_helper(path) - .map(|bytes| { - if let Some((mt, size)) = stamp { - helper_cache_put(path, mt, size, Arc::new(bytes.clone())); - } - bytes - }) - .map_err(|helper_error| { - if matches!( - helper_error.kind(), - io::ErrorKind::NotFound | io::ErrorKind::PermissionDenied - ) { - // The original file read was a permission failure. A missing helper - // must not turn that into NotFound, which callers interpret as a - // moved file. - io::Error::new( - io::ErrorKind::PermissionDenied, - format!( - "Qoder data is not directly readable and its CLI helper is unavailable: {helper_error}" - ), - ) - } else { - // Size/encoding failures, timeouts, and abnormal helper exits retain - // their distinct classification so the UI reports a read failure, - // not an auth hint. - helper_error - } - }) - } - #[cfg(not(target_os = "macos"))] - { - Err(error) - } - } - Err(error) => Err(error), - } -} - -/// UTF-8 text counterpart to [`read_bytes`]. Qoder's JSON/JSONL data is defined as UTF-8, so -/// malformed data is reported rather than silently replacing bytes and corrupting records. -pub(crate) fn read_text(path: &Path) -> io::Result { - String::from_utf8(read_bytes(path)?) - .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error)) -} - -fn mtime_ms_of(meta: &fs::Metadata) -> f64 { - meta.modified() - .ok() - .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) - .map(|d| d.as_millis() as f64) - .unwrap_or(0.0) -} - -fn file_stamp(path: &Path) -> io::Result<(f64, u64)> { - let meta = fs::metadata(path)?; - Ok((mtime_ms_of(&meta), meta.len())) -} - -/// Bytes fetched through the macOS helper, memoized by (mtime, size) — the list/search/detail -/// paths and the 4s live-follow tick otherwise each pay a bun startup for the SAME file version. -/// Stat keeps working on content-protected files (discovery depends on it), so the stamp is the -/// same freshness signal the list-meta memo uses. -struct HelperCache { - map: HashMap>)>, - bytes: usize, -} - -fn helper_cache() -> &'static Mutex { - static CACHE: OnceLock> = OnceLock::new(); - CACHE.get_or_init(|| Mutex::new(HelperCache { map: HashMap::new(), bytes: 0 })) -} - -fn helper_cache_get(path: &Path, mt: f64, size: u64) -> Option> { - let cache = helper_cache().lock().ok()?; - let (cmt, csz, bytes) = cache.map.get(path)?; - (*cmt == mt && *csz == size).then(|| bytes.as_ref().clone()) -} - -fn helper_cache_put(path: &Path, mt: f64, size: u64, bytes: Arc>) { - if let Ok(mut cache) = helper_cache().lock() { - if cache.bytes + bytes.len() > HELPER_CACHE_BUDGET { - cache.map.clear(); - cache.bytes = 0; - } - let len = bytes.len(); - if let Some((_, _, old)) = cache.map.insert(path.to_path_buf(), (mt, size, bytes)) { - cache.bytes = cache.bytes.saturating_sub(old.len()); - } - cache.bytes += len; - } -} - -/// Minimal standard-alphabet base64 decoder for the batch helper's output (node/bun emit padded -/// base64 without line breaks; stray CR/LF are tolerated anyway). -fn b64_decode(s: &str) -> Option> { - fn val(b: u8) -> Option { - match b { - b'A'..=b'Z' => Some((b - b'A') as u32), - b'a'..=b'z' => Some((b - b'a' + 26) as u32), - b'0'..=b'9' => Some((b - b'0' + 52) as u32), - b'+' => Some(62), - b'/' => Some(63), - _ => None, - } - } - let bytes = s.as_bytes(); - let mut out = Vec::with_capacity(bytes.len() / 4 * 3); - let mut chunk = [0u32; 4]; - let mut n = 0usize; - let mut pad = 0usize; - for &b in bytes { - if b == b'\r' || b == b'\n' { - continue; - } - if b == b'=' { - pad += 1; - chunk[n] = 0; - } else { - if pad > 0 { - return None; // data after padding - } - chunk[n] = val(b)?; - } - n += 1; - if n == 4 { - let v = (chunk[0] << 18) | (chunk[1] << 12) | (chunk[2] << 6) | chunk[3]; - out.push((v >> 16) as u8); - if pad < 2 { - out.push((v >> 8) as u8); - } - if pad < 1 { - out.push(v as u8); - } - n = 0; - if pad > 0 { - break; - } - } - } - (n == 0).then_some(out) -} - -/// Warm the helper cache for many qoder files with ONE helper invocation per data root — the -/// list, search, usage, and subagent scans otherwise pay one bun startup per file on a protected -/// macOS install (the measured stall is seconds for a first refresh). Directly-readable and -/// fresh-cached files are skipped; per-file failures fall back to the on-demand single read. -/// No-op off macOS. -pub(crate) fn prefetch(paths: &[PathBuf]) { - #[cfg(target_os = "macos")] - prefetch_macos(paths); - #[cfg(not(target_os = "macos"))] - let _ = paths; -} - -#[cfg(target_os = "macos")] -fn prefetch_macos(paths: &[PathBuf]) { - // (canonical helper target, original cache key, stamp) - let mut by_root: HashMap> = HashMap::new(); - for path in paths { - let Ok((mt, size)) = file_stamp(path) else { continue }; - if size > MAX_READ_BYTES as u64 || helper_cache_get(path, mt, size).is_some() { - continue; - } - match fs::File::open(path) { - Ok(_) => continue, // direct reads work — the ordinary path is cheap - Err(error) if error.kind() == io::ErrorKind::PermissionDenied => {} - Err(_) => continue, - } - let Ok((canonical, root)) = validated_qoder_data_path(path) else { continue }; - by_root.entry(root).or_default().push((canonical, path.clone(), mt, size)); - } - for (root, files) in by_root { - let Ok(helper) = installed_qoder_helper(&root) else { continue }; - if verify_helper_trust(&helper).is_err() { - continue; - } - // Small argv chunks keep each spawn's total output within the shared byte cap and far - // below ARG_MAX; a lost chunk (timeout/oversize) degrades to per-file reads, not failure. - for chunk in files.chunks(32) { - let mut cmd = Command::new(&helper); - cmd.env("BUN_BE_BUN", "1").arg("-e").arg(QODER_BATCH_READ_SCRIPT); - for (canonical, _, _, _) in chunk { - cmd.arg(canonical); - } - let Ok(out) = run_helper_bounded(cmd, MAX_READ_BYTES, HELPER_BATCH_TIMEOUT, 0) else { - continue; - }; - let by_canonical: HashMap<&Path, (&PathBuf, f64, u64)> = chunk - .iter() - .map(|(canonical, original, mt, size)| (canonical.as_path(), (original, *mt, *size))) - .collect(); - for line in out.split(|b| *b == b'\n') { - let Ok(row) = serde_json::from_slice::(line) else { continue }; - let Some(p) = row.get("p").and_then(Value::as_str) else { continue }; - let Some(&(original, mt, size)) = by_canonical.get(Path::new(p)) else { continue }; - let Some(bytes) = row.get("b64").and_then(Value::as_str).and_then(b64_decode) else { - continue; // per-file err rows fall back to the single-read path on demand - }; - helper_cache_put(original, mt, size, Arc::new(bytes)); - } - } - } -} - -/// Container-shape test for routing: a .jsonl anywhere under a `.qoder/projects/` or -/// `.qoderwork/projects/` tree (main sessions AND `/subagents/agent-*.jsonl`). -pub fn looks_qoder_path(file: &Path) -> bool { - if file.extension().and_then(|e| e.to_str()) != Some("jsonl") { - return false; - } - let mut child: Option<&std::ffi::OsStr> = None; - for anc in file.ancestors().skip(1) { - let name = match anc.file_name() { - Some(n) => n, - None => break, - }; - if (name == ".qoder" || name == ".qoderwork") - && child.map(|c| c == "projects").unwrap_or(false) - { - return true; - } - child = Some(name); - } - false -} - -/// The session uuid (its file stem) — sidecar key and renderer id both build on it. -fn session_stem(file: &Path) -> String { - file.file_stem() - .and_then(|s| s.to_str()) - .unwrap_or("") - .to_string() -} - -fn sidecar_key(file: &Path) -> String { - format!("qoder:{}", session_stem(file)) -} - -fn trimmed_string(value: Option<&Value>) -> Option { - value - .and_then(Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(str::to_owned) -} - -fn latest_inline_string(records: &[Value], record_type: &str, field: &str) -> Option { - records.iter().rev().find_map(|record| { - (record.get("type").and_then(Value::as_str) == Some(record_type)) - .then(|| trimmed_string(record.get(field))) - .flatten() - }) -} - -fn text_content(value: &Value) -> Option { - if let Some(text) = value.as_str() { - let text = text.trim(); - return (!text.is_empty()).then(|| text.to_owned()); - } - if let Some(blocks) = value.as_array() { - let parts: Vec<&str> = blocks - .iter() - .filter_map(|block| { - if let Some(text) = block.as_str() { - return Some(text); - } - let kind = block.get("type").and_then(Value::as_str).unwrap_or(""); - matches!(kind, "text" | "input_text") - .then(|| block.get("text").and_then(Value::as_str)) - .flatten() - }) - .map(str::trim) - .filter(|part| !part.is_empty()) - .collect(); - if !parts.is_empty() { - return Some(parts.join("\n")); - } - } - value - .get("text") - .and_then(Value::as_str) - .map(str::trim) - .filter(|text| !text.is_empty()) - .map(str::to_owned) -} - -fn summary_from(records: &[Value]) -> Option { - records.iter().rev().find_map(|record| { - if record.get("type").and_then(Value::as_str) != Some("summary") { - return None; - } - trimmed_string(record.get("summary")) - .or_else(|| record.get("content").and_then(text_content)) - .or_else(|| { - record - .get("message") - .and_then(|message| message.get("content")) - .and_then(text_content) - }) - }) -} - -fn first_user_text_from(records: &[Value]) -> Option { - records - .iter() - .find_map(|record| match record.get("type").and_then(Value::as_str) { - Some("user") - if record.get("isMeta").and_then(Value::as_bool) != Some(true) - && record.get("isCompactSummary").and_then(Value::as_bool) != Some(true) => - { - record - .get("message") - .and_then(|message| message.get("content")) - .and_then(text_content) - } - Some("attachment") - if record - .get("attachment") - .and_then(|attachment| attachment.get("type")) - .and_then(Value::as_str) - == Some("queued_command") => - { - trimmed_string( - record - .get("attachment") - .and_then(|attachment| attachment.get("prompt")), - ) - } - _ => None, - }) -} - -/// Qoder's inline title, in the same precedence used by its own conversation list. Repeated -/// metadata records are append-only updates, so the last non-empty value wins within each tier. -pub(crate) fn session_title_from(records: &[Value]) -> Option { - latest_inline_string(records, "custom-title", "customTitle") - .or_else(|| latest_inline_string(records, "ai-title", "aiTitle")) - .or_else(|| latest_inline_string(records, "last-prompt", "lastPrompt")) - .or_else(|| summary_from(records)) - .or_else(|| first_user_text_from(records)) -} - -/// Primary workspace from Qoder's latest inline `workspace-directories` record. -pub(crate) fn working_dir_from(records: &[Value]) -> Option { - records.iter().rev().find_map(|record| { - if record.get("type").and_then(Value::as_str) != Some("workspace-directories") { - return None; - } - record - .get("directories") - .and_then(Value::as_array) - .and_then(|directories| { - directories - .iter() - .find_map(|value| trimmed_string(Some(value))) - }) - }) -} - -/// Effective model from Qoder's latest inline `runtime-config` update. -pub(crate) fn model_from(records: &[Value]) -> Option { - latest_inline_string(records, "runtime-config", "model") -} - -fn has_value(value: &Value) -> bool { - match value { - Value::Null => false, - Value::String(value) => !value.trim().is_empty(), - Value::Array(value) => !value.is_empty(), - Value::Object(value) => !value.is_empty(), - Value::Bool(_) | Value::Number(_) => true, - } -} - -fn without_redacted_thinking(record: &Value) -> Value { - let mut record = record.clone(); - if let Some(content) = record - .get_mut("message") - .and_then(|message| message.get_mut("content")) - .and_then(Value::as_array_mut) - { - content - .retain(|block| block.get("type").and_then(Value::as_str) != Some("redacted_thinking")); - } - record -} - -fn merge_assistant_wrapper(target: &mut Value, wrapper: &Value) { - let Some(source_message) = wrapper.get("message").and_then(Value::as_object) else { - return; - }; - let Some(target_message) = target.get_mut("message").and_then(Value::as_object_mut) else { - return; - }; - - if let Some(source_content) = source_message.get("content").and_then(Value::as_array) { - match target_message.get_mut("content") { - Some(Value::Array(target_content)) => { - target_content.extend(source_content.iter().cloned()) - } - Some(Value::Null) | None => { - target_message.insert("content".to_string(), Value::Array(source_content.clone())); - } - Some(_) => {} - } - } - - for field in ["model", "usage", "stop_reason"] { - if let Some(value) = source_message.get(field).filter(|value| has_value(value)) { - target_message.insert(field.to_string(), value.clone()); - } - } -} - -fn queued_command_as_user(record: &Value) -> Option { - let attachment = record.get("attachment")?; - if attachment.get("type").and_then(Value::as_str) != Some("queued_command") { - return None; - } - let prompt = attachment - .get("prompt") - .and_then(Value::as_str) - .unwrap_or_default() - .to_owned(); - let mut normalized = record.clone(); - let object = normalized.as_object_mut()?; - object.insert("type".to_string(), Value::String("user".to_string())); - object.insert( - "message".to_string(), - json!({ "role": "user", "content": prompt }), - ); - object.remove("attachment"); - Some(normalized) -} - -/// Content sniff for qoder transcripts that lost their container path (import copies, bundle -/// zips): the inline metadata / queued-command record types are qoder-only vocabulary that no -/// Claude Code or Codex transcript produces. -pub(crate) fn looks_qoder_records(records: &[Value]) -> bool { - records.iter().any(|record| match record.get("type").and_then(Value::as_str) { - Some("agent-setting") | Some("ai-title") | Some("custom-title") | Some("last-prompt") - | Some("workspace-directories") | Some("runtime-config") => true, - Some("attachment") => { - record - .get("attachment") - .and_then(|attachment| attachment.get("type")) - .and_then(Value::as_str) - == Some("queued_command") - } - _ => false, - }) -} - -/// Convert Qoder's append-only wire records into the Claude-like records expected by the shared -/// history shaper. Atomic assistant wrappers with the same `message.id` collapse at their first -/// position, queued prompts become user messages, and opaque duplicate thinking blocks are -/// discarded in favor of the corresponding ordinary `thinking` block. -pub(crate) fn normalize_records(records: &[Value]) -> Vec { - let mut normalized = Vec::with_capacity(records.len()); - let mut assistant_by_message_id: HashMap = HashMap::new(); - - for record in records { - if let Some(user) = queued_command_as_user(record) { - normalized.push(user); - continue; - } - if record.get("type").and_then(Value::as_str) != Some("assistant") { - normalized.push(record.clone()); - continue; - } - - let wrapper = without_redacted_thinking(record); - let message_id = wrapper - .get("message") - .and_then(|message| message.get("id")) - .and_then(Value::as_str) - .map(str::trim) - .filter(|id| !id.is_empty()) - .map(str::to_owned); - if let Some(message_id) = message_id { - if let Some(index) = assistant_by_message_id.get(&message_id).copied() { - merge_assistant_wrapper(&mut normalized[index], &wrapper); - } else { - assistant_by_message_id.insert(message_id, normalized.len()); - normalized.push(wrapper); - } - } else { - normalized.push(wrapper); - } - } - - normalized -} - -/// (custom title, tags, deleted) from the shared agent sidecar (~/.ccbud/agent-meta.json). -pub fn sidecar_meta(file: &Path) -> (Option, Vec, bool) { - crate::sidecar::meta(&crate::sidecar::agent_file(), &sidecar_key(file)) -} - -pub fn is_deleted(file: &Path) -> bool { - sidecar_meta(file).2 -} - -pub fn set_meta(file: &str, patch: &Value) -> Value { - let key = sidecar_key(Path::new(file)); - if key == "qoder:" { - return json!({ "ok": false, "reason": "empty" }); - } - crate::sidecar::set_meta(&crate::sidecar::agent_file(), &key, patch) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn test_dir(name: &str) -> PathBuf { - std::env::temp_dir().join(format!("ccbud-qoder-{name}-{}", std::process::id())) - } - - #[test] - fn detects_qoder_paths() { - assert!(looks_qoder_path(Path::new( - "/h/.qoder/projects/-Users-a-p/1111-uuid.jsonl" - ))); - assert!(looks_qoder_path(Path::new( - "/h/.qoderwork/projects/-Users-a-p/1111-uuid.jsonl" - ))); - // subagent transcripts under the session's own dir route too (search scans them) - assert!(looks_qoder_path(Path::new( - "/h/.qoder/projects/-enc/1111-uuid/subagents/agent-x.jsonl" - ))); - assert!(!looks_qoder_path(Path::new( - "/h/.claude/projects/-enc/1111-uuid.jsonl" - ))); - assert!(!looks_qoder_path(Path::new( - "/h/.qoder/projects/-enc/session-state.json" - ))); - // projects/ must be DIRECTLY under the qoder root - assert!(!looks_qoder_path(Path::new( - "/h/.qoder/sessions/-enc/1111-uuid.jsonl" - ))); - assert!(!looks_qoder_path(Path::new( - "/h/qoder/projects/-enc/1111-uuid.jsonl" - ))); - } - - #[test] - fn extracts_inline_title_workspace_and_runtime_metadata() { - let records = vec![ - json!({ "type": "user", "isMeta": true, "message": { "content": "hidden setup" } }), - json!({ "type": "user", "message": { "content": [{ "type": "tool_result", "content": "not a title" }] } }), - json!({ "type": "user", "message": { "content": " First real prompt " } }), - json!({ "type": "summary", "summary": " Summary fallback " }), - json!({ "type": "last-prompt", "lastPrompt": " Older prompt " }), - json!({ "type": "last-prompt", "lastPrompt": " Latest prompt " }), - json!({ "type": "ai-title", "aiTitle": " Generated title " }), - json!({ "type": "custom-title", "customTitle": " " }), - json!({ "type": "custom-title", "customTitle": " Chosen title " }), - json!({ "type": "workspace-directories", "directories": ["/old/workspace"] }), - json!({ "type": "workspace-directories", "directories": [" ", "/work/project", "/work/secondary"] }), - json!({ "type": "runtime-config", "model": "basic" }), - json!({ "type": "runtime-config", "model": " " }), - json!({ "type": "runtime-config", "model": "ultimate" }), - ]; - - assert_eq!( - session_title_from(&records).as_deref(), - Some("Chosen title") - ); - assert_eq!(working_dir_from(&records).as_deref(), Some("/work/project")); - assert_eq!(model_from(&records).as_deref(), Some("ultimate")); - - assert_eq!( - session_title_from(&records[..8]).as_deref(), - Some("Generated title") - ); - assert_eq!( - session_title_from(&records[..6]).as_deref(), - Some("Latest prompt") - ); - assert_eq!( - session_title_from(&records[..4]).as_deref(), - Some("Summary fallback") - ); - assert_eq!( - session_title_from(&records[..3]).as_deref(), - Some("First real prompt") - ); - } - - #[test] - fn normalizes_atomic_assistant_wrappers_and_queued_commands() { - let records = vec![ - json!({ "type": "runtime-config", "model": "ultimate" }), - json!({ - "type": "assistant", "uuid": "wrapper-1", "timestamp": "2026-01-01T00:00:00Z", - "message": { - "id": "message-1", "role": "assistant", "model": "draft", - "content": [ - { "type": "thinking", "thinking": "plan" }, - { "type": "redacted_thinking", "data": "opaque duplicate" } - ], - "usage": { "input_tokens": 1 }, "stop_reason": null - } - }), - json!({ - "type": "assistant", "uuid": "wrapper-2", "timestamp": "2026-01-01T00:00:01Z", - "message": { - "id": "message-1", "role": "assistant", "model": "ultimate", - "content": [{ "type": "text", "text": "checking" }], - "usage": null, "stop_reason": "tool_use" - } - }), - json!({ - "type": "assistant", "uuid": "wrapper-3", "timestamp": "2026-01-01T00:00:02Z", - "message": { - "id": "message-1", "role": "assistant", "model": " ", - "content": [{ "type": "tool_use", "id": "tool-1", "name": "Read", "input": { "file_path": "/work/file" } }], - "usage": { "input_tokens": 7, "output_tokens": 3 }, "stop_reason": null - } - }), - json!({ - "type": "attachment", "uuid": "queued-1", "cwd": "/work/project", - "attachment": { "type": "queued_command", "prompt": "follow up", "commandMode": "agent" } - }), - json!({ - "type": "assistant", "uuid": "wrapper-without-id", - "message": { "role": "assistant", "content": [ - { "type": "redacted_thinking", "data": "drop me" }, - { "type": "text", "text": "kept" } - ] } - }), - ]; - - let normalized = normalize_records(&records); - assert_eq!(normalized.len(), 4); - assert_eq!(normalized[0]["type"], "runtime-config"); - - let assistant = &normalized[1]; - assert_eq!(assistant["uuid"], "wrapper-1"); - assert_eq!(assistant["timestamp"], "2026-01-01T00:00:00Z"); - assert_eq!(assistant["message"]["model"], "ultimate"); - assert_eq!( - assistant["message"]["usage"], - json!({ "input_tokens": 7, "output_tokens": 3 }) - ); - assert_eq!(assistant["message"]["stop_reason"], "tool_use"); - assert_eq!( - assistant["message"]["content"] - .as_array() - .unwrap() - .iter() - .map(|block| block["type"].as_str().unwrap()) - .collect::>(), - vec!["thinking", "text", "tool_use"] - ); - - let queued = &normalized[2]; - assert_eq!(queued["type"], "user"); - assert_eq!(queued["uuid"], "queued-1"); - assert_eq!(queued["cwd"], "/work/project"); - assert_eq!( - queued["message"], - json!({ "role": "user", "content": "follow up" }) - ); - assert!(queued.get("attachment").is_none()); - - assert_eq!( - normalized[3]["message"]["content"], - json!([{ "type": "text", "text": "kept" }]) - ); - // The caller's parsed records remain untouched. - assert_eq!( - records[1]["message"]["content"].as_array().unwrap().len(), - 2 - ); - } - - #[test] - fn decodes_batch_helper_base64() { - assert_eq!(b64_decode("").unwrap(), b""); - assert_eq!(b64_decode("aGVsbG8=").unwrap(), b"hello"); - assert_eq!(b64_decode("aGVsbG8h").unwrap(), b"hello!"); - assert_eq!(b64_decode("aA==").unwrap(), b"h"); - assert_eq!(b64_decode("5Lit5paH").unwrap(), "中文".as_bytes()); - assert!(b64_decode("not base64!").is_none()); - assert!(b64_decode("aGVsbG8").is_none()); // truncated group - } - - #[test] - fn helper_cache_serves_only_fresh_stamps() { - let path = test_dir("cache").join("t.jsonl"); - assert!(helper_cache_get(&path, 1.0, 10).is_none()); - helper_cache_put(&path, 1.0, 10, Arc::new(b"v1".to_vec())); - assert_eq!(helper_cache_get(&path, 1.0, 10).unwrap(), b"v1"); - // a changed mtime or size means a new file version — the stale entry must not serve - assert!(helper_cache_get(&path, 2.0, 10).is_none()); - assert!(helper_cache_get(&path, 1.0, 11).is_none()); - helper_cache_put(&path, 2.0, 10, Arc::new(b"v2".to_vec())); - assert_eq!(helper_cache_get(&path, 2.0, 10).unwrap(), b"v2"); - } - - #[test] - fn sniffs_qoder_records_by_inline_vocabulary() { - assert!(looks_qoder_records(&[json!({ "type": "ai-title", "aiTitle": "t" })])); - assert!(looks_qoder_records(&[ - json!({ "type": "user", "message": { "content": "hi" } }), - json!({ "type": "attachment", "attachment": { "type": "queued_command", "prompt": "p" } }), - ])); - // plain Claude / Codex shapes must not sniff as qoder - assert!(!looks_qoder_records(&[ - json!({ "type": "user", "message": { "content": "hi" }, "cwd": "/x" }), - json!({ "type": "assistant", "message": { "role": "assistant", "content": [] } }), - json!({ "type": "attachment", "attachment": { "type": "file" } }), - json!({ "type": "session_meta", "payload": {} }), - ])); - } - - #[test] - fn ordinary_reads_do_not_require_a_qoder_path() { - let dir = test_dir("ordinary-read"); - let _ = fs::remove_dir_all(&dir); - fs::create_dir_all(&dir).unwrap(); - let file = dir.join("ordinary.txt"); - fs::write(&file, "local UTF-8 文本").unwrap(); - - assert_eq!(read_bytes(&file).unwrap(), "local UTF-8 文本".as_bytes()); - assert_eq!(read_text(&file).unwrap(), "local UTF-8 文本"); - - fs::write(&file, [0xff, 0xfe]).unwrap(); - assert_eq!( - read_text(&file).unwrap_err().kind(), - io::ErrorKind::InvalidData - ); - let _ = fs::remove_dir_all(&dir); - } - - #[test] - fn helper_target_is_limited_to_canonical_qoder_project_data() { - let dir = test_dir("path-validation"); - let _ = fs::remove_dir_all(&dir); - let projects = dir.join(".qoder").join("projects"); - let session = projects.join("-encoded-cwd").join("session-id"); - fs::create_dir_all(&session).unwrap(); - - let transcript = projects.join("-encoded-cwd").join("session-id.jsonl"); - let state = session.join("state.json"); - let metadata = session.join("agent-worker.meta.json"); - fs::write(&transcript, "{}\n").unwrap(); - fs::write(&state, "{}").unwrap(); - fs::write(&metadata, "{}").unwrap(); - - for file in [&transcript, &state, &metadata] { - let (validated, root) = validated_qoder_data_path(file).unwrap(); - assert_eq!(validated, fs::canonicalize(file).unwrap()); - assert_eq!(root, fs::canonicalize(dir.join(".qoder")).unwrap()); - } - - let arbitrary = session.join("secret.txt"); - fs::write(&arbitrary, "not helper-readable").unwrap(); - assert_eq!( - validated_qoder_data_path(&arbitrary).unwrap_err().kind(), - io::ErrorKind::PermissionDenied - ); - - let outside = dir.join("outside.jsonl"); - fs::write(&outside, "{}\n").unwrap(); - assert_eq!( - validated_qoder_data_path(&outside).unwrap_err().kind(), - io::ErrorKind::PermissionDenied - ); - - #[cfg(unix)] - { - use std::os::unix::fs::symlink; - let escaped = session.join("escaped.jsonl"); - symlink(&outside, &escaped).unwrap(); - assert_eq!( - validated_qoder_data_path(&escaped).unwrap_err().kind(), - io::ErrorKind::PermissionDenied - ); - } - - let _ = fs::remove_dir_all(&dir); - } -} diff --git a/src-tauri/src/qoder/exec.rs b/src-tauri/src/qoder/exec.rs new file mode 100644 index 0000000..602f4d7 --- /dev/null +++ b/src-tauri/src/qoder/exec.rs @@ -0,0 +1,116 @@ +// Bounded execution of one helper invocation (hard deadline, capped stdout, drained stderr). +// macOS-only (declared `#[cfg(target_os = "macos")] mod exec;`), moved verbatim from qoder.rs. + +use std::io; +use std::io::Read; +use std::process::{Command, Stdio}; +use std::time::{Duration, Instant}; + +use super::guard::too_large; + +/// Poll the child for exit until `deadline`; None = still running when time ran out. +#[cfg(target_os = "macos")] +fn wait_deadline( + child: &mut std::process::Child, + deadline: Instant, +) -> io::Result> { + loop { + if let Some(status) = child.try_wait()? { + return Ok(Some(status)); + } + if Instant::now() >= deadline { + return Ok(None); + } + std::thread::sleep(Duration::from_millis(10)); + } +} + +/// Run a prepared helper command with a hard deadline. stdout drains on its own thread (bounded +/// at `stdout_cap`), stderr on another (an 8 KiB diagnostic tail, then discarded so a chatty +/// child can't deadlock on a full pipe), the child is killed at the deadline or on an oversized +/// stream, and exit is polled — a helper that closes stdout but never exits still can't pin the +/// calling thread. +#[cfg(target_os = "macos")] +pub(super) fn run_helper_bounded( + mut cmd: Command, + stdout_cap: usize, + timeout: Duration, + expected_len: usize, +) -> io::Result> { + use std::sync::mpsc; + let timed_out = || io::Error::new(io::ErrorKind::TimedOut, "Qoder CLI helper timed out"); + let mut child = cmd + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn()?; + let deadline = Instant::now() + timeout; + let mut stdout = child + .stdout + .take() + .ok_or_else(|| io::Error::new(io::ErrorKind::Other, "Qoder CLI stdout was not captured"))?; + let (tx, rx) = mpsc::channel(); + // Pre-allocate for the expected payload, capped — metadata could claim MAX_READ_BYTES and an + // upfront 256 MiB allocation per read is needless; Vec growth covers honest large files. + let prealloc = expected_len.min(8 * 1024 * 1024); + let cap = stdout_cap as u64; + let reader = std::thread::spawn(move || { + let mut bytes = Vec::with_capacity(prealloc); + let result = stdout.by_ref().take(cap + 1).read_to_end(&mut bytes); + let _ = tx.send((result, bytes)); + }); + let (etx, erx) = mpsc::channel(); + let stderr_reader = child.stderr.take().map(|mut pipe| { + std::thread::spawn(move || { + let mut tail = Vec::with_capacity(1024); + let _ = pipe.by_ref().take(8192).read_to_end(&mut tail); + let _ = io::copy(&mut pipe, &mut io::sink()); + let _ = etx.send(tail); + }) + }); + let kill = |child: &mut std::process::Child| { + let _ = child.kill(); + let _ = child.wait(); + }; + let remaining = deadline.saturating_duration_since(Instant::now()); + let (read_result, bytes) = match rx.recv_timeout(remaining) { + Ok(outcome) => outcome, + Err(_) => { + kill(&mut child); // pipe closes → reader threads unblock and exit + let _ = reader.join(); + return Err(timed_out()); + } + }; + let _ = reader.join(); + if let Err(error) = read_result { + kill(&mut child); + return Err(error); + } + if bytes.len() > stdout_cap { + kill(&mut child); + return Err(too_large()); + } + let status = match wait_deadline(&mut child, deadline)? { + Some(status) => status, + None => { + kill(&mut child); + return Err(timed_out()); + } + }; + let stderr_tail = stderr_reader + .and_then(|_| erx.recv_timeout(Duration::from_millis(200)).ok()) + .unwrap_or_default(); + if !status.success() { + let detail = String::from_utf8_lossy(&stderr_tail); + let detail = detail.trim(); + return Err(io::Error::new( + io::ErrorKind::Other, + if detail.is_empty() { + format!("Qoder CLI helper exited with status {status}") + } else { + format!("Qoder CLI helper exited with status {status}: {detail}") + }, + )); + } + Ok(bytes) +} diff --git a/src-tauri/src/qoder/guard.rs b/src-tauri/src/qoder/guard.rs new file mode 100644 index 0000000..5512212 --- /dev/null +++ b/src-tauri/src/qoder/guard.rs @@ -0,0 +1,80 @@ +// Error shapes and the strict projects-tree validation every privileged helper read must pass. +// Moved verbatim from qoder.rs. + +use std::fs; +use std::io; +use std::path::{Path, PathBuf}; + +use super::limits::MAX_READ_BYTES; + +pub(super) fn denied(message: impl Into) -> io::Error { + io::Error::new(io::ErrorKind::PermissionDenied, message.into()) +} + +pub(super) fn too_large() -> io::Error { + io::Error::new( + io::ErrorKind::InvalidData, + format!( + "Qoder data file exceeds the {} MiB read limit", + MAX_READ_BYTES / 1024 / 1024 + ), + ) +} + +/// Canonicalize a prospective helper target and prove that it is a JSON/JSONL data file in a +/// `projects` directory directly below a `.qoder` or `.qoderwork` root. Canonicalizing the root, +/// projects directory, and target separately prevents a symlink below `projects` from escaping +/// into an arbitrary part of the filesystem. +pub(super) fn validated_qoder_data_path(path: &Path) -> io::Result<(PathBuf, PathBuf)> { + let projects = path + .ancestors() + .find(|ancestor| { + ancestor + .file_name() + .map(|name| name == "projects") + .unwrap_or(false) + && ancestor + .parent() + .and_then(Path::file_name) + .map(|name| name == ".qoder" || name == ".qoderwork") + .unwrap_or(false) + }) + .ok_or_else(|| denied("helper reads are limited to .qoder/.qoderwork projects trees"))?; + let root = projects + .parent() + .ok_or_else(|| denied("Qoder projects directory has no data root"))?; + + let canonical_root = fs::canonicalize(root)?; + let canonical_projects = fs::canonicalize(projects)?; + if canonical_projects.parent() != Some(canonical_root.as_path()) { + return Err(denied("Qoder projects directory escapes its data root")); + } + + let canonical_path = fs::canonicalize(path)?; + let relative = canonical_path + .strip_prefix(&canonical_projects) + .map_err(|_| denied("Qoder data path escapes its projects directory"))?; + if relative.as_os_str().is_empty() { + return Err(denied("Qoder data path must name a file below projects")); + } + + let is_data_file = matches!( + canonical_path + .extension() + .and_then(|extension| extension.to_str()), + Some("json") | Some("jsonl") + ); + if !is_data_file { + return Err(denied("Qoder helper only reads JSON and JSONL data files")); + } + + let metadata = fs::metadata(&canonical_path)?; + if !metadata.is_file() { + return Err(denied("Qoder data path is not a regular file")); + } + if metadata.len() > MAX_READ_BYTES as u64 { + return Err(too_large()); + } + + Ok((canonical_path, canonical_root)) +} diff --git a/src-tauri/src/qoder/helper.rs b/src-tauri/src/qoder/helper.rs new file mode 100644 index 0000000..a93e797 --- /dev/null +++ b/src-tauri/src/qoder/helper.rs @@ -0,0 +1,145 @@ +// Locating Qoder's installed CLI binary and proving it is trustworthy before executing it. +// macOS-only (declared `#[cfg(target_os = "macos")] mod helper;`), moved verbatim from qoder.rs. + +use std::collections::HashMap; +use std::fs; +use std::io; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::sync::{Mutex, OnceLock}; + +use super::guard::denied; +use super::read::mtime_ms_of; +use super::roots::{default_root, home}; + +#[cfg(target_os = "macos")] +fn helper_from_install_dir(install_dir: &Path) -> io::Result { + let canonical_dir = fs::canonicalize(install_dir)?; + if !fs::metadata(&canonical_dir)?.is_dir() { + return Err(io::Error::new( + io::ErrorKind::NotFound, + "Qoder CLI helper install directory is not a directory", + )); + } + + let version_path = fs::canonicalize(canonical_dir.join("version.txt"))?; + if version_path.parent() != Some(canonical_dir.as_path()) { + return Err(denied( + "Qoder CLI version file escapes its install directory", + )); + } + let version = fs::read_to_string(version_path)?; + let version = version.trim(); + if version.is_empty() + || version.len() > 64 + || !version + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || b"._-".contains(&byte)) + { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "Qoder CLI version.txt contains an invalid version", + )); + } + + let helper = fs::canonicalize(canonical_dir.join(format!("qodercli-{version}")))?; + if helper.parent() != Some(canonical_dir.as_path()) || !fs::metadata(&helper)?.is_file() { + return Err(denied("Qoder CLI helper escapes its install directory")); + } + Ok(helper) +} + +#[cfg(target_os = "macos")] +pub(super) fn installed_qoder_helper(current_root: &Path) -> io::Result { + let current_install = current_root.join("bin").join("qodercli"); + let home_install = default_root().join("bin").join("qodercli"); + + let mut last_error = None; + for install_dir in [¤t_install, &home_install] { + match helper_from_install_dir(install_dir) { + Ok(helper) => return Ok(helper), + Err(error) => last_error = Some(error), + } + } + Err(last_error.unwrap_or_else(|| { + io::Error::new( + io::ErrorKind::NotFound, + "installed Qoder CLI helper was not found", + ) + })) +} + +/// The helper lives in a user-writable tree, so before executing it must prove it wasn't planted: +/// owned by the same uid as $HOME, neither it nor its directory group/world-writable, a valid +/// strict codesign signature, and a real TeamIdentifier (rejects ad-hoc-signed payloads). Same-uid +/// malware can defeat any same-uid check by definition — the goal is to stop weaker writers and +/// unsigned binaries, and to guarantee a signing-identity trail for anything that does run. +/// codesign hashes the whole (large) binary, so the verdict is memoized per (path, mtime, size). +#[cfg(target_os = "macos")] +pub(super) fn verify_helper_trust(helper: &Path) -> io::Result<()> { + static VERDICTS: OnceLock>> = OnceLock::new(); + let meta = fs::metadata(helper)?; + let (mt, size) = (mtime_ms_of(&meta), meta.len()); + let cache = VERDICTS.get_or_init(|| Mutex::new(HashMap::new())); + if let Ok(guard) = cache.lock() { + if let Some((cmt, csz, ok)) = guard.get(helper) { + if *cmt == mt && *csz == size { + return if *ok { + Ok(()) + } else { + Err(denied("Qoder CLI helper previously failed trust verification")) + }; + } + } + } + let verdict = helper_trust_checks(helper, &meta); + if let Ok(mut guard) = cache.lock() { + guard.insert(helper.to_path_buf(), (mt, size, verdict.is_ok())); + } + verdict +} + +#[cfg(target_os = "macos")] +fn helper_trust_checks(helper: &Path, meta: &fs::Metadata) -> io::Result<()> { + use std::os::unix::fs::MetadataExt; + let home_uid = fs::metadata(home())?.uid(); + let dir_meta = match helper.parent() { + Some(dir) => fs::metadata(dir)?, + None => return Err(denied("Qoder CLI helper has no install directory")), + }; + for (what, m) in [("helper", meta), ("helper directory", &dir_meta)] { + if m.uid() != home_uid { + return Err(denied(format!("Qoder CLI {what} is not owned by the current user"))); + } + if m.mode() & 0o022 != 0 { + return Err(denied(format!("Qoder CLI {what} is group/world writable"))); + } + } + // codesign is Apple's own bounded tool — a plain blocking call is fine here. + let valid = Command::new("/usr/bin/codesign") + .args(["--verify", "--strict", "--"]) + .arg(helper) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status()?; + if !valid.success() { + return Err(denied("Qoder CLI helper has no valid code signature")); + } + let display = Command::new("/usr/bin/codesign") + .args(["-d", "--verbose=2", "--"]) + .arg(helper) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::piped()) + .output()?; // codesign -d prints its details on stderr + let info = String::from_utf8_lossy(&display.stderr); + let has_team = info.lines().any(|line| { + let line = line.trim_start(); + line.starts_with("TeamIdentifier=") && line != "TeamIdentifier=not set" + }); + if !has_team { + return Err(denied("Qoder CLI helper is not signed with a developer Team ID")); + } + Ok(()) +} diff --git a/src-tauri/src/qoder/limits.rs b/src-tauri/src/qoder/limits.rs new file mode 100644 index 0000000..402d76b --- /dev/null +++ b/src-tauri/src/qoder/limits.rs @@ -0,0 +1,31 @@ +// Read-size, timeout and cache budgets for the Qoder CLI helper path, plus the two tiny +// JavaScript programs it is handed. Moved verbatim from qoder.rs. + +#[cfg(target_os = "macos")] +use std::time::Duration; + +/// Keep the privileged helper path bounded even if the on-disk file changes while it is read. +/// This is deliberately much larger than normal transcripts, while still preventing an +/// accidental/untrusted child process from filling the app's memory with stdout. +pub(super) const MAX_READ_BYTES: usize = 256 * 1024 * 1024; + +/// Hard deadline for one helper invocation — generous for a MAX_READ_BYTES read, but bounded so +/// a wedged helper can never pin a sync command thread (and with it the renderer's coalesced +/// request slot for that session) until app restart. Batches get longer since they serve many +/// files in one spawn. +#[cfg(target_os = "macos")] +pub(super) const HELPER_TIMEOUT: Duration = Duration::from_secs(15); +#[cfg(target_os = "macos")] +pub(super) const HELPER_BATCH_TIMEOUT: Duration = Duration::from_secs(45); + +/// Byte budget for the helper-read cache (see helper_cache) — cleared wholesale when exceeded, +/// mirroring the search cache's crude-but-safe policy. +pub(super) const HELPER_CACHE_BUDGET: usize = 256 * 1024 * 1024; + +pub(super) const QODER_READ_SCRIPT: &str = + "const fs=require(\"fs\");process.stdout.write(fs.readFileSync(process.argv[1]))"; + +/// Batch counterpart: one line of JSON per argv file — content as base64 (`b64`) or a per-file +/// error (`err`) that must not abort the rest of the batch. +#[cfg(target_os = "macos")] +pub(super) const QODER_BATCH_READ_SCRIPT: &str = "const fs=require(\"fs\");for(const p of process.argv.slice(1)){let line;try{line=JSON.stringify({p,b64:fs.readFileSync(p).toString(\"base64\")})}catch(e){line=JSON.stringify({p,err:String(e&&e.code||e)})}process.stdout.write(line+\"\\n\")}"; diff --git a/src-tauri/src/qoder/meta.rs b/src-tauri/src/qoder/meta.rs new file mode 100644 index 0000000..f21ac83 --- /dev/null +++ b/src-tauri/src/qoder/meta.rs @@ -0,0 +1,34 @@ +// Per-session identity and the shared foreign-CLI sidecar (title / tags / soft-delete). Moved +// verbatim from qoder.rs. + +use serde_json::{json, Value}; +use std::path::Path; + +/// The session uuid (its file stem) — sidecar key and renderer id both build on it. +fn session_stem(file: &Path) -> String { + file.file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("") + .to_string() +} + +fn sidecar_key(file: &Path) -> String { + format!("qoder:{}", session_stem(file)) +} + +/// (custom title, tags, deleted) from the shared agent sidecar (~/.ccbud/agent-meta.json). +pub fn sidecar_meta(file: &Path) -> (Option, Vec, bool) { + crate::sidecar::meta(&crate::sidecar::agent_file(), &sidecar_key(file)) +} + +pub fn is_deleted(file: &Path) -> bool { + sidecar_meta(file).2 +} + +pub fn set_meta(file: &str, patch: &Value) -> Value { + let key = sidecar_key(Path::new(file)); + if key == "qoder:" { + return json!({ "ok": false, "reason": "empty" }); + } + crate::sidecar::set_meta(&crate::sidecar::agent_file(), &key, patch) +} diff --git a/src-tauri/src/qoder/mod.rs b/src-tauri/src/qoder/mod.rs new file mode 100644 index 0000000..ee76e79 --- /dev/null +++ b/src-tauri/src/qoder/mod.rs @@ -0,0 +1,32 @@ +// Qoder CLI session support — Qoder writes Claude-like transcripts into its own trees +// (`~/.qoder/projects//.jsonl` and the same layout under `~/.qoderwork`, +// subagents in `/subagents/agent-*.jsonl`). Qoder streams assistant content as atomic +// wrappers and stores title/workspace/runtime metadata inline, so this module provides the small +// normalization layer needed by the normal Claude pipeline, plus root discovery, safe reads, +// path routing, and the shared foreign-CLI sidecar. The source files belong to another tool and +// are never rewritten, which also means hard-delete refuses them (history.rs). + +#![allow(dead_code)] +mod guard; +mod limits; +mod meta; +mod normalize; +mod prefetch; +mod read; +mod roots; +mod titles; +#[cfg(target_os = "macos")] +mod exec; +#[cfg(target_os = "macos")] +mod helper; +#[cfg(test)] +mod tests; +#[cfg(test)] +mod tests_more; + +pub use meta::{is_deleted, set_meta, sidecar_meta}; +pub use roots::{default_root, looks_qoder_path, root_exists, work_root}; +pub(crate) use normalize::{looks_qoder_records, normalize_records}; +pub(crate) use prefetch::prefetch; +pub(crate) use read::{read_bytes, read_text}; +pub(crate) use titles::{model_from, session_title_from, working_dir_from}; diff --git a/src-tauri/src/qoder/normalize.rs b/src-tauri/src/qoder/normalize.rs new file mode 100644 index 0000000..ae28b0d --- /dev/null +++ b/src-tauri/src/qoder/normalize.rs @@ -0,0 +1,136 @@ +// Converting Qoder's append-only wire records into the Claude-like records the shared history +// shaper consumes, plus the content sniff for transcripts that lost their container path. Moved +// verbatim from qoder.rs. + +use serde_json::{json, Value}; +use std::collections::HashMap; + +fn has_value(value: &Value) -> bool { + match value { + Value::Null => false, + Value::String(value) => !value.trim().is_empty(), + Value::Array(value) => !value.is_empty(), + Value::Object(value) => !value.is_empty(), + Value::Bool(_) | Value::Number(_) => true, + } +} + +fn without_redacted_thinking(record: &Value) -> Value { + let mut record = record.clone(); + if let Some(content) = record + .get_mut("message") + .and_then(|message| message.get_mut("content")) + .and_then(Value::as_array_mut) + { + content + .retain(|block| block.get("type").and_then(Value::as_str) != Some("redacted_thinking")); + } + record +} + +fn merge_assistant_wrapper(target: &mut Value, wrapper: &Value) { + let Some(source_message) = wrapper.get("message").and_then(Value::as_object) else { + return; + }; + let Some(target_message) = target.get_mut("message").and_then(Value::as_object_mut) else { + return; + }; + + if let Some(source_content) = source_message.get("content").and_then(Value::as_array) { + match target_message.get_mut("content") { + Some(Value::Array(target_content)) => { + target_content.extend(source_content.iter().cloned()) + } + Some(Value::Null) | None => { + target_message.insert("content".to_string(), Value::Array(source_content.clone())); + } + Some(_) => {} + } + } + + for field in ["model", "usage", "stop_reason"] { + if let Some(value) = source_message.get(field).filter(|value| has_value(value)) { + target_message.insert(field.to_string(), value.clone()); + } + } +} + +fn queued_command_as_user(record: &Value) -> Option { + let attachment = record.get("attachment")?; + if attachment.get("type").and_then(Value::as_str) != Some("queued_command") { + return None; + } + let prompt = attachment + .get("prompt") + .and_then(Value::as_str) + .unwrap_or_default() + .to_owned(); + let mut normalized = record.clone(); + let object = normalized.as_object_mut()?; + object.insert("type".to_string(), Value::String("user".to_string())); + object.insert( + "message".to_string(), + json!({ "role": "user", "content": prompt }), + ); + object.remove("attachment"); + Some(normalized) +} + +/// Content sniff for qoder transcripts that lost their container path (import copies, bundle +/// zips): the inline metadata / queued-command record types are qoder-only vocabulary that no +/// Claude Code or Codex transcript produces. +pub(crate) fn looks_qoder_records(records: &[Value]) -> bool { + records.iter().any(|record| match record.get("type").and_then(Value::as_str) { + Some("agent-setting") | Some("ai-title") | Some("custom-title") | Some("last-prompt") + | Some("workspace-directories") | Some("runtime-config") => true, + Some("attachment") => { + record + .get("attachment") + .and_then(|attachment| attachment.get("type")) + .and_then(Value::as_str) + == Some("queued_command") + } + _ => false, + }) +} + +/// Convert Qoder's append-only wire records into the Claude-like records expected by the shared +/// history shaper. Atomic assistant wrappers with the same `message.id` collapse at their first +/// position, queued prompts become user messages, and opaque duplicate thinking blocks are +/// discarded in favor of the corresponding ordinary `thinking` block. +pub(crate) fn normalize_records(records: &[Value]) -> Vec { + let mut normalized = Vec::with_capacity(records.len()); + let mut assistant_by_message_id: HashMap = HashMap::new(); + + for record in records { + if let Some(user) = queued_command_as_user(record) { + normalized.push(user); + continue; + } + if record.get("type").and_then(Value::as_str) != Some("assistant") { + normalized.push(record.clone()); + continue; + } + + let wrapper = without_redacted_thinking(record); + let message_id = wrapper + .get("message") + .and_then(|message| message.get("id")) + .and_then(Value::as_str) + .map(str::trim) + .filter(|id| !id.is_empty()) + .map(str::to_owned); + if let Some(message_id) = message_id { + if let Some(index) = assistant_by_message_id.get(&message_id).copied() { + merge_assistant_wrapper(&mut normalized[index], &wrapper); + } else { + assistant_by_message_id.insert(message_id, normalized.len()); + normalized.push(wrapper); + } + } else { + normalized.push(wrapper); + } + } + + normalized +} diff --git a/src-tauri/src/qoder/prefetch.rs b/src-tauri/src/qoder/prefetch.rs new file mode 100644 index 0000000..59b95ee --- /dev/null +++ b/src-tauri/src/qoder/prefetch.rs @@ -0,0 +1,141 @@ +// Batch helper warm-up: one helper invocation per data root instead of one per file, plus the +// small base64 decoder its output needs. Moved verbatim from qoder.rs. + +use std::path::PathBuf; + +#[cfg(target_os = "macos")] +use serde_json::Value; +#[cfg(target_os = "macos")] +use std::collections::HashMap; +#[cfg(target_os = "macos")] +use std::fs; +#[cfg(target_os = "macos")] +use std::io; +#[cfg(target_os = "macos")] +use std::path::Path; +#[cfg(target_os = "macos")] +use std::process::Command; +#[cfg(target_os = "macos")] +use std::sync::Arc; +#[cfg(target_os = "macos")] +use super::exec::run_helper_bounded; +#[cfg(target_os = "macos")] +use super::guard::validated_qoder_data_path; +#[cfg(target_os = "macos")] +use super::helper::{installed_qoder_helper, verify_helper_trust}; +#[cfg(target_os = "macos")] +use super::limits::{HELPER_BATCH_TIMEOUT, MAX_READ_BYTES, QODER_BATCH_READ_SCRIPT}; +#[cfg(target_os = "macos")] +use super::read::{file_stamp, helper_cache_get, helper_cache_put}; + +/// Minimal standard-alphabet base64 decoder for the batch helper's output (node/bun emit padded +/// base64 without line breaks; stray CR/LF are tolerated anyway). +pub(super) fn b64_decode(s: &str) -> Option> { + fn val(b: u8) -> Option { + match b { + b'A'..=b'Z' => Some((b - b'A') as u32), + b'a'..=b'z' => Some((b - b'a' + 26) as u32), + b'0'..=b'9' => Some((b - b'0' + 52) as u32), + b'+' => Some(62), + b'/' => Some(63), + _ => None, + } + } + let bytes = s.as_bytes(); + let mut out = Vec::with_capacity(bytes.len() / 4 * 3); + let mut chunk = [0u32; 4]; + let mut n = 0usize; + let mut pad = 0usize; + for &b in bytes { + if b == b'\r' || b == b'\n' { + continue; + } + if b == b'=' { + pad += 1; + chunk[n] = 0; + } else { + if pad > 0 { + return None; // data after padding + } + chunk[n] = val(b)?; + } + n += 1; + if n == 4 { + let v = (chunk[0] << 18) | (chunk[1] << 12) | (chunk[2] << 6) | chunk[3]; + out.push((v >> 16) as u8); + if pad < 2 { + out.push((v >> 8) as u8); + } + if pad < 1 { + out.push(v as u8); + } + n = 0; + if pad > 0 { + break; + } + } + } + (n == 0).then_some(out) +} + +/// Warm the helper cache for many qoder files with ONE helper invocation per data root — the +/// list, search, usage, and subagent scans otherwise pay one bun startup per file on a protected +/// macOS install (the measured stall is seconds for a first refresh). Directly-readable and +/// fresh-cached files are skipped; per-file failures fall back to the on-demand single read. +/// No-op off macOS. +pub(crate) fn prefetch(paths: &[PathBuf]) { + #[cfg(target_os = "macos")] + prefetch_macos(paths); + #[cfg(not(target_os = "macos"))] + let _ = paths; +} + +#[cfg(target_os = "macos")] +fn prefetch_macos(paths: &[PathBuf]) { + // (canonical helper target, original cache key, stamp) + let mut by_root: HashMap> = HashMap::new(); + for path in paths { + let Ok((mt, size)) = file_stamp(path) else { continue }; + if size > MAX_READ_BYTES as u64 || helper_cache_get(path, mt, size).is_some() { + continue; + } + match fs::File::open(path) { + Ok(_) => continue, // direct reads work — the ordinary path is cheap + Err(error) if error.kind() == io::ErrorKind::PermissionDenied => {} + Err(_) => continue, + } + let Ok((canonical, root)) = validated_qoder_data_path(path) else { continue }; + by_root.entry(root).or_default().push((canonical, path.clone(), mt, size)); + } + for (root, files) in by_root { + let Ok(helper) = installed_qoder_helper(&root) else { continue }; + if verify_helper_trust(&helper).is_err() { + continue; + } + // Small argv chunks keep each spawn's total output within the shared byte cap and far + // below ARG_MAX; a lost chunk (timeout/oversize) degrades to per-file reads, not failure. + for chunk in files.chunks(32) { + let mut cmd = Command::new(&helper); + cmd.env("BUN_BE_BUN", "1").arg("-e").arg(QODER_BATCH_READ_SCRIPT); + for (canonical, _, _, _) in chunk { + cmd.arg(canonical); + } + let Ok(out) = run_helper_bounded(cmd, MAX_READ_BYTES, HELPER_BATCH_TIMEOUT, 0) else { + continue; + }; + let by_canonical: HashMap<&Path, (&PathBuf, f64, u64)> = chunk + .iter() + .map(|(canonical, original, mt, size)| (canonical.as_path(), (original, *mt, *size))) + .collect(); + for line in out.split(|b| *b == b'\n') { + let Ok(row) = serde_json::from_slice::(line) else { continue }; + let Some(p) = row.get("p").and_then(Value::as_str) else { continue }; + let Some(&(original, mt, size)) = by_canonical.get(Path::new(p)) else { continue }; + let Some(bytes) = row.get("b64").and_then(Value::as_str).and_then(b64_decode) else { + continue; // per-file err rows fall back to the single-read path on demand + }; + helper_cache_put(original, mt, size, Arc::new(bytes)); + } + } + } +} diff --git a/src-tauri/src/qoder/read.rs b/src-tauri/src/qoder/read.rs new file mode 100644 index 0000000..81db2c0 --- /dev/null +++ b/src-tauri/src/qoder/read.rs @@ -0,0 +1,147 @@ +// Reading Qoder data files: the ordinary filesystem path, the macOS helper fallback, and the +// (mtime, size) memo that keeps repeat reads of the same file version off the helper. Moved +// verbatim from qoder.rs. + +use std::collections::HashMap; +use std::fs; +use std::io; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex, OnceLock}; + +use super::limits::{HELPER_CACHE_BUDGET, MAX_READ_BYTES}; +use super::guard::too_large; + +#[cfg(target_os = "macos")] +use std::process::Command; +#[cfg(target_os = "macos")] +use super::exec::run_helper_bounded; +#[cfg(target_os = "macos")] +use super::guard::validated_qoder_data_path; +#[cfg(target_os = "macos")] +use super::helper::{installed_qoder_helper, verify_helper_trust}; +#[cfg(target_os = "macos")] +use super::limits::{HELPER_TIMEOUT, QODER_READ_SCRIPT}; + +#[cfg(target_os = "macos")] +fn read_with_qoder_helper(path: &Path) -> io::Result> { + let (path, root) = validated_qoder_data_path(path)?; + let helper = installed_qoder_helper(&root)?; + verify_helper_trust(&helper)?; + let expected_len = fs::metadata(&path)?.len().min(MAX_READ_BYTES as u64) as usize; + + // Qoder CLI is a Bun executable. Passing the fixed program and target as distinct argv + // entries is important: never interpolate a path into JavaScript or a shell command. + let mut cmd = Command::new(helper); + cmd.env("BUN_BE_BUN", "1").arg("-e").arg(QODER_READ_SCRIPT).arg(&path); + run_helper_bounded(cmd, MAX_READ_BYTES, HELPER_TIMEOUT, expected_len) +} + +/// Read a local history file. The normal filesystem path is always attempted first; macOS may +/// fall back to Qoder's already-installed CLI only for a permission denial, and only after the +/// helper target passes the strict projects-tree validation above. +pub(crate) fn read_bytes(path: &Path) -> io::Result> { + match fs::read(path) { + Ok(bytes) if bytes.len() <= MAX_READ_BYTES => Ok(bytes), + Ok(_) => Err(too_large()), + Err(error) if error.kind() == io::ErrorKind::PermissionDenied => { + #[cfg(target_os = "macos")] + { + // Serve repeat reads of the same file version from the helper cache — stat still + // works on content-protected files, so (mtime, size) is a valid freshness key. + let stamp = file_stamp(path).ok(); + if let Some((mt, size)) = stamp { + if let Some(hit) = helper_cache_get(path, mt, size) { + return Ok(hit); + } + } + read_with_qoder_helper(path) + .map(|bytes| { + if let Some((mt, size)) = stamp { + helper_cache_put(path, mt, size, Arc::new(bytes.clone())); + } + bytes + }) + .map_err(|helper_error| { + if matches!( + helper_error.kind(), + io::ErrorKind::NotFound | io::ErrorKind::PermissionDenied + ) { + // The original file read was a permission failure. A missing helper + // must not turn that into NotFound, which callers interpret as a + // moved file. + io::Error::new( + io::ErrorKind::PermissionDenied, + format!( + "Qoder data is not directly readable and its CLI helper is unavailable: {helper_error}" + ), + ) + } else { + // Size/encoding failures, timeouts, and abnormal helper exits retain + // their distinct classification so the UI reports a read failure, + // not an auth hint. + helper_error + } + }) + } + #[cfg(not(target_os = "macos"))] + { + Err(error) + } + } + Err(error) => Err(error), + } +} + +/// UTF-8 text counterpart to [`read_bytes`]. Qoder's JSON/JSONL data is defined as UTF-8, so +/// malformed data is reported rather than silently replacing bytes and corrupting records. +pub(crate) fn read_text(path: &Path) -> io::Result { + String::from_utf8(read_bytes(path)?) + .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error)) +} + +pub(super) fn mtime_ms_of(meta: &fs::Metadata) -> f64 { + meta.modified() + .ok() + .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|d| d.as_millis() as f64) + .unwrap_or(0.0) +} + +pub(super) fn file_stamp(path: &Path) -> io::Result<(f64, u64)> { + let meta = fs::metadata(path)?; + Ok((mtime_ms_of(&meta), meta.len())) +} + +/// Bytes fetched through the macOS helper, memoized by (mtime, size) — the list/search/detail +/// paths and the 4s live-follow tick otherwise each pay a bun startup for the SAME file version. +/// Stat keeps working on content-protected files (discovery depends on it), so the stamp is the +/// same freshness signal the list-meta memo uses. +struct HelperCache { + map: HashMap>)>, + bytes: usize, +} + +fn helper_cache() -> &'static Mutex { + static CACHE: OnceLock> = OnceLock::new(); + CACHE.get_or_init(|| Mutex::new(HelperCache { map: HashMap::new(), bytes: 0 })) +} + +pub(super) fn helper_cache_get(path: &Path, mt: f64, size: u64) -> Option> { + let cache = helper_cache().lock().ok()?; + let (cmt, csz, bytes) = cache.map.get(path)?; + (*cmt == mt && *csz == size).then(|| bytes.as_ref().clone()) +} + +pub(super) fn helper_cache_put(path: &Path, mt: f64, size: u64, bytes: Arc>) { + if let Ok(mut cache) = helper_cache().lock() { + if cache.bytes + bytes.len() > HELPER_CACHE_BUDGET { + cache.map.clear(); + cache.bytes = 0; + } + let len = bytes.len(); + if let Some((_, _, old)) = cache.map.insert(path.to_path_buf(), (mt, size, bytes)) { + cache.bytes = cache.bytes.saturating_sub(old.len()); + } + cache.bytes += len; + } +} diff --git a/src-tauri/src/qoder/roots.rs b/src-tauri/src/qoder/roots.rs new file mode 100644 index 0000000..46ce853 --- /dev/null +++ b/src-tauri/src/qoder/roots.rs @@ -0,0 +1,48 @@ +// Qoder data roots and the container-shape path test used for routing. Moved verbatim from +// qoder.rs. + +use std::path::{Path, PathBuf}; + +pub(super) fn home() -> PathBuf { + std::env::var("HOME") + .map(PathBuf::from) + .unwrap_or_else(|_| PathBuf::from(".")) +} + +/// Qoder's two known data roots (both observed in the wild): each is a history-dir entry +/// candidate for the auto-add migration; browsing itself walks every configured dir's +/// `projects/` tree, so these only seed historyDirs. +pub fn default_root() -> PathBuf { + home().join(".qoder") +} + +pub fn work_root() -> PathBuf { + home().join(".qoderwork") +} + +/// A qoder install exists at `root` when its projects tree is on disk. +pub fn root_exists(root: &Path) -> bool { + root.join("projects").is_dir() +} + +/// Container-shape test for routing: a .jsonl anywhere under a `.qoder/projects/` or +/// `.qoderwork/projects/` tree (main sessions AND `/subagents/agent-*.jsonl`). +pub fn looks_qoder_path(file: &Path) -> bool { + if file.extension().and_then(|e| e.to_str()) != Some("jsonl") { + return false; + } + let mut child: Option<&std::ffi::OsStr> = None; + for anc in file.ancestors().skip(1) { + let name = match anc.file_name() { + Some(n) => n, + None => break, + }; + if (name == ".qoder" || name == ".qoderwork") + && child.map(|c| c == "projects").unwrap_or(false) + { + return true; + } + child = Some(name); + } + false +} diff --git a/src-tauri/src/qoder/tests.rs b/src-tauri/src/qoder/tests.rs new file mode 100644 index 0000000..0365a9a --- /dev/null +++ b/src-tauri/src/qoder/tests.rs @@ -0,0 +1,162 @@ +use super::*; +use serde_json::json; +use std::path::Path; + +#[test] +fn detects_qoder_paths() { + assert!(looks_qoder_path(Path::new( + "/h/.qoder/projects/-Users-a-p/1111-uuid.jsonl" + ))); + assert!(looks_qoder_path(Path::new( + "/h/.qoderwork/projects/-Users-a-p/1111-uuid.jsonl" + ))); + // subagent transcripts under the session's own dir route too (search scans them) + assert!(looks_qoder_path(Path::new( + "/h/.qoder/projects/-enc/1111-uuid/subagents/agent-x.jsonl" + ))); + assert!(!looks_qoder_path(Path::new( + "/h/.claude/projects/-enc/1111-uuid.jsonl" + ))); + assert!(!looks_qoder_path(Path::new( + "/h/.qoder/projects/-enc/session-state.json" + ))); + // projects/ must be DIRECTLY under the qoder root + assert!(!looks_qoder_path(Path::new( + "/h/.qoder/sessions/-enc/1111-uuid.jsonl" + ))); + assert!(!looks_qoder_path(Path::new( + "/h/qoder/projects/-enc/1111-uuid.jsonl" + ))); +} + +#[test] +fn extracts_inline_title_workspace_and_runtime_metadata() { + let records = vec![ + json!({ "type": "user", "isMeta": true, "message": { "content": "hidden setup" } }), + json!({ "type": "user", "message": { "content": [{ "type": "tool_result", "content": "not a title" }] } }), + json!({ "type": "user", "message": { "content": " First real prompt " } }), + json!({ "type": "summary", "summary": " Summary fallback " }), + json!({ "type": "last-prompt", "lastPrompt": " Older prompt " }), + json!({ "type": "last-prompt", "lastPrompt": " Latest prompt " }), + json!({ "type": "ai-title", "aiTitle": " Generated title " }), + json!({ "type": "custom-title", "customTitle": " " }), + json!({ "type": "custom-title", "customTitle": " Chosen title " }), + json!({ "type": "workspace-directories", "directories": ["/old/workspace"] }), + json!({ "type": "workspace-directories", "directories": [" ", "/work/project", "/work/secondary"] }), + json!({ "type": "runtime-config", "model": "basic" }), + json!({ "type": "runtime-config", "model": " " }), + json!({ "type": "runtime-config", "model": "ultimate" }), + ]; + + assert_eq!( + session_title_from(&records).as_deref(), + Some("Chosen title") + ); + assert_eq!(working_dir_from(&records).as_deref(), Some("/work/project")); + assert_eq!(model_from(&records).as_deref(), Some("ultimate")); + + assert_eq!( + session_title_from(&records[..8]).as_deref(), + Some("Generated title") + ); + assert_eq!( + session_title_from(&records[..6]).as_deref(), + Some("Latest prompt") + ); + assert_eq!( + session_title_from(&records[..4]).as_deref(), + Some("Summary fallback") + ); + assert_eq!( + session_title_from(&records[..3]).as_deref(), + Some("First real prompt") + ); +} + +#[test] +fn normalizes_atomic_assistant_wrappers_and_queued_commands() { + let records = vec![ + json!({ "type": "runtime-config", "model": "ultimate" }), + json!({ + "type": "assistant", "uuid": "wrapper-1", "timestamp": "2026-01-01T00:00:00Z", + "message": { + "id": "message-1", "role": "assistant", "model": "draft", + "content": [ + { "type": "thinking", "thinking": "plan" }, + { "type": "redacted_thinking", "data": "opaque duplicate" } + ], + "usage": { "input_tokens": 1 }, "stop_reason": null + } + }), + json!({ + "type": "assistant", "uuid": "wrapper-2", "timestamp": "2026-01-01T00:00:01Z", + "message": { + "id": "message-1", "role": "assistant", "model": "ultimate", + "content": [{ "type": "text", "text": "checking" }], + "usage": null, "stop_reason": "tool_use" + } + }), + json!({ + "type": "assistant", "uuid": "wrapper-3", "timestamp": "2026-01-01T00:00:02Z", + "message": { + "id": "message-1", "role": "assistant", "model": " ", + "content": [{ "type": "tool_use", "id": "tool-1", "name": "Read", "input": { "file_path": "/work/file" } }], + "usage": { "input_tokens": 7, "output_tokens": 3 }, "stop_reason": null + } + }), + json!({ + "type": "attachment", "uuid": "queued-1", "cwd": "/work/project", + "attachment": { "type": "queued_command", "prompt": "follow up", "commandMode": "agent" } + }), + json!({ + "type": "assistant", "uuid": "wrapper-without-id", + "message": { "role": "assistant", "content": [ + { "type": "redacted_thinking", "data": "drop me" }, + { "type": "text", "text": "kept" } + ] } + }), + ]; + + let normalized = normalize_records(&records); + assert_eq!(normalized.len(), 4); + assert_eq!(normalized[0]["type"], "runtime-config"); + + let assistant = &normalized[1]; + assert_eq!(assistant["uuid"], "wrapper-1"); + assert_eq!(assistant["timestamp"], "2026-01-01T00:00:00Z"); + assert_eq!(assistant["message"]["model"], "ultimate"); + assert_eq!( + assistant["message"]["usage"], + json!({ "input_tokens": 7, "output_tokens": 3 }) + ); + assert_eq!(assistant["message"]["stop_reason"], "tool_use"); + assert_eq!( + assistant["message"]["content"] + .as_array() + .unwrap() + .iter() + .map(|block| block["type"].as_str().unwrap()) + .collect::>(), + vec!["thinking", "text", "tool_use"] + ); + + let queued = &normalized[2]; + assert_eq!(queued["type"], "user"); + assert_eq!(queued["uuid"], "queued-1"); + assert_eq!(queued["cwd"], "/work/project"); + assert_eq!( + queued["message"], + json!({ "role": "user", "content": "follow up" }) + ); + assert!(queued.get("attachment").is_none()); + + assert_eq!( + normalized[3]["message"]["content"], + json!([{ "type": "text", "text": "kept" }]) + ); + // The caller's parsed records remain untouched. + assert_eq!( + records[1]["message"]["content"].as_array().unwrap().len(), + 2 + ); +} diff --git a/src-tauri/src/qoder/tests_more.rs b/src-tauri/src/qoder/tests_more.rs new file mode 100644 index 0000000..246af25 --- /dev/null +++ b/src-tauri/src/qoder/tests_more.rs @@ -0,0 +1,121 @@ +use super::*; +use super::guard::validated_qoder_data_path; +use super::prefetch::b64_decode; +use super::read::{helper_cache_get, helper_cache_put}; +use serde_json::json; +use std::fs; +use std::io; +use std::path::PathBuf; +use std::sync::Arc; + +fn test_dir(name: &str) -> PathBuf { + std::env::temp_dir().join(format!("ccbud-qoder-{name}-{}", std::process::id())) +} + +#[test] +fn decodes_batch_helper_base64() { + assert_eq!(b64_decode("").unwrap(), b""); + assert_eq!(b64_decode("aGVsbG8=").unwrap(), b"hello"); + assert_eq!(b64_decode("aGVsbG8h").unwrap(), b"hello!"); + assert_eq!(b64_decode("aA==").unwrap(), b"h"); + assert_eq!(b64_decode("5Lit5paH").unwrap(), "中文".as_bytes()); + assert!(b64_decode("not base64!").is_none()); + assert!(b64_decode("aGVsbG8").is_none()); // truncated group +} + +#[test] +fn helper_cache_serves_only_fresh_stamps() { + let path = test_dir("cache").join("t.jsonl"); + assert!(helper_cache_get(&path, 1.0, 10).is_none()); + helper_cache_put(&path, 1.0, 10, Arc::new(b"v1".to_vec())); + assert_eq!(helper_cache_get(&path, 1.0, 10).unwrap(), b"v1"); + // a changed mtime or size means a new file version — the stale entry must not serve + assert!(helper_cache_get(&path, 2.0, 10).is_none()); + assert!(helper_cache_get(&path, 1.0, 11).is_none()); + helper_cache_put(&path, 2.0, 10, Arc::new(b"v2".to_vec())); + assert_eq!(helper_cache_get(&path, 2.0, 10).unwrap(), b"v2"); +} + +#[test] +fn sniffs_qoder_records_by_inline_vocabulary() { + assert!(looks_qoder_records(&[json!({ "type": "ai-title", "aiTitle": "t" })])); + assert!(looks_qoder_records(&[ + json!({ "type": "user", "message": { "content": "hi" } }), + json!({ "type": "attachment", "attachment": { "type": "queued_command", "prompt": "p" } }), + ])); + // plain Claude / Codex shapes must not sniff as qoder + assert!(!looks_qoder_records(&[ + json!({ "type": "user", "message": { "content": "hi" }, "cwd": "/x" }), + json!({ "type": "assistant", "message": { "role": "assistant", "content": [] } }), + json!({ "type": "attachment", "attachment": { "type": "file" } }), + json!({ "type": "session_meta", "payload": {} }), + ])); +} + +#[test] +fn ordinary_reads_do_not_require_a_qoder_path() { + let dir = test_dir("ordinary-read"); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + let file = dir.join("ordinary.txt"); + fs::write(&file, "local UTF-8 文本").unwrap(); + + assert_eq!(read_bytes(&file).unwrap(), "local UTF-8 文本".as_bytes()); + assert_eq!(read_text(&file).unwrap(), "local UTF-8 文本"); + + fs::write(&file, [0xff, 0xfe]).unwrap(); + assert_eq!( + read_text(&file).unwrap_err().kind(), + io::ErrorKind::InvalidData + ); + let _ = fs::remove_dir_all(&dir); +} + +#[test] +fn helper_target_is_limited_to_canonical_qoder_project_data() { + let dir = test_dir("path-validation"); + let _ = fs::remove_dir_all(&dir); + let projects = dir.join(".qoder").join("projects"); + let session = projects.join("-encoded-cwd").join("session-id"); + fs::create_dir_all(&session).unwrap(); + + let transcript = projects.join("-encoded-cwd").join("session-id.jsonl"); + let state = session.join("state.json"); + let metadata = session.join("agent-worker.meta.json"); + fs::write(&transcript, "{}\n").unwrap(); + fs::write(&state, "{}").unwrap(); + fs::write(&metadata, "{}").unwrap(); + + for file in [&transcript, &state, &metadata] { + let (validated, root) = validated_qoder_data_path(file).unwrap(); + assert_eq!(validated, fs::canonicalize(file).unwrap()); + assert_eq!(root, fs::canonicalize(dir.join(".qoder")).unwrap()); + } + + let arbitrary = session.join("secret.txt"); + fs::write(&arbitrary, "not helper-readable").unwrap(); + assert_eq!( + validated_qoder_data_path(&arbitrary).unwrap_err().kind(), + io::ErrorKind::PermissionDenied + ); + + let outside = dir.join("outside.jsonl"); + fs::write(&outside, "{}\n").unwrap(); + assert_eq!( + validated_qoder_data_path(&outside).unwrap_err().kind(), + io::ErrorKind::PermissionDenied + ); + + #[cfg(unix)] + { + use std::os::unix::fs::symlink; + let escaped = session.join("escaped.jsonl"); + symlink(&outside, &escaped).unwrap(); + assert_eq!( + validated_qoder_data_path(&escaped).unwrap_err().kind(), + io::ErrorKind::PermissionDenied + ); + } + + let _ = fs::remove_dir_all(&dir); +} diff --git a/src-tauri/src/qoder/titles.rs b/src-tauri/src/qoder/titles.rs new file mode 100644 index 0000000..6ea9f14 --- /dev/null +++ b/src-tauri/src/qoder/titles.rs @@ -0,0 +1,130 @@ +// Title / workspace / model extraction from Qoder's inline metadata records. Moved verbatim +// from qoder.rs. + +use serde_json::Value; + +fn trimmed_string(value: Option<&Value>) -> Option { + value + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_owned) +} + +fn latest_inline_string(records: &[Value], record_type: &str, field: &str) -> Option { + records.iter().rev().find_map(|record| { + (record.get("type").and_then(Value::as_str) == Some(record_type)) + .then(|| trimmed_string(record.get(field))) + .flatten() + }) +} + +fn text_content(value: &Value) -> Option { + if let Some(text) = value.as_str() { + let text = text.trim(); + return (!text.is_empty()).then(|| text.to_owned()); + } + if let Some(blocks) = value.as_array() { + let parts: Vec<&str> = blocks + .iter() + .filter_map(|block| { + if let Some(text) = block.as_str() { + return Some(text); + } + let kind = block.get("type").and_then(Value::as_str).unwrap_or(""); + matches!(kind, "text" | "input_text") + .then(|| block.get("text").and_then(Value::as_str)) + .flatten() + }) + .map(str::trim) + .filter(|part| !part.is_empty()) + .collect(); + if !parts.is_empty() { + return Some(parts.join("\n")); + } + } + value + .get("text") + .and_then(Value::as_str) + .map(str::trim) + .filter(|text| !text.is_empty()) + .map(str::to_owned) +} + +fn summary_from(records: &[Value]) -> Option { + records.iter().rev().find_map(|record| { + if record.get("type").and_then(Value::as_str) != Some("summary") { + return None; + } + trimmed_string(record.get("summary")) + .or_else(|| record.get("content").and_then(text_content)) + .or_else(|| { + record + .get("message") + .and_then(|message| message.get("content")) + .and_then(text_content) + }) + }) +} + +fn first_user_text_from(records: &[Value]) -> Option { + records + .iter() + .find_map(|record| match record.get("type").and_then(Value::as_str) { + Some("user") + if record.get("isMeta").and_then(Value::as_bool) != Some(true) + && record.get("isCompactSummary").and_then(Value::as_bool) != Some(true) => + { + record + .get("message") + .and_then(|message| message.get("content")) + .and_then(text_content) + } + Some("attachment") + if record + .get("attachment") + .and_then(|attachment| attachment.get("type")) + .and_then(Value::as_str) + == Some("queued_command") => + { + trimmed_string( + record + .get("attachment") + .and_then(|attachment| attachment.get("prompt")), + ) + } + _ => None, + }) +} + +/// Qoder's inline title, in the same precedence used by its own conversation list. Repeated +/// metadata records are append-only updates, so the last non-empty value wins within each tier. +pub(crate) fn session_title_from(records: &[Value]) -> Option { + latest_inline_string(records, "custom-title", "customTitle") + .or_else(|| latest_inline_string(records, "ai-title", "aiTitle")) + .or_else(|| latest_inline_string(records, "last-prompt", "lastPrompt")) + .or_else(|| summary_from(records)) + .or_else(|| first_user_text_from(records)) +} + +/// Primary workspace from Qoder's latest inline `workspace-directories` record. +pub(crate) fn working_dir_from(records: &[Value]) -> Option { + records.iter().rev().find_map(|record| { + if record.get("type").and_then(Value::as_str) != Some("workspace-directories") { + return None; + } + record + .get("directories") + .and_then(Value::as_array) + .and_then(|directories| { + directories + .iter() + .find_map(|value| trimmed_string(Some(value))) + }) + }) +} + +/// Effective model from Qoder's latest inline `runtime-config` update. +pub(crate) fn model_from(records: &[Value]) -> Option { + latest_inline_string(records, "runtime-config", "model") +} diff --git a/src-tauri/src/startup.rs b/src-tauri/src/startup.rs new file mode 100644 index 0000000..571608a --- /dev/null +++ b/src-tauri/src/startup.rs @@ -0,0 +1,201 @@ +// App boot sequencing. +// +// Cold-start rule: the Tauri main thread must only build the window, tray and event hooks. +// Every filesystem-heavy step that used to run synchronously inside `setup()` — one-time +// dir migrations, plugin reconcile, CLI connection repair, gateway/plugin start, history +// watcher registration and the usage-cache warm — runs here, off the main thread, in the +// same relative order as before. This is what keeps the window from showing a white, +// busy-cursor shell while the disk is being scanned. + +use serde_json::{json, Value}; +use tauri::{Emitter, Manager}; + +/// Fire-and-forget boot chain. Ordering preserved from the old synchronous setup(): +/// 1. one-time historyDirs migrations (must precede the watcher so new trees get watched) +/// 2. plugin reconcile + persisted CLI connection repair + login-item refresh +/// 3. gateway + active plugin start +/// 4. history watcher registration + usage cache warm +/// 5. tray refresh + change events so an already-loaded renderer reconciles +pub fn spawn_background_boot( + app: tauri::AppHandle, + gw: std::sync::Arc, + pm: std::sync::Arc, + startup_cfg: Value, +) { + tauri::async_runtime::spawn(async move { + let pm_fs = pm.clone(); + let app_fs = app.clone(); + let open_at_login = startup_cfg + .get("openAtLogin") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + let fs_phase = tauri::async_runtime::spawn_blocking(move || { + // One-time migrations: detected installs of the other coding CLIs (Codex, Grok + // Build, Copilot CLI, Antigravity CLI, Qoder) and an XDG Claude tree join + // historyDirs as regular work dirs. `|` (not `||`) — every probe must run. + let migrated = crate::store::ensure_codex_dir() + | crate::store::ensure_xdg_claude_dir() + | crate::store::ensure_grok_dir() + | crate::store::ensure_copilot_dir() + | crate::store::ensure_antigravity_dir() + | crate::store::ensure_qoder_dir(); + // Reconcile services with installed plugins, then repair previously managed + // CLI targets that remain selected. Startup never connects a first-time target + // or disconnects one. + pm_fs.sync_providers(); + let cfg = crate::store::read_config(); + crate::reconcile_connections_on_startup(&cfg); + // Rewrite the login item to the current exe path — in-place hot updates (and + // the one-time bundle rename) otherwise leave it pointing at a binary that no + // longer exists. + if open_at_login { + use tauri_plugin_autostart::ManagerExt; + let _ = app_fs.autolaunch().enable(); + } + (migrated, cfg) + }) + .await; + let (migrated, cfg) = match fs_phase { + Ok(v) => v, + Err(_) => (false, startup_cfg.clone()), + }; + + // Gateway + the active plugin-backed service (if any) — the active service would + // otherwise be dead until the user re-enables its plugin. + let port = cfg.get("port").and_then(|v| v.as_u64()).unwrap_or(8788) as u16; + let enabled = cfg.get("gatewayEnabled").and_then(|v| v.as_bool()).unwrap_or(true); + if enabled { + if let Err(e) = gw.start(port).await { + eprintln!("[ccbud] gateway start failed: {}", e); + } + } + if let Some(pid) = active_plugin_id(&cfg) { + if let Err(e) = pm.start(&pid).await { + eprintln!("[ccbud] active plugin '{}' start failed: {}", pid, e); + } + } + + // Watcher + usage warm live on a detached thread for the app's lifetime. + let app_watch = app.clone(); + std::thread::spawn(move || start_history_watch_and_warm(&app_watch)); + + // Reflect the real gateway/connection state in the tray, and let an already-loaded + // renderer reconcile with any config the migrations/plugin sync just changed. + crate::refresh_tray_menu(&app); + let _ = app.emit("config:changed", crate::store::read_config()); + if migrated { + let _ = app.emit("history:changed", json!({ "files": [] })); + } + }); +} + +/// Plugin id backing the active provider, if the active provider is plugin-backed. +fn active_plugin_id(cfg: &Value) -> Option { + cfg.get("activeProviderId") + .and_then(|v| v.as_str()) + .and_then(|aid| { + cfg.get("providers") + .and_then(|v| v.as_array()) + .and_then(|arr| { + arr.iter() + .find(|p| p.get("id").and_then(|v| v.as_str()) == Some(aid)) + }) + }) + .filter(|p| p.get("backend").and_then(|v| v.as_str()) == Some("plugin")) + .and_then(|p| p.get("pluginId").and_then(|v| v.as_str())) + .map(|s| s.to_string()) +} + +/// History live-watch (fs events on the projects dirs → history:changed) + the startup +/// usage-cache warm, so the FIRST popover open is instant instead of paying the cold-scan +/// cost. Recursive watch registration walks every projects tree — precisely the work that +/// must never run on the UI thread. +fn start_history_watch_and_warm(app: &tauri::AppHandle) { + use notify_debouncer_mini::{new_debouncer, notify::RecursiveMode, DebounceEventResult}; + let app_w = app.clone(); + if let Ok(mut deb) = new_debouncer( + std::time::Duration::from_millis(250), + move |res: DebounceEventResult| { + if let Ok(events) = res { + let files: Vec = events + .iter() + .map(|e| e.path.to_string_lossy().to_string()) + .collect(); + let _ = app_w.emit("history:changed", json!({ "files": files })); + // History changed → drop the stale usage cache and re-warm off-thread + // (+ refresh the tray title) so the next popover open stays instant. + crate::usage::invalidate_cache(); + let h = app_w.clone(); + std::thread::spawn(move || warm_usage_cache(&h)); + } + }, + ) { + for root in crate::history::watch_roots(&crate::store::read_config()) { + if root.is_dir() { + let _ = deb.watcher().watch(&root, RecursiveMode::Recursive); + } + } + std::mem::forget(deb); // keep watching for the app's lifetime + } + warm_usage_cache(app); +} + +/// Warm the usage cache and surface the scan shape in the settings Logs panel — the first +/// place to look when the usage numbers look wrong — then sync the tray usage title. +fn warm_usage_cache(app: &tauri::AppHandle) { + let cfg = crate::store::read_config(); + crate::usage::warm_cache(&cfg, "all"); + if let Some(g) = app.try_state::>() { + g.log("info", crate::usage::diag(&cfg, "all")); + } + crate::update_tray_title(app); +} + +/// Older installs live in "ccbud.app" (pre-1.3.4) or "CCBuddy.app" (1.3.4). The +/// in-app updater swaps the bundle's contents but never the folder itself, and +/// macOS shows CFBundleDisplayName only when the folder name matches CFBundleName +/// ("CC Buddy") — any mismatch makes the Dock and the Applications list fall back +/// to the folder name. Rename the bundle once, relaunch from the new path so +/// Launch Services re-registers it, and exit. Bails out on any obstacle +/// (translocation, read-only volume, name already taken) and keeps running under +/// the old name. +#[cfg(target_os = "macos")] +pub fn migrate_legacy_bundle_name() { + let exe = match std::env::current_exe() { + Ok(p) => p, + Err(_) => return, + }; + // exe = /.app/Contents/MacOS/ + let bundle = match exe.ancestors().nth(3) { + Some(p) + if matches!( + p.file_name().and_then(|n| n.to_str()), + Some("ccbud.app") | Some("CCBuddy.app") + ) => + { + p.to_path_buf() + } + _ => return, + }; + let target = match bundle.parent() { + Some(dir) => dir.join("CC Buddy.app"), + None => return, + }; + if target.exists() || std::fs::rename(&bundle, &target).is_err() { + return; + } + // `open -n` asks Launch Services to start a fresh instance from the new path + // (which also re-registers the name). Wait for its verdict rather than exiting + // on spawn: a refusal must restore the old name so the running process keeps a + // valid bundle path behind it instead of leaving the user with nothing open. + let launched = std::process::Command::new("/usr/bin/open") + .arg("-n") + .arg(&target) + .status() + .map(|s| s.success()) + .unwrap_or(false); + if launched { + std::process::exit(0); + } + let _ = std::fs::rename(&target, &bundle); +} diff --git a/src-tauri/src/store.rs b/src-tauri/src/store.rs deleted file mode 100644 index 81b288f..0000000 --- a/src-tauri/src/store.rs +++ /dev/null @@ -1,655 +0,0 @@ -// Config persistence. -// -// All settings live under ~/.ccbud/config.json (override the dir with CCBUD_HOME, used by -// tests/self-check). Writes are atomic (temp file + rename, mode 0600) so a crash mid-write -// never tears the file. `normalize` keeps the on-disk schema stable across releases. - -use serde_json::{json, Value}; -use std::fs; -use std::path::PathBuf; -use std::sync::{Mutex, MutexGuard, OnceLock}; - -pub fn ccbud_home() -> PathBuf { - if let Ok(d) = std::env::var("CCBUD_HOME") { - if !d.is_empty() { - return PathBuf::from(d); - } - } - let home = std::env::var("HOME").unwrap_or_else(|_| ".".into()); - PathBuf::from(home).join(".ccbud") -} - -fn config_file() -> PathBuf { - ccbud_home().join("config.json") -} - -fn config_lock() -> MutexGuard<'static, ()> { - static LOCK: OnceLock> = OnceLock::new(); - LOCK.get_or_init(|| Mutex::new(())) - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) -} - -pub fn default_config() -> Value { - json!({ - "port": 8788, - "activeProviderId": null, - "requireToken": false, - "gatewayToken": "", - "gatewayEnabled": true, - "openAtLogin": false, - "claudeBackup": null, - "trayUsage": { "enabled": false, "range": "7d" }, - "language": null, - "historyDirs": ["~/.claude"], - "historyActive": "all", - "connectTargets": [], - "retry429": { "enabled": true, "max": 3, "baseMs": 500 }, - "insecureSkipVerify": false, - "autoUpdate": { "check": true, "autoDownload": true }, - "providers": [] - }) -} - -/// Collapse a home-prefixed absolute path back to `~` form so the UI shows -/// `~/.claude` instead of `/Users//.claude`. Inverse of history::expand_tilde. -pub fn collapse_home(p: &str) -> String { - let home = std::env::var("HOME").unwrap_or_default(); - if home.is_empty() { - return p.to_string(); - } - let home = home.trim_end_matches('/'); - if p == home { - return "~".to_string(); - } - if let Some(rest) = p.strip_prefix(&format!("{}/", home)) { - return format!("~/{}", rest); - } - p.to_string() -} - -fn str_of(v: Option<&Value>) -> String { - v.and_then(|x| x.as_str()).unwrap_or("").to_string() -} -fn bool_of(v: Option<&Value>, default: bool) -> bool { - v.and_then(|x| x.as_bool()).unwrap_or(default) -} - -/// Mirror of store.js `normalize`: merge over defaults, then sanitize every field. -pub fn normalize(input: Value) -> Value { - let mut c = default_config(); - if let Value::Object(src) = &input { - let obj = c.as_object_mut().unwrap(); - for (k, v) in src { - obj.insert(k.clone(), v.clone()); - } - } - - // ---- providers ---- - let mut norm_provs: Vec = vec![]; - if let Some(Value::Array(arr)) = c.get("providers") { - for p in arr { - let name = { - let n = str_of(p.get("name")); - if n.is_empty() { "Unnamed".to_string() } else { n } - }; - let map_default = p - .get("mapDefaultModels") - .map(|v| v.as_bool().unwrap_or(true)) - .unwrap_or(true); - let mut models: Vec = vec![]; - if let Some(Value::Array(ms)) = p.get("models") { - for m in ms { - let alias = str_of(m.get("alias")); - let upstream = str_of(m.get("upstream")); - if !alias.is_empty() || !upstream.is_empty() { - models.push(json!({ "alias": alias, "upstream": upstream })); - } - } - } - // Upstream wire protocol. Default 'anthropic' = today's verbatim passthrough; the - // other two make the gateway translate Claude Code's Anthropic Messages into the - // provider's format (see src/protocol/). Anything unrecognized falls back to anthropic. - let protocol = match p.get("protocol").and_then(|v| v.as_str()) { - Some("openai-chat") => "openai-chat", - Some("openai-responses") => "openai-responses", - _ => "anthropic", - }; - // Zhipu's Anthropic-compatible endpoint is versioned. The old preset omitted `/v1`; - // its unversioned path returns HTTP 200 with an embedded `404 NOT_FOUND`, which cannot - // trigger the gateway's status-based compatibility retry. Normalize only that exact - // legacy preset URL, leaving every custom/provider URL authoritative. - let mut base_url = str_of(p.get("baseUrl")); - if protocol == "anthropic" - && base_url.trim_end_matches('/') == "https://open.bigmodel.cn/api/anthropic" - { - base_url = "https://open.bigmodel.cn/api/anthropic/v1".to_string(); - } - let mut np = json!({ - "id": p.get("id").cloned().unwrap_or(Value::Null), - "name": name, - "baseUrl": base_url, - "authToken": str_of(p.get("authToken")), - "defaultModel": str_of(p.get("defaultModel")), - "smallFastModel": str_of(p.get("smallFastModel")), - "mapDefaultModels": map_default, - "protocol": protocol, - "models": models, - }); - if let Some(ic) = p.get("icon").and_then(|v| v.as_str()) { - if !ic.trim().is_empty() { - np.as_object_mut() - .unwrap() - .insert("icon".into(), json!(ic.trim())); - } - } - // Backend type. 'http' (default) = an ordinary upstream at baseUrl. 'plugin' = fronted - // by a local sidecar plugin process (see plugin.rs); its baseUrl points at the plugin's - // localhost port, maintained by PluginManager. pluginId links back to the plugin. - let backend = match p.get("backend").and_then(|v| v.as_str()) { - Some("plugin") => "plugin", - _ => "http", - }; - np.as_object_mut() - .unwrap() - .insert("backend".into(), json!(backend)); - if backend == "plugin" { - np.as_object_mut() - .unwrap() - .insert("pluginId".into(), json!(str_of(p.get("pluginId")))); - } - norm_provs.push(np); - } - } - // activeProviderId: keep if it points at a real provider, else first provider, else null. - let active = c.get("activeProviderId").cloned().unwrap_or(Value::Null); - let active_ok = norm_provs.iter().any(|p| p.get("id") == Some(&active)); - let active = if active_ok { - active - } else { - norm_provs - .first() - .and_then(|p| p.get("id").cloned()) - .unwrap_or(Value::Null) - }; - - let obj = c.as_object_mut().unwrap(); - obj.insert("providers".into(), json!(norm_provs)); - obj.insert("activeProviderId".into(), active); - - // ---- scalars ---- - let port = obj - .get("port") - .and_then(|v| v.as_i64().or_else(|| v.as_str().and_then(|s| s.parse().ok()))) - .filter(|n| *n > 0) - .unwrap_or(8788); - obj.insert("port".into(), json!(port)); - obj.insert("requireToken".into(), json!(bool_of(obj.get("requireToken"), false))); - obj.insert("gatewayEnabled".into(), json!(bool_of(obj.get("gatewayEnabled"), true))); - obj.insert("gatewayToken".into(), json!(str_of(obj.get("gatewayToken")))); - obj.insert("openAtLogin".into(), json!(bool_of(obj.get("openAtLogin"), false))); - if obj.get("claudeBackup").map(|v| v.is_null()).unwrap_or(true) { - obj.insert("claudeBackup".into(), Value::Null); - } - - // trayUsage - let tu = obj.get("trayUsage").cloned().unwrap_or(json!({})); - let tu_enabled = bool_of(tu.get("enabled"), false); - let tu_range = tu - .get("range") - .and_then(|v| v.as_str()) - .filter(|r| ["1d", "7d", "30d", "all"].contains(r)) - .unwrap_or("7d"); - obj.insert("trayUsage".into(), json!({ "enabled": tu_enabled, "range": tu_range })); - - // retry429 (clamped) - let rr = obj.get("retry429").cloned().unwrap_or(json!({})); - let rr_enabled = rr.get("enabled").map(|v| v.as_bool().unwrap_or(true)).unwrap_or(true); - let rr_max = rr.get("max").and_then(|v| v.as_i64()).filter(|n| *n >= 0).map(|n| n.min(10)).unwrap_or(3); - let rr_base = rr.get("baseMs").and_then(|v| v.as_i64()).filter(|n| *n >= 0).map(|n| n.min(10000)).unwrap_or(500); - obj.insert("retry429".into(), json!({ "enabled": rr_enabled, "max": rr_max, "baseMs": rr_base })); - - obj.insert("insecureSkipVerify".into(), json!(bool_of(obj.get("insecureSkipVerify"), false))); - - // autoUpdate - let au = obj.get("autoUpdate").cloned().unwrap_or(json!({})); - let au_check = au.get("check").map(|v| v.as_bool().unwrap_or(true)).unwrap_or(true); - let au_dl = au.get("autoDownload").map(|v| v.as_bool().unwrap_or(true)).unwrap_or(true); - obj.insert("autoUpdate".into(), json!({ "check": au_check, "autoDownload": au_dl })); - - // language: only the supported set, else null - let lang = obj - .get("language") - .and_then(|v| v.as_str()) - .filter(|l| ["en", "zh", "zh-TW", "ja", "ko"].contains(l)) - .map(|s| s.to_string()); - obj.insert("language".into(), lang.map(Value::String).unwrap_or(Value::Null)); - - // historyDirs: trim, strip trailing slashes, dedup, ensure ~/.claude present - let mut dirs: Vec = vec![]; - if let Some(Value::Array(ds)) = obj.get("historyDirs") { - for d in ds { - if let Some(s) = d.as_str() { - // Collapse home-prefixed absolute paths to `~/…` for a tidy, portable display. - let t = collapse_home(s.trim().trim_end_matches(['/', '\\'])); - if !t.is_empty() && !dirs.iter().any(|x| *x == t) { - dirs.push(t); - } - } - } - } - if !dirs.iter().any(|d| d == "~/.claude") { - dirs.insert(0, "~/.claude".to_string()); - } - obj.insert("historyDirs".into(), json!(dirs)); - - // connectTargets: which coding CLIs are wired to the gateway. Subset of {claude, codex}, deduped. - // Empty is a VALID state (everything disconnected) — don't snap it back to ["claude"], or the UI - // toggle for the last-remaining CLI could never turn off. Fresh and legacy configs deliberately - // normalize to [] so startup never mistakes a schema default for an explicit connection choice. - let mut targets: Vec = vec![]; - if let Some(arr) = obj.get("connectTargets").and_then(|v| v.as_array()) { - for t in arr { - if let Some(s) = t.as_str() { - if (s == "claude" || s == "codex") && !targets.iter().any(|x| x == s) { - targets.push(s.to_string()); - } - } - } - } - obj.insert("connectTargets".into(), json!(targets)); - - // historyActive: 'all' | '__imported__' | '__trash__' (recycle bin) | a configured dir, else 'all'. - // '__codex__' is the retired synthetic Codex bucket — map it onto the real ~/.codex dir entry. - let ha = obj.get("historyActive").and_then(|v| v.as_str()).unwrap_or("all").to_string(); - let ha = if ha == "__codex__" { crate::codex::codex_label() } else { ha }; - let ha_ok = ha == "all" || ha == "__imported__" || ha == "__trash__" || dirs.iter().any(|d| *d == ha); - obj.insert("historyActive".into(), json!(if ha_ok { ha } else { "all".to_string() })); - - c -} - -fn read_config_unlocked() -> Value { - match fs::read_to_string(config_file()) { - Ok(s) => match serde_json::from_str::(&s) { - Ok(v) => normalize(v), - Err(_) => default_config(), - }, - Err(_) => default_config(), - } -} - -pub fn read_config() -> Value { - let _guard = config_lock(); - read_config_unlocked() -} - -fn write_config_unlocked(next: Value) -> (Value, bool) { - let normalized = normalize(next); - let dir = ccbud_home(); - if fs::create_dir_all(&dir).is_err() { - return (normalized, false); - } - let file = config_file(); - let tmp = dir.join("config.json.tmp"); - if let Ok(bytes) = serde_json::to_vec_pretty(&normalized) { - if fs::write(&tmp, &bytes).is_ok() { - set_0600(&tmp); - if fs::rename(&tmp, &file).is_ok() { - set_0600(&file); - return (normalized, true); - } - } - } - let _ = fs::remove_file(tmp); - (normalized, false) -} - -pub fn write_config(next: Value) -> Value { - let _guard = config_lock(); - write_config_unlocked(next).0 -} - -fn update_provider_base_url_to_v1( - config: &mut Value, - provider_id: &str, - expected_base_url: &str, -) -> bool { - let Some(provider) = config - .get_mut("providers") - .and_then(Value::as_array_mut) - .and_then(|providers| { - providers - .iter_mut() - .find(|provider| provider.get("id").and_then(Value::as_str) == Some(provider_id)) - }) - else { - return false; - }; - if provider.get("backend").and_then(Value::as_str) == Some("plugin") - || provider.get("baseUrl").and_then(Value::as_str) != Some(expected_base_url) - { - return false; - } - let Some(provider) = provider.as_object_mut() else { - return false; - }; - provider.insert( - "baseUrl".into(), - json!(format!("{}/v1", expected_base_url.trim_end_matches('/'))), - ); - true -} - -/// Atomically migrate one HTTP provider's base URL after a successful `/v1` fallback. -/// The expected URL is a compare-and-swap guard against overwriting a concurrent user edit. -pub fn migrate_provider_base_url_to_v1( - provider_id: &str, - expected_base_url: &str, -) -> Option { - let _guard = config_lock(); - let mut config = read_config_unlocked(); - if !update_provider_base_url_to_v1(&mut config, provider_id, expected_base_url) { - return None; - } - let (saved, persisted) = write_config_unlocked(config); - persisted.then_some(saved) -} - -/// One-time startup migration: when a Codex install exists (its sessions tree is on disk), -/// add its config dir (`~/.codex`, CODEX_HOME-aware) to historyDirs so Codex conversations -/// appear in 对话 like any other work dir. The `codexDirAutoAdded` flag makes this run once — -/// a user who later REMOVES the dir isn't fighting an auto-re-add. Returns true if it changed -/// the config (caller refreshes the history views). Mirrors main.js ensureCodexDir. -pub fn ensure_codex_dir() -> bool { - let mut cfg = read_config(); - if cfg.get("codexDirAutoAdded").and_then(|v| v.as_bool()).unwrap_or(false) { - return false; - } - if !crate::codex::root_exists() { - return false; // no Codex install yet — keep probing on future launches - } - let label = crate::codex::codex_label(); - let obj = cfg.as_object_mut().unwrap(); - let mut dirs: Vec = obj - .get("historyDirs") - .and_then(|v| v.as_array()) - .map(|a| a.iter().filter_map(|d| d.as_str().map(|s| s.to_string())).collect()) - .unwrap_or_default(); - if !dirs.iter().any(|d| *d == label) { - dirs.push(label); - } - obj.insert("historyDirs".into(), json!(dirs)); - obj.insert("codexDirAutoAdded".into(), json!(true)); - write_config(cfg); - true -} - -/// Shared body of the ensure_*_dir migrations: when `exists` and the run-once `flag` hasn't -/// fired, add `label` to historyDirs (dedup) and set the flag. Returns true when the config -/// changed (caller refreshes the history views). A user who later REMOVES the dir isn't -/// fighting an auto-re-add; a missing install keeps probing on future launches. -fn ensure_history_dir(flag: &str, exists: bool, label: String) -> bool { - let mut cfg = read_config(); - if cfg.get(flag).and_then(|v| v.as_bool()).unwrap_or(false) { - return false; - } - if !exists { - return false; // nothing there yet — keep probing on future launches - } - let obj = cfg.as_object_mut().unwrap(); - let mut dirs: Vec = obj - .get("historyDirs") - .and_then(|v| v.as_array()) - .map(|a| a.iter().filter_map(|d| d.as_str().map(|s| s.to_string())).collect()) - .unwrap_or_default(); - if !dirs.iter().any(|d| *d == label) { - dirs.push(label); - } - obj.insert("historyDirs".into(), json!(dirs)); - obj.insert(flag.into(), json!(true)); - write_config(cfg); - true -} - -/// One-time startup migrations for the other coding CLIs whose sessions the 对话 view can -/// browse: Grok Build (~/.grok, GROK_HOME-aware), GitHub Copilot CLI (~/.copilot), and the -/// Antigravity CLI (~/.gemini/antigravity-cli). Same run-once contract as ensure_codex_dir. -pub fn ensure_grok_dir() -> bool { - ensure_history_dir("grokDirAutoAdded", crate::grok::root_exists(), crate::grok::grok_label()) -} - -pub fn ensure_copilot_dir() -> bool { - ensure_history_dir("copilotDirAutoAdded", crate::copilot::root_exists(), crate::copilot::copilot_label()) -} - -pub fn ensure_antigravity_dir() -> bool { - ensure_history_dir("antigravityDirAutoAdded", crate::antigravity::root_exists(), crate::antigravity::agy_label()) -} - -/// Qoder writes Claude-format sessions under two known roots (~/.qoder and ~/.qoderwork) — -/// each detected root joins historyDirs once, under its own run-once flag. -pub fn ensure_qoder_dir() -> bool { - let mut changed = false; - for (flag, root) in - [("qoderDirAutoAdded", crate::qoder::default_root()), ("qoderworkDirAutoAdded", crate::qoder::work_root())] - { - changed |= ensure_history_dir(flag, crate::qoder::root_exists(&root), collapse_home(&root.to_string_lossy())); - } - changed -} - -/// One-time startup migration (ccusage parity): Claude Code also writes history under the XDG -/// config dir (`$XDG_CONFIG_HOME/claude`, default `~/.config/claude`) — when that tree exists, -/// add it to historyDirs so its sessions count toward conversations and usage. Same run-once -/// contract as ensure_codex_dir. -pub fn ensure_xdg_claude_dir() -> bool { - let mut cfg = read_config(); - if cfg.get("xdgClaudeDirAutoAdded").and_then(|v| v.as_bool()).unwrap_or(false) { - return false; - } - let base = std::env::var("XDG_CONFIG_HOME") - .ok() - .filter(|s| !s.trim().is_empty()) - .map(PathBuf::from) - .unwrap_or_else(|| { - std::env::var("HOME") - .map(PathBuf::from) - .unwrap_or_else(|_| PathBuf::from(".")) - .join(".config") - }); - let dir = base.join("claude"); - if !dir.join("projects").is_dir() { - return false; // nothing there yet — keep probing on future launches - } - let label = dir.to_string_lossy().to_string(); - let obj = cfg.as_object_mut().unwrap(); - let mut dirs: Vec = obj - .get("historyDirs") - .and_then(|v| v.as_array()) - .map(|a| a.iter().filter_map(|d| d.as_str().map(|s| s.to_string())).collect()) - .unwrap_or_default(); - if !dirs.iter().any(|d| *d == label) { - dirs.push(label); - } - obj.insert("historyDirs".into(), json!(dirs)); - obj.insert("xdgClaudeDirAutoAdded".into(), json!(true)); - write_config(cfg); - true -} - -#[cfg(unix)] -fn set_0600(p: &PathBuf) { - use std::os::unix::fs::PermissionsExt; - let _ = fs::set_permissions(p, fs::Permissions::from_mode(0o600)); -} -#[cfg(not(unix))] -fn set_0600(_p: &PathBuf) {} - -#[cfg(test)] -mod tests { - use super::*; - #[test] - fn fresh_and_legacy_configs_do_not_select_a_startup_connection() { - assert_eq!(default_config()["connectTargets"], json!([])); - assert_eq!(normalize(json!({}))["connectTargets"], json!([])); - - let explicit = normalize(json!({ - "connectTargets": ["codex", "claude", "codex", "invalid"] - })); - assert_eq!(explicit["connectTargets"], json!(["codex", "claude"])); - } - - #[test] - fn normalize_sanitizes_providers_and_active() { - let input = json!({ - "port": 9000, - "providers": [{ "name": "X", "baseUrl": "u", "authToken": "t", "extra": "drop", - "models": [{ "alias": "a", "upstream": "u" }, { "alias": "", "upstream": "" }] }] - }); - let n = normalize(input); - assert_eq!(n["port"], 9000); - assert_eq!(n["providers"][0]["name"], "X"); - assert!(n["providers"][0].get("extra").is_none(), "unknown field must be dropped"); - assert_eq!(n["providers"][0]["models"].as_array().unwrap().len(), 1, "empty model dropped"); - assert_eq!(n["activeProviderId"], n["providers"][0]["id"], "active auto-set to first provider"); - assert!(n["historyDirs"].as_array().unwrap().iter().any(|d| d == "~/.claude")); - assert_eq!(n["providers"][0]["protocol"], "anthropic", "protocol defaults to anthropic (passthrough)"); - } - - #[test] - fn provider_protocol_normalized() { - let ok = normalize(json!({ "providers": [{ "name": "O", "protocol": "openai-chat" }] })); - assert_eq!(ok["providers"][0]["protocol"], "openai-chat"); - // unrecognized → safe passthrough default - let bad = normalize(json!({ "providers": [{ "name": "B", "protocol": "grpc" }] })); - assert_eq!(bad["providers"][0]["protocol"], "anthropic"); - } - #[test] - fn normalize_migrates_legacy_glm_anthropic_base_url() { - let legacy = normalize(json!({ "providers": [{ - "name": "GLM", - "baseUrl": "https://open.bigmodel.cn/api/anthropic/", - "protocol": "anthropic" - }] })); - assert_eq!( - legacy["providers"][0]["baseUrl"], - "https://open.bigmodel.cn/api/anthropic/v1" - ); - - let custom = normalize(json!({ "providers": [{ - "name": "Custom", - "baseUrl": "https://example.com/api/anthropic", - "protocol": "anthropic" - }] })); - assert_eq!(custom["providers"][0]["baseUrl"], "https://example.com/api/anthropic"); - } - #[test] - fn normalize_clamps_retry() { - let n = normalize(json!({ "retry429": { "max": 999, "baseMs": 99999 } })); - assert_eq!(n["retry429"]["max"], 10); - assert_eq!(n["retry429"]["baseMs"], 10000); - } - #[test] - fn normalize_keeps_recycle_bin_active() { - // Synthetic buckets must survive normalize, else history_set_active("__trash__") is - // silently reset to "all" and the recycle bin can never be opened. - assert_eq!(normalize(json!({ "historyActive": "__trash__" }))["historyActive"], "__trash__"); - assert_eq!(normalize(json!({ "historyActive": "__imported__" }))["historyActive"], "__imported__"); - assert_eq!(normalize(json!({ "historyActive": "bogus-dir" }))["historyActive"], "all"); - } - - #[test] - fn provider_base_url_v1_migration_updates_only_the_matching_url() { - let mut config = json!({ - "port": 9000, - "customSetting": { "keep": true }, - "providers": [ - { - "id": "target", - "name": "Target", - "backend": "http", - "baseUrl": "https://example.com/api/", - "authToken": "secret", - "defaultModel": "model-a", - "models": [{ "alias": "fast", "upstream": "model-b" }] - }, - { - "id": "other", - "backend": "http", - "baseUrl": "https://other.example/api", - "authToken": "other-secret" - } - ] - }); - let before_other = config["providers"][1].clone(); - let before_settings = config["customSetting"].clone(); - - assert!(update_provider_base_url_to_v1( - &mut config, - "target", - "https://example.com/api/" - )); - assert_eq!( - config["providers"][0]["baseUrl"], - "https://example.com/api/v1" - ); - assert_eq!(config["providers"][0]["authToken"], "secret"); - assert_eq!(config["providers"][0]["defaultModel"], "model-a"); - assert_eq!( - config["providers"][0]["models"], - json!([{ "alias": "fast", "upstream": "model-b" }]) - ); - assert_eq!(config["providers"][1], before_other); - assert_eq!(config["customSetting"], before_settings); - assert_eq!(config["port"], 9000); - } - - #[test] - fn provider_base_url_v1_migration_requires_expected_old_url() { - let mut config = json!({ - "providers": [{ - "id": "target", - "backend": "http", - "baseUrl": "https://example.com/user-edit" - }] - }); - let before = config.clone(); - - assert!(!update_provider_base_url_to_v1( - &mut config, - "target", - "https://example.com/old" - )); - assert_eq!(config, before); - } - - #[test] - fn provider_base_url_v1_migration_skips_plugins() { - let mut config = json!({ - "providers": [{ - "id": "target", - "backend": "plugin", - "baseUrl": "http://127.0.0.1:12345" - }] - }); - let before = config.clone(); - - assert!(!update_provider_base_url_to_v1( - &mut config, - "target", - "http://127.0.0.1:12345" - )); - assert_eq!(config, before); - } -} - -/// Stable-enough unique id for a new provider (single-user, serialized writes). -pub fn gen_id() -> String { - use std::time::{SystemTime, UNIX_EPOCH}; - let n = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|d| d.as_nanos()) - .unwrap_or(0); - format!("p{}", n) -} diff --git a/src-tauri/src/store/defaults.rs b/src-tauri/src/store/defaults.rs new file mode 100644 index 0000000..1876971 --- /dev/null +++ b/src-tauri/src/store/defaults.rs @@ -0,0 +1,24 @@ +// The shipped default config. Moved verbatim from store.rs. + +use serde_json::{json, Value}; + +pub fn default_config() -> Value { + json!({ + "port": 8788, + "activeProviderId": null, + "requireToken": false, + "gatewayToken": "", + "gatewayEnabled": true, + "openAtLogin": false, + "claudeBackup": null, + "trayUsage": { "enabled": false, "range": "7d" }, + "language": null, + "historyDirs": ["~/.claude"], + "historyActive": "all", + "connectTargets": [], + "retry429": { "enabled": true, "max": 3, "baseMs": 500 }, + "insecureSkipVerify": false, + "autoUpdate": { "check": true, "autoDownload": true }, + "providers": [] + }) +} diff --git a/src-tauri/src/store/dirs.rs b/src-tauri/src/store/dirs.rs new file mode 100644 index 0000000..bffbf1b --- /dev/null +++ b/src-tauri/src/store/dirs.rs @@ -0,0 +1,130 @@ +// The run-once startup migrations that add each detected coding CLI's data dir to historyDirs. +// Moved verbatim from store.rs. + +use serde_json::json; +use std::path::PathBuf; + +use super::io::{read_config, write_config}; +use super::paths::collapse_home; + +/// One-time startup migration: when a Codex install exists (its sessions tree is on disk), +/// add its config dir (`~/.codex`, CODEX_HOME-aware) to historyDirs so Codex conversations +/// appear in 对话 like any other work dir. The `codexDirAutoAdded` flag makes this run once — +/// a user who later REMOVES the dir isn't fighting an auto-re-add. Returns true if it changed +/// the config (caller refreshes the history views). Mirrors main.js ensureCodexDir. +pub fn ensure_codex_dir() -> bool { + let mut cfg = read_config(); + if cfg.get("codexDirAutoAdded").and_then(|v| v.as_bool()).unwrap_or(false) { + return false; + } + if !crate::codex::root_exists() { + return false; // no Codex install yet — keep probing on future launches + } + let label = crate::codex::codex_label(); + let obj = cfg.as_object_mut().unwrap(); + let mut dirs: Vec = obj + .get("historyDirs") + .and_then(|v| v.as_array()) + .map(|a| a.iter().filter_map(|d| d.as_str().map(|s| s.to_string())).collect()) + .unwrap_or_default(); + if !dirs.iter().any(|d| *d == label) { + dirs.push(label); + } + obj.insert("historyDirs".into(), json!(dirs)); + obj.insert("codexDirAutoAdded".into(), json!(true)); + write_config(cfg); + true +} + +/// Shared body of the ensure_*_dir migrations: when `exists` and the run-once `flag` hasn't +/// fired, add `label` to historyDirs (dedup) and set the flag. Returns true when the config +/// changed (caller refreshes the history views). A user who later REMOVES the dir isn't +/// fighting an auto-re-add; a missing install keeps probing on future launches. +fn ensure_history_dir(flag: &str, exists: bool, label: String) -> bool { + let mut cfg = read_config(); + if cfg.get(flag).and_then(|v| v.as_bool()).unwrap_or(false) { + return false; + } + if !exists { + return false; // nothing there yet — keep probing on future launches + } + let obj = cfg.as_object_mut().unwrap(); + let mut dirs: Vec = obj + .get("historyDirs") + .and_then(|v| v.as_array()) + .map(|a| a.iter().filter_map(|d| d.as_str().map(|s| s.to_string())).collect()) + .unwrap_or_default(); + if !dirs.iter().any(|d| *d == label) { + dirs.push(label); + } + obj.insert("historyDirs".into(), json!(dirs)); + obj.insert(flag.into(), json!(true)); + write_config(cfg); + true +} + +/// One-time startup migrations for the other coding CLIs whose sessions the 对话 view can +/// browse: Grok Build (~/.grok, GROK_HOME-aware), GitHub Copilot CLI (~/.copilot), and the +/// Antigravity CLI (~/.gemini/antigravity-cli). Same run-once contract as ensure_codex_dir. +pub fn ensure_grok_dir() -> bool { + ensure_history_dir("grokDirAutoAdded", crate::grok::root_exists(), crate::grok::grok_label()) +} + +pub fn ensure_copilot_dir() -> bool { + ensure_history_dir("copilotDirAutoAdded", crate::copilot::root_exists(), crate::copilot::copilot_label()) +} + +pub fn ensure_antigravity_dir() -> bool { + ensure_history_dir("antigravityDirAutoAdded", crate::antigravity::root_exists(), crate::antigravity::agy_label()) +} + +/// Qoder writes Claude-format sessions under two known roots (~/.qoder and ~/.qoderwork) — +/// each detected root joins historyDirs once, under its own run-once flag. +pub fn ensure_qoder_dir() -> bool { + let mut changed = false; + for (flag, root) in + [("qoderDirAutoAdded", crate::qoder::default_root()), ("qoderworkDirAutoAdded", crate::qoder::work_root())] + { + changed |= ensure_history_dir(flag, crate::qoder::root_exists(&root), collapse_home(&root.to_string_lossy())); + } + changed +} + +/// One-time startup migration (ccusage parity): Claude Code also writes history under the XDG +/// config dir (`$XDG_CONFIG_HOME/claude`, default `~/.config/claude`) — when that tree exists, +/// add it to historyDirs so its sessions count toward conversations and usage. Same run-once +/// contract as ensure_codex_dir. +pub fn ensure_xdg_claude_dir() -> bool { + let mut cfg = read_config(); + if cfg.get("xdgClaudeDirAutoAdded").and_then(|v| v.as_bool()).unwrap_or(false) { + return false; + } + let base = std::env::var("XDG_CONFIG_HOME") + .ok() + .filter(|s| !s.trim().is_empty()) + .map(PathBuf::from) + .unwrap_or_else(|| { + std::env::var("HOME") + .map(PathBuf::from) + .unwrap_or_else(|_| PathBuf::from(".")) + .join(".config") + }); + let dir = base.join("claude"); + if !dir.join("projects").is_dir() { + return false; // nothing there yet — keep probing on future launches + } + let label = dir.to_string_lossy().to_string(); + let obj = cfg.as_object_mut().unwrap(); + let mut dirs: Vec = obj + .get("historyDirs") + .and_then(|v| v.as_array()) + .map(|a| a.iter().filter_map(|d| d.as_str().map(|s| s.to_string())).collect()) + .unwrap_or_default(); + if !dirs.iter().any(|d| *d == label) { + dirs.push(label); + } + obj.insert("historyDirs".into(), json!(dirs)); + obj.insert("xdgClaudeDirAutoAdded".into(), json!(true)); + write_config(cfg); + true +} diff --git a/src-tauri/src/store/io.rs b/src-tauri/src/store/io.rs new file mode 100644 index 0000000..18156ca --- /dev/null +++ b/src-tauri/src/store/io.rs @@ -0,0 +1,96 @@ +// Reading and atomically writing config.json, plus the one provider baseUrl migration. Moved +// verbatim from store.rs. + +use serde_json::{json, Value}; +use std::fs; + +use super::defaults::default_config; +use super::normalize::normalize; +use super::paths::{ccbud_home, config_file, config_lock, set_0600}; + +fn read_config_unlocked() -> Value { + match fs::read_to_string(config_file()) { + Ok(s) => match serde_json::from_str::(&s) { + Ok(v) => normalize(v), + Err(_) => default_config(), + }, + Err(_) => default_config(), + } +} + +pub fn read_config() -> Value { + let _guard = config_lock(); + read_config_unlocked() +} + +fn write_config_unlocked(next: Value) -> (Value, bool) { + let normalized = normalize(next); + let dir = ccbud_home(); + if fs::create_dir_all(&dir).is_err() { + return (normalized, false); + } + let file = config_file(); + let tmp = dir.join("config.json.tmp"); + if let Ok(bytes) = serde_json::to_vec_pretty(&normalized) { + if fs::write(&tmp, &bytes).is_ok() { + set_0600(&tmp); + if fs::rename(&tmp, &file).is_ok() { + set_0600(&file); + return (normalized, true); + } + } + } + let _ = fs::remove_file(tmp); + (normalized, false) +} + +pub fn write_config(next: Value) -> Value { + let _guard = config_lock(); + write_config_unlocked(next).0 +} + +pub(super) fn update_provider_base_url_to_v1( + config: &mut Value, + provider_id: &str, + expected_base_url: &str, +) -> bool { + let Some(provider) = config + .get_mut("providers") + .and_then(Value::as_array_mut) + .and_then(|providers| { + providers + .iter_mut() + .find(|provider| provider.get("id").and_then(Value::as_str) == Some(provider_id)) + }) + else { + return false; + }; + if provider.get("backend").and_then(Value::as_str) == Some("plugin") + || provider.get("baseUrl").and_then(Value::as_str) != Some(expected_base_url) + { + return false; + } + let Some(provider) = provider.as_object_mut() else { + return false; + }; + provider.insert( + "baseUrl".into(), + json!(format!("{}/v1", expected_base_url.trim_end_matches('/'))), + ); + true +} + +/// Atomically migrate one HTTP provider's base URL after a successful `/v1` fallback. +/// The expected URL is a compare-and-swap guard against overwriting a concurrent user edit. +pub fn migrate_provider_base_url_to_v1( + provider_id: &str, + expected_base_url: &str, +) -> Option { + let _guard = config_lock(); + let mut config = read_config_unlocked(); + if !update_provider_base_url_to_v1(&mut config, provider_id, expected_base_url) { + return None; + } + let (saved, persisted) = write_config_unlocked(config); + persisted.then_some(saved) +} diff --git a/src-tauri/src/store/mod.rs b/src-tauri/src/store/mod.rs new file mode 100644 index 0000000..b0821fc --- /dev/null +++ b/src-tauri/src/store/mod.rs @@ -0,0 +1,25 @@ +// Config persistence. +// +// All settings live under ~/.ccbud/config.json (override the dir with CCBUD_HOME, used by +// tests/self-check). Writes are atomic (temp file + rename, mode 0600) so a crash mid-write +// never tears the file. `normalize` keeps the on-disk schema stable across releases. +mod defaults; +mod dirs; +mod io; +mod normalize; +mod paths; +#[cfg(test)] +mod tests; + +pub use dirs::{ + ensure_antigravity_dir, ensure_codex_dir, ensure_copilot_dir, ensure_grok_dir, + ensure_qoder_dir, ensure_xdg_claude_dir, +}; +pub use io::{migrate_provider_base_url_to_v1, read_config, write_config}; +pub use paths::{ccbud_home, collapse_home, gen_id}; +// Part of the module's API but currently only referenced from within it — a non-test build sees +// these re-exports as unused; allow that instead of dropping the paths. +#[allow(unused_imports)] +pub use defaults::default_config; +#[allow(unused_imports)] +pub use normalize::normalize; diff --git a/src-tauri/src/store/normalize.rs b/src-tauri/src/store/normalize.rs new file mode 100644 index 0000000..44613b7 --- /dev/null +++ b/src-tauri/src/store/normalize.rs @@ -0,0 +1,201 @@ +// Schema normalization: merge over defaults, then sanitize every field. Moved verbatim from +// store.rs. + +use serde_json::{json, Value}; + +use super::defaults::default_config; +use super::paths::{bool_of, collapse_home, str_of}; + +/// Mirror of store.js `normalize`: merge over defaults, then sanitize every field. +pub fn normalize(input: Value) -> Value { + let mut c = default_config(); + if let Value::Object(src) = &input { + let obj = c.as_object_mut().unwrap(); + for (k, v) in src { + obj.insert(k.clone(), v.clone()); + } + } + + // ---- providers ---- + let mut norm_provs: Vec = vec![]; + if let Some(Value::Array(arr)) = c.get("providers") { + for p in arr { + let name = { + let n = str_of(p.get("name")); + if n.is_empty() { "Unnamed".to_string() } else { n } + }; + let map_default = p + .get("mapDefaultModels") + .map(|v| v.as_bool().unwrap_or(true)) + .unwrap_or(true); + let mut models: Vec = vec![]; + if let Some(Value::Array(ms)) = p.get("models") { + for m in ms { + let alias = str_of(m.get("alias")); + let upstream = str_of(m.get("upstream")); + if !alias.is_empty() || !upstream.is_empty() { + models.push(json!({ "alias": alias, "upstream": upstream })); + } + } + } + // Upstream wire protocol. Default 'anthropic' = today's verbatim passthrough; the + // other two make the gateway translate Claude Code's Anthropic Messages into the + // provider's format (see src/protocol/). Anything unrecognized falls back to anthropic. + let protocol = match p.get("protocol").and_then(|v| v.as_str()) { + Some("openai-chat") => "openai-chat", + Some("openai-responses") => "openai-responses", + _ => "anthropic", + }; + // Zhipu's Anthropic-compatible endpoint is versioned. The old preset omitted `/v1`; + // its unversioned path returns HTTP 200 with an embedded `404 NOT_FOUND`, which cannot + // trigger the gateway's status-based compatibility retry. Normalize only that exact + // legacy preset URL, leaving every custom/provider URL authoritative. + let mut base_url = str_of(p.get("baseUrl")); + if protocol == "anthropic" + && base_url.trim_end_matches('/') == "https://open.bigmodel.cn/api/anthropic" + { + base_url = "https://open.bigmodel.cn/api/anthropic/v1".to_string(); + } + let mut np = json!({ + "id": p.get("id").cloned().unwrap_or(Value::Null), + "name": name, + "baseUrl": base_url, + "authToken": str_of(p.get("authToken")), + "defaultModel": str_of(p.get("defaultModel")), + "smallFastModel": str_of(p.get("smallFastModel")), + "mapDefaultModels": map_default, + "protocol": protocol, + "models": models, + }); + if let Some(ic) = p.get("icon").and_then(|v| v.as_str()) { + if !ic.trim().is_empty() { + np.as_object_mut() + .unwrap() + .insert("icon".into(), json!(ic.trim())); + } + } + // Backend type. 'http' (default) = an ordinary upstream at baseUrl. 'plugin' = fronted + // by a local sidecar plugin process (see plugin.rs); its baseUrl points at the plugin's + // localhost port, maintained by PluginManager. pluginId links back to the plugin. + let backend = match p.get("backend").and_then(|v| v.as_str()) { + Some("plugin") => "plugin", + _ => "http", + }; + np.as_object_mut() + .unwrap() + .insert("backend".into(), json!(backend)); + if backend == "plugin" { + np.as_object_mut() + .unwrap() + .insert("pluginId".into(), json!(str_of(p.get("pluginId")))); + } + norm_provs.push(np); + } + } + // activeProviderId: keep if it points at a real provider, else first provider, else null. + let active = c.get("activeProviderId").cloned().unwrap_or(Value::Null); + let active_ok = norm_provs.iter().any(|p| p.get("id") == Some(&active)); + let active = if active_ok { + active + } else { + norm_provs + .first() + .and_then(|p| p.get("id").cloned()) + .unwrap_or(Value::Null) + }; + + let obj = c.as_object_mut().unwrap(); + obj.insert("providers".into(), json!(norm_provs)); + obj.insert("activeProviderId".into(), active); + + // ---- scalars ---- + let port = obj + .get("port") + .and_then(|v| v.as_i64().or_else(|| v.as_str().and_then(|s| s.parse().ok()))) + .filter(|n| *n > 0) + .unwrap_or(8788); + obj.insert("port".into(), json!(port)); + obj.insert("requireToken".into(), json!(bool_of(obj.get("requireToken"), false))); + obj.insert("gatewayEnabled".into(), json!(bool_of(obj.get("gatewayEnabled"), true))); + obj.insert("gatewayToken".into(), json!(str_of(obj.get("gatewayToken")))); + obj.insert("openAtLogin".into(), json!(bool_of(obj.get("openAtLogin"), false))); + if obj.get("claudeBackup").map(|v| v.is_null()).unwrap_or(true) { + obj.insert("claudeBackup".into(), Value::Null); + } + + // trayUsage + let tu = obj.get("trayUsage").cloned().unwrap_or(json!({})); + let tu_enabled = bool_of(tu.get("enabled"), false); + let tu_range = tu + .get("range") + .and_then(|v| v.as_str()) + .filter(|r| ["1d", "7d", "30d", "all"].contains(r)) + .unwrap_or("7d"); + obj.insert("trayUsage".into(), json!({ "enabled": tu_enabled, "range": tu_range })); + + // retry429 (clamped) + let rr = obj.get("retry429").cloned().unwrap_or(json!({})); + let rr_enabled = rr.get("enabled").map(|v| v.as_bool().unwrap_or(true)).unwrap_or(true); + let rr_max = rr.get("max").and_then(|v| v.as_i64()).filter(|n| *n >= 0).map(|n| n.min(10)).unwrap_or(3); + let rr_base = rr.get("baseMs").and_then(|v| v.as_i64()).filter(|n| *n >= 0).map(|n| n.min(10000)).unwrap_or(500); + obj.insert("retry429".into(), json!({ "enabled": rr_enabled, "max": rr_max, "baseMs": rr_base })); + + obj.insert("insecureSkipVerify".into(), json!(bool_of(obj.get("insecureSkipVerify"), false))); + + // autoUpdate + let au = obj.get("autoUpdate").cloned().unwrap_or(json!({})); + let au_check = au.get("check").map(|v| v.as_bool().unwrap_or(true)).unwrap_or(true); + let au_dl = au.get("autoDownload").map(|v| v.as_bool().unwrap_or(true)).unwrap_or(true); + obj.insert("autoUpdate".into(), json!({ "check": au_check, "autoDownload": au_dl })); + + // language: only the supported set, else null + let lang = obj + .get("language") + .and_then(|v| v.as_str()) + .filter(|l| ["en", "zh", "zh-TW", "ja", "ko"].contains(l)) + .map(|s| s.to_string()); + obj.insert("language".into(), lang.map(Value::String).unwrap_or(Value::Null)); + + // historyDirs: trim, strip trailing slashes, dedup, ensure ~/.claude present + let mut dirs: Vec = vec![]; + if let Some(Value::Array(ds)) = obj.get("historyDirs") { + for d in ds { + if let Some(s) = d.as_str() { + // Collapse home-prefixed absolute paths to `~/…` for a tidy, portable display. + let t = collapse_home(s.trim().trim_end_matches(['/', '\\'])); + if !t.is_empty() && !dirs.iter().any(|x| *x == t) { + dirs.push(t); + } + } + } + } + if !dirs.iter().any(|d| d == "~/.claude") { + dirs.insert(0, "~/.claude".to_string()); + } + obj.insert("historyDirs".into(), json!(dirs)); + + // connectTargets: which coding CLIs are wired to the gateway. Subset of {claude, codex}, deduped. + // Empty is a VALID state (everything disconnected) — don't snap it back to ["claude"], or the UI + // toggle for the last-remaining CLI could never turn off. Fresh and legacy configs deliberately + // normalize to [] so startup never mistakes a schema default for an explicit connection choice. + let mut targets: Vec = vec![]; + if let Some(arr) = obj.get("connectTargets").and_then(|v| v.as_array()) { + for t in arr { + if let Some(s) = t.as_str() { + if (s == "claude" || s == "codex") && !targets.iter().any(|x| x == s) { + targets.push(s.to_string()); + } + } + } + } + obj.insert("connectTargets".into(), json!(targets)); + + // historyActive: 'all' | '__imported__' | '__trash__' (recycle bin) | a configured dir, else 'all'. + // '__codex__' is the retired synthetic Codex bucket — map it onto the real ~/.codex dir entry. + let ha = obj.get("historyActive").and_then(|v| v.as_str()).unwrap_or("all").to_string(); + let ha = if ha == "__codex__" { crate::codex::codex_label() } else { ha }; + let ha_ok = ha == "all" || ha == "__imported__" || ha == "__trash__" || dirs.iter().any(|d| *d == ha); + obj.insert("historyActive".into(), json!(if ha_ok { ha } else { "all".to_string() })); + + c +} diff --git a/src-tauri/src/store/paths.rs b/src-tauri/src/store/paths.rs new file mode 100644 index 0000000..bc5cdb3 --- /dev/null +++ b/src-tauri/src/store/paths.rs @@ -0,0 +1,70 @@ +// Where the config lives, the write lock, home-collapsing, the small JSON accessors, the 0600 +// permission helper and provider id generation. Moved verbatim from store.rs. + +use serde_json::Value; +use std::fs; +use std::path::PathBuf; +use std::sync::{Mutex, MutexGuard, OnceLock}; + +pub fn ccbud_home() -> PathBuf { + if let Ok(d) = std::env::var("CCBUD_HOME") { + if !d.is_empty() { + return PathBuf::from(d); + } + } + let home = std::env::var("HOME").unwrap_or_else(|_| ".".into()); + PathBuf::from(home).join(".ccbud") +} + +pub(super) fn config_file() -> PathBuf { + ccbud_home().join("config.json") +} + +pub(super) fn config_lock() -> MutexGuard<'static, ()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())) + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) +} + +/// Collapse a home-prefixed absolute path back to `~` form so the UI shows +/// `~/.claude` instead of `/Users//.claude`. Inverse of history::expand_tilde. +pub fn collapse_home(p: &str) -> String { + let home = std::env::var("HOME").unwrap_or_default(); + if home.is_empty() { + return p.to_string(); + } + let home = home.trim_end_matches('/'); + if p == home { + return "~".to_string(); + } + if let Some(rest) = p.strip_prefix(&format!("{}/", home)) { + return format!("~/{}", rest); + } + p.to_string() +} + +pub(super) fn str_of(v: Option<&Value>) -> String { + v.and_then(|x| x.as_str()).unwrap_or("").to_string() +} +pub(super) fn bool_of(v: Option<&Value>, default: bool) -> bool { + v.and_then(|x| x.as_bool()).unwrap_or(default) +} + +#[cfg(unix)] +pub(super) fn set_0600(p: &PathBuf) { + use std::os::unix::fs::PermissionsExt; + let _ = fs::set_permissions(p, fs::Permissions::from_mode(0o600)); +} +#[cfg(not(unix))] +pub(super) fn set_0600(_p: &PathBuf) {} + +/// Stable-enough unique id for a new provider (single-user, serialized writes). +pub fn gen_id() -> String { + use std::time::{SystemTime, UNIX_EPOCH}; + let n = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0); + format!("p{}", n) +} diff --git a/src-tauri/src/store/tests.rs b/src-tauri/src/store/tests.rs new file mode 100644 index 0000000..7a91c1e --- /dev/null +++ b/src-tauri/src/store/tests.rs @@ -0,0 +1,158 @@ +use super::defaults::default_config; +use super::io::update_provider_base_url_to_v1; +use super::normalize::normalize; +use serde_json::json; + +#[test] +fn fresh_and_legacy_configs_do_not_select_a_startup_connection() { + assert_eq!(default_config()["connectTargets"], json!([])); + assert_eq!(normalize(json!({}))["connectTargets"], json!([])); + + let explicit = normalize(json!({ + "connectTargets": ["codex", "claude", "codex", "invalid"] + })); + assert_eq!(explicit["connectTargets"], json!(["codex", "claude"])); +} + +#[test] +fn normalize_sanitizes_providers_and_active() { + let input = json!({ + "port": 9000, + "providers": [{ "name": "X", "baseUrl": "u", "authToken": "t", "extra": "drop", + "models": [{ "alias": "a", "upstream": "u" }, { "alias": "", "upstream": "" }] }] + }); + let n = normalize(input); + assert_eq!(n["port"], 9000); + assert_eq!(n["providers"][0]["name"], "X"); + assert!(n["providers"][0].get("extra").is_none(), "unknown field must be dropped"); + assert_eq!(n["providers"][0]["models"].as_array().unwrap().len(), 1, "empty model dropped"); + assert_eq!(n["activeProviderId"], n["providers"][0]["id"], "active auto-set to first provider"); + assert!(n["historyDirs"].as_array().unwrap().iter().any(|d| d == "~/.claude")); + assert_eq!(n["providers"][0]["protocol"], "anthropic", "protocol defaults to anthropic (passthrough)"); +} + +#[test] +fn provider_protocol_normalized() { + let ok = normalize(json!({ "providers": [{ "name": "O", "protocol": "openai-chat" }] })); + assert_eq!(ok["providers"][0]["protocol"], "openai-chat"); + // unrecognized → safe passthrough default + let bad = normalize(json!({ "providers": [{ "name": "B", "protocol": "grpc" }] })); + assert_eq!(bad["providers"][0]["protocol"], "anthropic"); +} +#[test] +fn normalize_migrates_legacy_glm_anthropic_base_url() { + let legacy = normalize(json!({ "providers": [{ + "name": "GLM", + "baseUrl": "https://open.bigmodel.cn/api/anthropic/", + "protocol": "anthropic" + }] })); + assert_eq!( + legacy["providers"][0]["baseUrl"], + "https://open.bigmodel.cn/api/anthropic/v1" + ); + + let custom = normalize(json!({ "providers": [{ + "name": "Custom", + "baseUrl": "https://example.com/api/anthropic", + "protocol": "anthropic" + }] })); + assert_eq!(custom["providers"][0]["baseUrl"], "https://example.com/api/anthropic"); +} +#[test] +fn normalize_clamps_retry() { + let n = normalize(json!({ "retry429": { "max": 999, "baseMs": 99999 } })); + assert_eq!(n["retry429"]["max"], 10); + assert_eq!(n["retry429"]["baseMs"], 10000); +} +#[test] +fn normalize_keeps_recycle_bin_active() { + // Synthetic buckets must survive normalize, else history_set_active("__trash__") is + // silently reset to "all" and the recycle bin can never be opened. + assert_eq!(normalize(json!({ "historyActive": "__trash__" }))["historyActive"], "__trash__"); + assert_eq!(normalize(json!({ "historyActive": "__imported__" }))["historyActive"], "__imported__"); + assert_eq!(normalize(json!({ "historyActive": "bogus-dir" }))["historyActive"], "all"); +} + +#[test] +fn provider_base_url_v1_migration_updates_only_the_matching_url() { + let mut config = json!({ + "port": 9000, + "customSetting": { "keep": true }, + "providers": [ + { + "id": "target", + "name": "Target", + "backend": "http", + "baseUrl": "https://example.com/api/", + "authToken": "secret", + "defaultModel": "model-a", + "models": [{ "alias": "fast", "upstream": "model-b" }] + }, + { + "id": "other", + "backend": "http", + "baseUrl": "https://other.example/api", + "authToken": "other-secret" + } + ] + }); + let before_other = config["providers"][1].clone(); + let before_settings = config["customSetting"].clone(); + + assert!(update_provider_base_url_to_v1( + &mut config, + "target", + "https://example.com/api/" + )); + assert_eq!( + config["providers"][0]["baseUrl"], + "https://example.com/api/v1" + ); + assert_eq!(config["providers"][0]["authToken"], "secret"); + assert_eq!(config["providers"][0]["defaultModel"], "model-a"); + assert_eq!( + config["providers"][0]["models"], + json!([{ "alias": "fast", "upstream": "model-b" }]) + ); + assert_eq!(config["providers"][1], before_other); + assert_eq!(config["customSetting"], before_settings); + assert_eq!(config["port"], 9000); +} + +#[test] +fn provider_base_url_v1_migration_requires_expected_old_url() { + let mut config = json!({ + "providers": [{ + "id": "target", + "backend": "http", + "baseUrl": "https://example.com/user-edit" + }] + }); + let before = config.clone(); + + assert!(!update_provider_base_url_to_v1( + &mut config, + "target", + "https://example.com/old" + )); + assert_eq!(config, before); +} + +#[test] +fn provider_base_url_v1_migration_skips_plugins() { + let mut config = json!({ + "providers": [{ + "id": "target", + "backend": "plugin", + "baseUrl": "http://127.0.0.1:12345" + }] + }); + let before = config.clone(); + + assert!(!update_provider_base_url_to_v1( + &mut config, + "target", + "http://127.0.0.1:12345" + )); + assert_eq!(config, before); +} diff --git a/src-tauri/src/tray.rs b/src-tauri/src/tray.rs new file mode 100644 index 0000000..0465bf1 --- /dev/null +++ b/src-tauri/src/tray.rs @@ -0,0 +1,119 @@ +// System tray title + dynamic, localized context menu, moved verbatim from lib.rs. + +use serde_json::{json, Value}; +use tauri::Manager; + +use crate::commands::format_tokens; +use crate::{gateway, store, usage}; + +/// Set the macOS menu-bar tray title to the configured usage token count (or clear it when +/// trayUsage is off). Heavy work (config read + usage scan) runs on the caller's thread; +/// only the set_title call hops to the main thread, where macOS requires UI mutation. +pub(crate) fn update_tray_title(app: &tauri::AppHandle) { + let config = store::read_config(); + let tu = config.get("trayUsage").cloned().unwrap_or_else(|| json!({})); + let enabled = tu.get("enabled").and_then(|v| v.as_bool()).unwrap_or(false); + let title: Option = if enabled { + let range = tu.get("range").and_then(|v| v.as_str()).unwrap_or("7d").to_string(); + // Same global scope as the popover — the tray count is a whole-machine number. + let tokens = usage::usage_get(&config, "all", &range) + .get("tokens") + .and_then(|v| v.as_i64()) + .unwrap_or(0); + Some(format!(" {}", format_tokens(tokens))) + } else { + None + }; + let app2 = app.clone(); + let _ = app.run_on_main_thread(move || { + if let Some(tray) = app2.tray_by_id("main") { + let _ = tray.set_title(title.as_deref()); + } + }); +} + +// ---- system tray: dynamic, localized context menu (parity with main.js buildTrayMenu) ---- +struct TrayLabels { + running_with: &'static str, + stopped: &'static str, + open_main: &'static str, + stop_gw: &'static str, + start_gw: &'static str, + quit: &'static str, + check_updates: &'static str, +} +fn tray_labels(lang: &str) -> TrayLabels { + match lang { + // config.language stores "zh" (store.rs normalize) — accept both spellings. + "zh" | "zh-CN" => TrayLabels { running_with: "● 网关运行中 · {name}", stopped: "○ 网关已停止", open_main: "打开主界面", stop_gw: "停止网关服务", start_gw: "启动网关服务", quit: "退出 CC Buddy", check_updates: "检查更新…" }, + "zh-TW" => TrayLabels { running_with: "● 閘道執行中 · {name}", stopped: "○ 閘道已停止", open_main: "開啟主視窗", stop_gw: "停止閘道服務", start_gw: "啟動閘道服務", quit: "結束 CC Buddy", check_updates: "檢查更新…" }, + "ja" => TrayLabels { running_with: "● ゲートウェイ稼働中 · {name}", stopped: "○ ゲートウェイ停止中", open_main: "メインウィンドウを開く", stop_gw: "ゲートウェイを停止", start_gw: "ゲートウェイを起動", quit: "CC Buddy を終了", check_updates: "更新を確認…" }, + "ko" => TrayLabels { running_with: "● 게이트웨이 실행 중 · {name}", stopped: "○ 게이트웨이 중지됨", open_main: "메인 창 열기", stop_gw: "게이트웨이 중지", start_gw: "게이트웨이 시작", quit: "CC Buddy 종료", check_updates: "업데이트 확인…" }, + _ => TrayLabels { running_with: "● Gateway running · {name}", stopped: "○ Gateway stopped", open_main: "Open main window", stop_gw: "Stop gateway service", start_gw: "Start gateway service", quit: "Quit CC Buddy", check_updates: "Check for updates…" }, + } +} +pub(crate) fn config_lang(config: &Value) -> String { + config.get("language").and_then(|v| v.as_str()).unwrap_or("en").to_string() +} +fn active_provider_name(config: &Value) -> String { + let id = match config.get("activeProviderId").and_then(|v| v.as_str()) { + Some(i) => i, + None => return String::new(), + }; + config + .get("providers") + .and_then(|v| v.as_array()) + .and_then(|arr| { + arr.iter() + .find(|p| p.get("id").and_then(|v| v.as_str()) == Some(id)) + .and_then(|p| p.get("name").and_then(|v| v.as_str())) + }) + .unwrap_or("") + .to_string() +} +pub(crate) fn build_tray_menu( + app: &tauri::AppHandle, + running: bool, + provider: &str, + lang: &str, +) -> tauri::Result> { + use tauri::menu::{Menu, MenuItem, PredefinedMenuItem}; + let l = tray_labels(lang); + let status_txt = if running { + let name = if provider.is_empty() { "CC Buddy" } else { provider }; + l.running_with.replace("{name}", name) + } else { + l.stopped.to_string() + }; + // Status row is disabled (it's an indicator, like main.js { enabled: false }). + let status_i = MenuItem::with_id(app, "tray_status", status_txt, false, None::<&str>)?; + let open_i = MenuItem::with_id(app, "tray_open", l.open_main, true, None::<&str>)?; + let conn_i = if running { + MenuItem::with_id(app, "tray_gw_stop", l.stop_gw, true, None::<&str>)? + } else { + MenuItem::with_id(app, "tray_gw_start", l.start_gw, true, None::<&str>)? + }; + let check_i = MenuItem::with_id(app, "tray_check", l.check_updates, true, None::<&str>)?; + let quit_i = MenuItem::with_id(app, "tray_quit", l.quit, true, None::<&str>)?; + let sep1 = PredefinedMenuItem::separator(app)?; + let sep2 = PredefinedMenuItem::separator(app)?; + Menu::with_items(app, &[&status_i, &sep1, &open_i, &conn_i, &check_i, &sep2, &quit_i]) +} +/// Rebuild the tray menu to reflect the gateway service state + locale + active provider. +pub(crate) fn refresh_tray_menu(app: &tauri::AppHandle) { + let app2 = app.clone(); + let _ = app.run_on_main_thread(move || { + let config = store::read_config(); + let running = app2 + .try_state::>() + .map(|s| s.port_sync().is_some()) + .unwrap_or(false); + let provider = active_provider_name(&config); + let lang = config_lang(&config); + if let Ok(menu) = build_tray_menu(&app2, running, &provider, &lang) { + if let Some(tray) = app2.tray_by_id("main") { + let _ = tray.set_menu(Some(menu)); + } + } + }); +} diff --git a/src-tauri/src/trayicon.rs b/src-tauri/src/trayicon.rs new file mode 100644 index 0000000..4f6d313 --- /dev/null +++ b/src-tauri/src/trayicon.rs @@ -0,0 +1,207 @@ +// System tray icon construction and its menu / click event handlers. +// +// Extracted verbatim from the `setup()` closure in lib.rs `run()` (the block that built the +// TrayIconBuilder) so both files stay under the split's size cap. Called exactly once, from +// run()'s setup, with the same `app` and `startup_cfg` it closed over there. + +use serde_json::{json, Value}; +use tauri::{Emitter, Manager}; + +use crate::commands::{auto_update_on_visible, full_status, set_dock_visible}; +use crate::popover::{now_ms, LAST_POPOVER_HIDE_MS, LAST_POPOVER_SHOW_MS}; +use crate::tray::{build_tray_menu, config_lang, refresh_tray_menu}; +use crate::{gateway, store}; + +// System tray: icon + dynamic i18n menu (status / open / connect-or-disconnect / +// check-updates / quit, parity with main.js buildTrayMenu) + click-to-open popover. +pub(crate) fn install_tray(app: &tauri::App, startup_cfg: &Value) -> tauri::Result<()> { + use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}; + let menu = build_tray_menu(app.handle(), false, "", &config_lang(&startup_cfg))?; + // Menu-bar icon: monochrome template (like other macOS apps), auto black/white. + let tray_img = tauri::image::Image::from_bytes(include_bytes!("../../build/iconTemplate.png")) + .unwrap_or_else(|_| app.default_window_icon().cloned().unwrap()); + let _ = TrayIconBuilder::with_id("main") + .icon(tray_img) + .icon_as_template(true) + .tooltip("CC Buddy") + .menu(&menu) + .show_menu_on_left_click(false) + .on_menu_event(|app, event| match event.id.as_ref() { + "tray_open" => { + if let Some(w) = app.get_webview_window("main") { + set_dock_visible(app, true); + let _ = w.show(); + let _ = w.unminimize(); + let _ = w.set_focus(); + } + } + // Tray toggles the gateway SERVICE (start/stop), never the CLI configs. + "tray_gw_start" | "tray_gw_stop" => { + let on = event.id.as_ref() == "tray_gw_start"; + let app = app.clone(); + tauri::async_runtime::spawn(async move { + let mut cfg = store::read_config(); + cfg["gatewayEnabled"] = json!(on); + let saved = store::write_config(cfg); + let port = saved.get("port").and_then(|v| v.as_u64()).unwrap_or(8788) as u16; + let gw = app + .try_state::>() + .map(|s| s.inner().clone()); + if let Some(gw) = gw { + if on { + let _ = gw.start(port).await; + } else { + gw.stop().await; + } + let status = full_status(&gw).await; + gw.emit("gateway:status", status); + } + refresh_tray_menu(&app); + }); + } + "tray_check" => { + if let Some(w) = app.get_webview_window("main") { + set_dock_visible(app, true); + let _ = w.show(); + let _ = w.unminimize(); + let _ = w.set_focus(); + } + // Open the About/update pane shortly after the window is up (main.js parity). + let app2 = app.clone(); + tauri::async_runtime::spawn(async move { + tokio::time::sleep(std::time::Duration::from_millis(300)).await; + let _ = app2.emit("update:openPane", json!({})); + }); + } + "tray_quit" => app.exit(0), + _ => {} + }) + .on_tray_icon_event(|tray, event| { + if let TrayIconEvent::Click { + button: MouseButton::Left, + button_state: MouseButtonState::Up, + rect, + .. + } = event + { + let app = tray.app_handle(); + if let Some(pop) = app.get_webview_window("popover") { + #[cfg(target_os = "macos")] + let vis_before = { + use tauri_nspanel::ManagerExt as _; + app.get_webview_panel("popover") + .map(|p| p.is_visible()) + .unwrap_or(false) + }; + #[cfg(not(target_os = "macos"))] + let vis_before = pop.is_visible().unwrap_or(false); + let debounced = now_ms() + - LAST_POPOVER_HIDE_MS + .load(std::sync::atomic::Ordering::Relaxed) + < 250; + let action; + if vis_before { + #[cfg(target_os = "macos")] + { + use tauri_nspanel::ManagerExt as _; + if let Ok(p) = app.get_webview_panel("popover") { + p.order_out(None); + } + } + #[cfg(not(target_os = "macos"))] + let _ = pop.hide(); + LAST_POPOVER_HIDE_MS + .store(now_ms(), std::sync::atomic::Ordering::Relaxed); + action = "hide"; + } else if debounced { + // Debounce: clicking the tray first blurs (hides) the popover; + // without this the same click would re-show it instantly. + action = "debounce_skip"; + } else { + // Center under the tray icon, clamped to the monitor (rect + + // scale are physical px, so retina is handled correctly). + // + // Pick the monitor the TRAY icon sits on. pop.current_monitor() is the + // monitor the (hidden) popover window last sat on, which on a + // multi-display setup is often NOT the screen whose menu bar was + // clicked; using it clamps the popover to the wrong monitor's + // bounds. Find the monitor whose physical bounds contain the tray + // rect (each candidate's own scale converts the rect to px). + let mon = pop + .available_monitors() + .ok() + .and_then(|mons| { + mons.into_iter().find(|m| { + let p = rect + .position + .to_physical::(m.scale_factor()); + let mp = m.position(); + let ms = m.size(); + p.x >= mp.x as f64 + && p.x < mp.x as f64 + ms.width as f64 + && p.y >= mp.y as f64 + && p.y < mp.y as f64 + ms.height as f64 + }) + }) + .or_else(|| pop.current_monitor().ok().flatten()) + .or_else(|| pop.primary_monitor().ok().flatten()); + let geom = mon.map(|mon| { + let scale = mon.scale_factor(); + let pw = (424.0 * scale) as i32; + let sx = mon.position().x; + let sw = mon.size().width as i32; + let tray_pos = rect.position.to_physical::(scale); + let tray_size = rect.size.to_physical::(scale); + let tray_cx = (tray_pos.x + tray_size.width / 2.0) as i32; + let x = (tray_cx - pw / 2).clamp(sx + 4, sx + sw - pw - 4); + let y = (tray_pos.y + tray_size.height + 2.0) as i32; + tauri::PhysicalPosition::new(x, y) + }); + if let Some(p) = geom { + let _ = pop.set_position(p); + } + // Show via the NSPanel: nonactivating, so it appears on the + // CURRENT Space (incl. a fullscreen app's) without activating + // ccbud or switching Spaces. + #[cfg(target_os = "macos")] + { + use tauri_nspanel::ManagerExt as _; + if let Ok(p) = app.get_webview_panel("popover") { + p.show(); + } + } + #[cfg(not(target_os = "macos"))] + { + let _ = pop.show(); + let _ = pop.set_focus(); + } + if let Some(p) = geom { + let _ = pop.set_position(p); + } + let _ = app.emit("popover:show", ()); + LAST_POPOVER_SHOW_MS + .store(now_ms(), std::sync::atomic::Ordering::Relaxed); + // The popover appearing counts as "app became visible today". + auto_update_on_visible(app); + action = "show"; + } + if let Ok(path) = std::env::var("CCBUD_SELFCHECK_OUT") { + use std::io::Write; + if let Ok(mut f) = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(&path) + { + let _ = writeln!( + f, + "{}", + json!({ "trayClick": action, "visBefore": vis_before }) + ); + } + } + } + } + }) + .build(app)?; + Ok(()) +} diff --git a/src-tauri/src/usage.rs b/src-tauri/src/usage.rs deleted file mode 100644 index 87e28d4..0000000 --- a/src-tauri/src/usage.rs +++ /dev/null @@ -1,1137 +0,0 @@ -// Usage analytics — aggregation semantics ported from ccusage (github.com/ccusage/ccusage), -// scoped to the two agents ccbud fronts: Claude Code and Codex. -// -// Per active work dir, two session trees contribute: -// -// Claude Code `projects/**/*.jsonl` (recursive, any depth — sessions, nested session dirs, -// subagent transcripts all included by construction): -// - every line whose `message.usage` carries numeric input/output tokens counts — no -// `type=="assistant"` gate (ccusage parity); -// - a line without a parseable RFC3339 `timestamp` is DROPPED (never guessed); -// - cache-creation prefers the nested `cache_creation.ephemeral_{5m,1h}_input_tokens` -// breakdown over the flat `cache_creation_input_tokens`; -// - `` models keep their tokens but get no model attribution; `usage.speed=="fast"` -// appends a `-fast` suffix to the model; -// - global de-dup by (message.id, requestId) — entries without a message.id are never -// de-duped; a sidechain replay that reuses the parent's message.id under a NEW requestId -// collapses onto the parent (non-sidechain wins, then higher token total). -// -// Codex `sessions/**/*.jsonl` + `archived_sessions/**/*.jsonl` (an archived copy of the same -// relative path is skipped — the active sessions/ copy wins): -// - `token_count` events: prefer `info.last_token_usage` (the turn delta); fall back to -// diffing consecutive `info.total_token_usage` snapshots; the cumulative baseline always -// advances so either form stays correct; -// - `thread_spawn` subagent files replay the parent's history as a leading burst of -// token_count lines sharing one timestamp-second — those are skipped (baseline still -// advances) so parent turns aren't counted twice; -// - identical (timestamp, model, tokens) events across files (resumed/forked sessions) -// de-dup globally; -// - model comes from the event payload/info, else the last `turn_context`, else "gpt-5"; -// `input_tokens` is INCLUSIVE of `cached_input_tokens` — the cached part is split out into -// cacheRead and the remainder becomes input. -// -// Day bucketing is local-timezone (chrono::Local), matching ccusage's system-timezone default. - -#![allow(dead_code)] - -use chrono::{Datelike, Local, TimeZone, Timelike}; -use serde_json::{json, Value}; -use std::collections::{HashMap, HashSet}; -use std::io::BufRead; -use std::path::{Path, PathBuf}; - -const DAY_MS: i64 = 86_400_000; -const HEATMAP_WEEKS: i64 = 26; - -fn home() -> PathBuf { - std::env::var("HOME").map(PathBuf::from).unwrap_or_else(|_| PathBuf::from(".")) -} -fn expand_tilde(p: &str) -> PathBuf { - if let Some(rest) = p.strip_prefix("~/") { - home().join(rest) - } else if p == "~" { - home() - } else { - PathBuf::from(p) - } -} - -/// Active work dirs (honors the directory switcher). A selector that matches no configured dir — -/// the synthetic recycle-bin / imported-bundle views ("__trash__", "__imported__"), or a stale -/// value from an older config — falls back to ALL dirs: a filter must never zero the stats. -fn active_roots(config: &Value, active: &str) -> Vec { - let mut all = vec![]; - let mut selected = vec![]; - if let Some(arr) = config.get("historyDirs").and_then(|v| v.as_array()) { - for d in arr { - if let Some(s) = d.as_str() { - all.push(expand_tilde(s)); - if active == s { - selected.push(expand_tilde(s)); - } - } - } - } - if active != "all" && !selected.is_empty() { - selected - } else { - all - } -} - -fn parse_ts(s: &str) -> Option { - chrono::DateTime::parse_from_rfc3339(s).ok().map(|d| d.timestamp_millis()) -} -fn key_of(ms: i64) -> String { - match Local.timestamp_millis_opt(ms).single() { - Some(d) => format!("{:04}-{:02}-{:02}", d.year(), d.month(), d.day()), - None => "1970-01-01".to_string(), - } -} -fn start_of_day(ms: i64) -> i64 { - match Local.timestamp_millis_opt(ms).single() { - Some(d) => { - let day = d.date_naive().and_hms_opt(0, 0, 0).unwrap(); - Local.from_local_datetime(&day).single().map(|x| x.timestamp_millis()).unwrap_or(ms) - } - None => ms, - } -} -fn ms_of_key(k: &str) -> i64 { - let parts: Vec = k.split('-').filter_map(|x| x.parse().ok()).collect(); - if parts.len() != 3 { - return 0; - } - let nd = chrono::NaiveDate::from_ymd_opt(parts[0] as i32, parts[1] as u32, parts[2] as u32); - match nd.and_then(|d| d.and_hms_opt(0, 0, 0)) { - Some(dt) => Local.from_local_datetime(&dt).single().map(|x| x.timestamp_millis()).unwrap_or(0), - None => 0, - } -} -fn hour_of(ms: i64) -> u32 { - Local.timestamp_millis_opt(ms).single().map(|d| d.hour()).unwrap_or(0) -} - -#[derive(Default, Clone)] -struct Day { - tokens: i64, - input: i64, - output: i64, - cache_read: i64, - cache_creation: i64, - requests: i64, - models: HashMap, - providers: HashMap, - hours: HashMap, -} - -/// One counted usage event, whichever tree it came from. -struct UsageRec { - ts: i64, - model: Option, - input: i64, - output: i64, - cache_read: i64, - cache_creation: i64, -} - -impl UsageRec { - fn total(&self) -> i64 { - self.input + self.output + self.cache_read + self.cache_creation - } -} - -fn bump(days: &mut HashMap, rec: &UsageRec) { - let day = days.entry(key_of(rec.ts)).or_default(); - day.requests += 1; - day.tokens += rec.total(); - day.input += rec.input; - day.output += rec.output; - day.cache_read += rec.cache_read; - day.cache_creation += rec.cache_creation; - if let Some(m) = &rec.model { - *day.models.entry(m.clone()).or_insert(0) += rec.total(); - } - *day.hours.entry(hour_of(rec.ts)).or_insert(0) += rec.total(); -} - -/// Recursively collect `*.jsonl` under `dir`, any depth (ccusage walks the whole tree — nested -/// session dirs and subagent transcripts are picked up by construction). Depth-capped as a -/// symlink-loop guard. -fn collect_jsonl(dir: &Path, depth: u32, out: &mut Vec) { - if depth > 8 { - return; - } - let entries = match std::fs::read_dir(dir) { - Ok(e) => e, - Err(_) => return, - }; - for ent in entries.flatten() { - let p = ent.path(); - if p.is_dir() { - collect_jsonl(&p, depth + 1, out); - } else if p.is_file() && p.extension().and_then(|e| e.to_str()) == Some("jsonl") { - out.push(p); - } - } -} - -/// Byte-based lossy line reader. History files can embed invalid UTF-8 inside tool output — -/// a strict `BufRead::lines` errors there and would silently discard the REST of the file -/// (ccusage reads raw bytes for the same reason). -struct LossyLines { - reader: std::io::BufReader, - buf: Vec, -} - -impl LossyLines { - fn open(file: &Path) -> Option { - std::fs::File::open(file) - .ok() - .map(|f| Self { reader: std::io::BufReader::new(f), buf: Vec::with_capacity(64 * 1024) }) - } - fn next_line(&mut self) -> Option { - self.buf.clear(); - match self.reader.read_until(b'\n', &mut self.buf) { - Ok(0) | Err(_) => None, - Ok(_) => Some(String::from_utf8_lossy(&self.buf).into_owned()), - } - } -} - -// --------------------------------------------------------------------------- -// Claude Code (projects/ tree) -// --------------------------------------------------------------------------- - -struct ClaudeRec { - id: Option, - request_id: Option, - sidechain: bool, - rec: UsageRec, -} - -/// Parse one history line into a usage entry. Requires numeric `message.usage.input_tokens` / -/// `output_tokens` and a parseable `timestamp`; everything else is optional. -fn parse_claude_line(line: &str) -> Option { - // cheap prefilter before JSON parse (ccusage scans for the same marker) - if !line.contains("\"usage\"") { - return None; - } - let r: Value = serde_json::from_str(line).ok()?; - let m = r.get("message")?; - let u = m.get("usage")?; - let input = u.get("input_tokens")?.as_i64()?; - let output = u.get("output_tokens")?.as_i64()?; - let cache_read = u.get("cache_read_input_tokens").and_then(|v| v.as_i64()).unwrap_or(0); - // nested ephemeral breakdown wins over the flat cache_creation_input_tokens - let cache_creation = match u.get("cache_creation").filter(|v| v.is_object()) { - Some(b) => { - b.get("ephemeral_5m_input_tokens").and_then(|v| v.as_i64()).unwrap_or(0) - + b.get("ephemeral_1h_input_tokens").and_then(|v| v.as_i64()).unwrap_or(0) - } - None => u.get("cache_creation_input_tokens").and_then(|v| v.as_i64()).unwrap_or(0), - }; - if input + output + cache_read + cache_creation <= 0 { - return None; // zero rows (synthetic error turns) carry no token information - } - let ts = r.get("timestamp").and_then(|v| v.as_str()).and_then(parse_ts)?; - let speed_fast = u.get("speed").and_then(|v| v.as_str()) == Some("fast"); - let model = m.get("model").and_then(|v| v.as_str()).and_then(|s| { - if s.is_empty() || s == "" { - None // tokens still count; no model attribution - } else if speed_fast { - Some(format!("{}-fast", s)) - } else { - Some(s.to_string()) - } - }); - Some(ClaudeRec { - id: m.get("id").and_then(|v| v.as_str()).filter(|s| !s.is_empty()).map(String::from), - request_id: r.get("requestId").and_then(|v| v.as_str()).filter(|s| !s.is_empty()).map(String::from), - sidechain: r.get("isSidechain").and_then(|v| v.as_bool()).unwrap_or(false), - rec: UsageRec { ts, model, input, output, cache_read, cache_creation }, - }) -} - -/// Message ids that older ccbud gateway builds stamped on EVERY translated response — known -/// non-unique, so they must never act as a de-dup key (an id-keyed de-dup would collapse whole -/// weeks of history written through the gateway into a single counted turn). -fn degenerate_id(id: &str) -> bool { - id == "msg_ccbud" || id == "chatcmpl-ccbud" || id == "resp_ccbud" -} - -/// Global de-dup, ccusage semantics: key (message.id, requestId); entries without an id are always -/// kept. A miss on the exact key falls back to the id-only bucket when either side is a sidechain -/// (a `/btw` replay reuses the parent's message.id under a new requestId). On a duplicate the -/// non-sidechain copy wins, then the higher token total. -fn dedup_claude(recs: Vec) -> Vec { - let mut kept: Vec = vec![]; - let mut by_exact: HashMap<(String, Option), usize> = HashMap::new(); - let mut by_id: HashMap = HashMap::new(); - for cand in recs { - let Some(id) = cand.id.clone().filter(|i| !degenerate_id(i)) else { - kept.push(cand); - continue; - }; - let exact = (id.clone(), cand.request_id.clone()); - let slot = by_exact.get(&exact).copied().or_else(|| { - by_id.get(&id).copied().filter(|&i| cand.sidechain || kept[i].sidechain) - }); - match slot { - Some(i) => { - let cur = &kept[i]; - let replace = (cur.sidechain && !cand.sidechain) - || (cur.sidechain == cand.sidechain && cand.rec.total() > cur.rec.total()); - if replace { - kept[i] = cand; - } - by_exact.insert(exact, i); - } - None => { - let i = kept.len(); - by_exact.insert(exact, i); - by_id.entry(id).or_insert(i); - kept.push(cand); - } - } - } - kept -} - -// --------------------------------------------------------------------------- -// Codex (sessions/ + archived_sessions/ trees) -// --------------------------------------------------------------------------- - -#[derive(Clone, Copy, Default, PartialEq, Eq, Hash)] -struct CodexUsage { - input: i64, - cached: i64, - output: i64, - reasoning: i64, - total: i64, -} - -/// Lenient token-usage decode (ccusage accepts several field aliases per component). -fn codex_usage_of(v: &Value) -> Option { - let o = v.as_object()?; - let g = |keys: &[&str]| keys.iter().find_map(|k| o.get(*k).and_then(|v| v.as_i64())).unwrap_or(0); - let input = g(&["input_tokens", "prompt_tokens", "input"]); - let cached = g(&["cached_input_tokens", "cache_read_input_tokens", "cached_tokens"]); - let output = g(&["output_tokens", "completion_tokens", "output"]); - let reasoning = g(&["reasoning_output_tokens", "reasoning_tokens"]); - let total = match o.get("total_tokens").and_then(|v| v.as_i64()) { - Some(t) if t > 0 || input + output + reasoning == 0 => t, - _ => input + output + reasoning, - }; - Some(CodexUsage { input, cached, output, reasoning, total }) -} - -fn codex_usage_sub(cur: CodexUsage, prev: Option) -> CodexUsage { - let p = prev.unwrap_or_default(); - CodexUsage { - input: (cur.input - p.input).max(0), - cached: (cur.cached - p.cached).max(0), - output: (cur.output - p.output).max(0), - reasoning: (cur.reasoning - p.reasoning).max(0), - total: (cur.total - p.total).max(0), - } -} - -fn codex_model_of(v: Option<&Value>) -> Option { - let o = v?.as_object()?; - o.get("model") - .or_else(|| o.get("model_name")) - .or_else(|| o.get("metadata").and_then(|m| m.get("model"))) - .and_then(|v| v.as_str()) - .filter(|s| !s.is_empty()) - .map(String::from) -} - -/// Whether this rollout is a `thread_spawn` subagent session (marker in the file head). -fn codex_is_subagent(file: &Path) -> bool { - use std::io::Read; - let Ok(mut f) = std::fs::File::open(file) else { return false }; - let mut buf = [0u8; 16 * 1024]; - let n = f.read(&mut buf).unwrap_or(0); - buf[..n].windows(b"thread_spawn".len()).any(|w| w == b"thread_spawn") -} - -/// A subagent file replays the parent's token_count history as a leading burst that shares one -/// timestamp-second — detect that second (the first two usage events landing on the same second), -/// so the replay can be skipped while the cumulative baseline still advances. -fn codex_replay_second(file: &Path) -> Option { - let mut first: Option = None; - let mut lines = LossyLines::open(file)?; - while let Some(line) = lines.next_line() { - let Some((ts, payload)) = codex_token_count_line(&line) else { continue }; - let info = payload.get("info"); - let has_usage = info - .map(|i| i.get("last_token_usage").is_some() || i.get("total_token_usage").is_some()) - .unwrap_or(false); - if !has_usage { - continue; - } - let second: String = ts.chars().take(19).collect(); - match &first { - None => first = Some(second), - Some(f) => return if *f == second { Some(second) } else { None }, - } - } - None -} - -/// Parse a line as a `token_count` event → (timestamp, payload). None for everything else. -fn codex_token_count_line(line: &str) -> Option<(String, Value)> { - if !line.contains("token_count") { - return None; - } - let r: Value = serde_json::from_str(line).ok()?; - if r.get("type").and_then(|v| v.as_str()) != Some("event_msg") { - return None; - } - let p = r.get("payload")?; - if p.get("type").and_then(|v| v.as_str()) != Some("token_count") { - return None; - } - let ts = r.get("timestamp").and_then(|v| v.as_str())?.to_string(); - Some((ts, p.clone())) -} - -/// Parse one Codex rollout file into per-turn usage events (ccusage semantics — see module doc). -fn parse_codex_file(file: &Path, out: &mut Vec<(CodexUsage, i64, String)>) { - let replay_second = if codex_is_subagent(file) { codex_replay_second(file) } else { None }; - let mut skip_replay = replay_second.is_some(); - let mut current_model: Option = None; - let mut prev_totals: Option = None; - let Some(mut lines) = LossyLines::open(file) else { return }; - while let Some(line) = lines.next_line() { - let s = line.trim(); - if s.is_empty() { - continue; - } - // turn_context carries the active model - if s.contains("turn_context") { - if let Ok(r) = serde_json::from_str::(s) { - if r.get("type").and_then(|v| v.as_str()) == Some("turn_context") { - if let Some(m) = codex_model_of(r.get("payload")) { - current_model = Some(m); - } - continue; - } - } - } - let Some((ts_str, payload)) = codex_token_count_line(s) else { continue }; - let info = payload.get("info").filter(|i| !i.is_null()); - let total = info.and_then(|i| i.get("total_token_usage")).and_then(codex_usage_of); - let last = info.and_then(|i| i.get("last_token_usage")).and_then(codex_usage_of); - // leading parent-history replay in a subagent file: skip, but keep the baseline moving - if skip_replay { - let second: String = ts_str.chars().take(19).collect(); - if Some(&second) == replay_second.as_ref() { - if let Some(t) = total { - prev_totals = Some(t); - } - continue; - } - skip_replay = false; - } - let usage = last.or_else(|| total.map(|t| codex_usage_sub(t, prev_totals))); - if let Some(t) = total { - prev_totals = Some(t); - } - let Some(mut u) = usage else { continue }; - if u.input + u.cached + u.output + u.reasoning == 0 { - continue; - } - let Some(ts) = parse_ts(&ts_str) else { continue }; - u.cached = u.cached.min(u.input); // input is INCLUSIVE of cached - let model = codex_model_of(Some(&payload)) - .or_else(|| codex_model_of(info)) - .or_else(|| current_model.clone()) - .unwrap_or_else(|| "gpt-5".to_string()); - out.push((u, ts, model)); - } -} - -/// Collect a work dir's Codex rollout files: sessions/ plus archived_sessions/, where an archived -/// copy of the same relative path loses to the active sessions/ copy. -fn codex_files(root: &Path) -> Vec { - let mut out: Vec = vec![]; - let mut seen_rel: HashSet = HashSet::new(); - for sub in ["sessions", "archived_sessions"] { - let dir = root.join(sub); - let mut files = vec![]; - collect_jsonl(&dir, 0, &mut files); - files.sort(); - for f in files { - // Grok Build shares the sessions/ root but keys children by percent-encoded cwd - // (`%2FUsers%2F…//chat_history.jsonl` + events/updates sidecar jsonl). Those - // must never hit the Codex token parser — wasteful and would mix formats if a line - // ever looked like a token_count event. Skip any path under a Grok-encoded dir. - if f.components().any(|c| crate::grok::is_cwd_dir_name(&c.as_os_str().to_string_lossy())) { - continue; - } - let rel = f.strip_prefix(&dir).map(|p| p.to_path_buf()).unwrap_or_else(|_| f.clone()); - if seen_rel.insert(rel) { - out.push(f); - } - } - } - out -} - -// --------------------------------------------------------------------------- -// aggregation -// --------------------------------------------------------------------------- - -fn build_data(config: &Value, active: &str) -> HashMap { - let mut days: HashMap = HashMap::new(); - let roots = active_roots(config, active); - - // Claude Code: parse everything, then de-dup globally, then bucket. - let mut claude_recs: Vec = vec![]; - for root in &roots { - let mut files = vec![]; - collect_jsonl(&root.join("projects"), 0, &mut files); - files.sort(); - // Qoder projects/ trees can be macOS-protected: route those files through the guarded - // reader (helper fallback + cache, warmed in one batch) so 用量 counts the same sessions - // the 对话 view can browse. Wrapper records that repeat a message id are handled by the - // usual dedup (max-total wins) and partial snapshots lack output_tokens, so nothing - // double-counts. - let qoder_files: Vec = files - .iter() - .filter(|f| crate::qoder::looks_qoder_path(f)) - .cloned() - .collect(); - crate::qoder::prefetch(&qoder_files); - for file in files { - if crate::qoder::looks_qoder_path(&file) { - let Ok(bytes) = crate::qoder::read_bytes(&file) else { continue }; - for line in String::from_utf8_lossy(&bytes).lines() { - if let Some(rec) = parse_claude_line(line.trim()) { - claude_recs.push(rec); - } - } - continue; - } - let Some(mut lines) = LossyLines::open(&file) else { continue }; - while let Some(line) = lines.next_line() { - if let Some(rec) = parse_claude_line(line.trim()) { - claude_recs.push(rec); - } - } - } - } - for kept in dedup_claude(claude_recs) { - bump(&mut days, &kept.rec); - } - - // Codex: per-turn events, de-duped globally by (timestamp, model, tokens) so resumed/forked - // session copies collapse. - let mut events: Vec<(CodexUsage, i64, String)> = vec![]; - for root in &roots { - for file in codex_files(root) { - parse_codex_file(&file, &mut events); - } - } - let mut seen: HashSet<(i64, String, CodexUsage)> = HashSet::new(); - for (u, ts, model) in events { - if !seen.insert((ts, model.clone(), u)) { - continue; - } - bump( - &mut days, - &UsageRec { - ts, - model: Some(model), - input: (u.input - u.cached).max(0), - output: u.output, - cache_read: u.cached, - cache_creation: 0, - }, - ); - } - days -} - -// ---- usage cache ---- -// build_data scans every history .jsonl (~0.5s cold for ~1200 files), and the popover calls -// usage_get TWICE per open (heatmap "all" + stats range). Cache the scanned per-day map keyed by -// the active dirs, invalidated when history files change (notify watcher) — so the second per-open -// call + repeated opens are instant, and a startup/post-change warm makes the first open instant. -struct UsageCache { - sig: String, - days: HashMap, -} -static USAGE_CACHE: std::sync::Mutex> = std::sync::Mutex::new(None); - -fn dirs_sig(config: &Value, active: &str) -> String { - format!("{}|{:?}", active, active_roots(config, active)) -} - -fn build_data_cached(config: &Value, active: &str) -> HashMap { - let sig = dirs_sig(config, active); - { - // recover a poisoned lock (a panicked scan thread must not disable caching forever) - let cache = USAGE_CACHE.lock().unwrap_or_else(|p| p.into_inner()); - if let Some(c) = cache.as_ref() { - if c.sig == sig { - return c.days.clone(); - } - } - } - let days = build_data(config, active); - let mut cache = USAGE_CACHE.lock().unwrap_or_else(|p| p.into_inner()); - *cache = Some(UsageCache { sig, days: days.clone() }); - days -} - -/// Drop the cached scan — call when history files change so the next read rescans. -pub fn invalidate_cache() { - let mut cache = USAGE_CACHE.lock().unwrap_or_else(|p| p.into_inner()); - *cache = None; -} - -/// Scan + cache now (off the click path). Call at startup and after history changes so the first -/// popover open is instant instead of paying the cold-scan cost. -pub fn warm_cache(config: &Value, active: &str) { - let _ = build_data_cached(config, active); -} - -fn range_keys(days: &HashMap, range: &str, now: i64) -> Vec { - let mut all: Vec = days.keys().cloned().collect(); - all.sort(); - if range == "all" { - return all; - } - let n = match range { - "1d" => 1, - "30d" => 30, - _ => 7, - }; - let cut = start_of_day(now - (n - 1) * DAY_MS); - all.into_iter().filter(|k| ms_of_key(k) >= cut).collect() -} - -fn top_key(map: &HashMap) -> Option { - map.iter().max_by_key(|(_, v)| **v).map(|(k, _)| k.clone()) -} - -fn streaks(days: &HashMap, now: i64) -> (i64, i64) { - let mut active: Vec = days - .iter() - .filter(|(_, d)| d.requests > 0) - .map(|(k, _)| ms_of_key(k)) - .collect(); - active.sort(); - let set: HashSet = active.iter().cloned().collect(); - let (mut longest, mut run, mut prev): (i64, i64, Option) = (0, 0, None); - for t in &active { - run = if prev.map(|p| t - p == DAY_MS).unwrap_or(false) { run + 1 } else { 1 }; - prev = Some(*t); - if run > longest { - longest = run; - } - } - let mut cur = 0; - let mut t = start_of_day(now); - if !set.contains(&t) { - t -= DAY_MS; - } - while set.contains(&t) { - cur += 1; - t -= DAY_MS; - } - (cur, longest) -} - -fn build_heatmap(days: &HashMap, weeks: i64, now: i64) -> Vec { - let today = start_of_day(now); - let span = weeks * 7; - let mut start = today - (span - 1) * DAY_MS; - let dow = Local.timestamp_millis_opt(start).single().map(|d| d.weekday().num_days_from_sunday() as i64).unwrap_or(0); - start -= dow * DAY_MS; - let mut cells: Vec<(String, i64)> = vec![]; - let mut max = 1i64; - let mut t = start; - while t <= today { - let k = key_of(t); - let tok = days.get(&k).map(|d| d.tokens).unwrap_or(0); - if tok > max { - max = tok; - } - cells.push((k, tok)); - t += DAY_MS; - } - cells - .into_iter() - .map(|(date, tokens)| { - let r = tokens as f64 / max as f64; - let level = if tokens == 0 { - 0 - } else if r > 0.66 { - 4 - } else if r > 0.33 { - 3 - } else if r > 0.1 { - 2 - } else { - 1 - }; - json!({ "date": date, "tokens": tokens, "level": level }) - }) - .collect() -} - -fn query(days: &HashMap, range: &str, now: i64) -> Value { - let keys = range_keys(days, range, now); - let (mut tokens, mut input, mut output, mut cache_read, mut cache_creation, mut requests) = (0i64, 0i64, 0i64, 0i64, 0i64, 0i64); - let mut models: HashMap = HashMap::new(); - let mut providers: HashMap = HashMap::new(); - let mut hours: HashMap = HashMap::new(); - let mut active_days = 0; - for k in &keys { - if let Some(d) = days.get(k) { - tokens += d.tokens; - input += d.input; - output += d.output; - cache_read += d.cache_read; - cache_creation += d.cache_creation; - requests += d.requests; - if d.requests > 0 { - active_days += 1; - } - for (m, v) in &d.models { - *models.entry(m.clone()).or_insert(0) += v; - } - for (p, v) in &d.providers { - *providers.entry(p.clone()).or_insert(0) += v; - } - for (h, v) in &d.hours { - *hours.entry(*h).or_insert(0) += v; - } - } - } - let mut by_model: Vec = models - .iter() - .map(|(m, t)| json!({ "model": m, "tokens": t, "pct": if tokens > 0 { *t as f64 / tokens as f64 } else { 0.0 } })) - .collect(); - by_model.sort_by(|a, b| b["tokens"].as_i64().unwrap_or(0).cmp(&a["tokens"].as_i64().unwrap_or(0))); - let mut by_provider: Vec = providers - .iter() - .map(|(p, t)| json!({ "provider": p, "tokens": t, "pct": if tokens > 0 { *t as f64 / tokens as f64 } else { 0.0 } })) - .collect(); - by_provider.sort_by(|a, b| b["tokens"].as_i64().unwrap_or(0).cmp(&a["tokens"].as_i64().unwrap_or(0))); - let peak_hour = hours.iter().max_by_key(|(_, v)| **v).map(|(h, _)| *h as i64); - let (cur, longest) = streaks(days, now); - - json!({ - "range": range, - "tokens": tokens, "input": input, "output": output, "cacheRead": cache_read, "cacheCreation": cache_creation, - "requests": requests, "activeDays": active_days, - "peakHour": peak_hour, - "favoriteModel": top_key(&models), - "favoriteProvider": top_key(&providers), - "byModel": by_model, - "byProvider": by_provider, - "currentStreak": cur, - "longestStreak": longest, - "heatmap": build_heatmap(days, HEATMAP_WEEKS, now), - }) -} - -/// One-line scan diagnostic for the gateway log — which dirs resolved, how many files/lines -/// reached each pipeline stage, and the day span. Makes an empty/partial aggregation visible -/// without a debugger ("only today shows up" → the counters name the stage that dropped it). -pub fn diag(config: &Value, active: &str) -> String { - let roots = active_roots(config, active); - let mut claude_files = 0usize; - let mut codex_file_count = 0usize; - let (mut usage_lines, mut parsed, mut zero_rows, mut no_ts, mut degen) = (0usize, 0usize, 0usize, 0usize, 0usize); - let mut recs: Vec = vec![]; - for root in &roots { - let mut files = vec![]; - collect_jsonl(&root.join("projects"), 0, &mut files); - claude_files += files.len(); - for f in &files { - let Some(mut lines) = LossyLines::open(f) else { continue }; - while let Some(l) = lines.next_line() { - let l = l.trim(); - if !l.contains("\"usage\"") { - continue; - } - usage_lines += 1; - match parse_claude_line(l) { - Some(r) => { - parsed += 1; - if r.id.as_deref().map(degenerate_id).unwrap_or(false) { - degen += 1; - } - recs.push(r); - } - None => { - if let Ok(v) = serde_json::from_str::(l) { - if v.get("message").and_then(|m| m.get("usage")).is_some() { - if v.get("timestamp").and_then(|t| t.as_str()).and_then(parse_ts).is_none() { - no_ts += 1; - } else { - zero_rows += 1; - } - } - } - } - } - } - } - codex_file_count += codex_files(root).len(); - } - let kept = dedup_claude(recs).len(); - let days = build_data(config, active); - let mut keys: Vec<_> = days.keys().cloned().collect(); - keys.sort(); - let span = match (keys.first(), keys.last()) { - (Some(a), Some(b)) => format!("{}..{}", a, b), - _ => "-".to_string(), - }; - format!( - "usage scan: active={} roots={:?} claude-files={} codex-files={} usage-lines={} parsed={} kept={} days={} span={} dropped(no-ts)={} dropped(zero/invalid)={} degenerate-ids={}", - active, - roots.iter().map(|r| r.to_string_lossy().to_string()).collect::>(), - claude_files, codex_file_count, usage_lines, parsed, kept, keys.len(), span, no_ts, zero_rows, degen - ) -} - -/// Public entry: aggregate the active dirs and return the usage stats payload for `range`. -pub fn usage_get(config: &Value, active: &str, range: &str) -> Value { - let days = build_data_cached(config, active); - let now = Local::now().timestamp_millis(); - query(&days, range, now) -} - -#[cfg(test)] -mod tests { - use super::*; - use std::fs; - - fn line(v: Value) -> String { - format!("{}\n", v) - } - fn asst(id: &str, req: &str, model: &str, ts: &str, inp: i64, out: i64) -> String { - line(json!({ "type": "assistant", "timestamp": ts, "requestId": req, - "message": { "id": id, "model": model, - "usage": { "input_tokens": inp, "output_tokens": out } } })) - } - - fn sum(days: &HashMap) -> (i64, i64, i64, i64, i64, HashMap) { - let (mut tokens, mut input, mut output, mut cache_read, mut requests) = (0i64, 0i64, 0i64, 0i64, 0i64); - let mut models: HashMap = HashMap::new(); - for d in days.values() { - tokens += d.tokens; - input += d.input; - output += d.output; - cache_read += d.cache_read; - requests += d.requests; - for (m, v) in &d.models { - *models.entry(m.clone()).or_insert(0) += v; - } - } - (tokens, input, output, cache_read, requests, models) - } - - #[test] - fn claude_ccusage_semantics() { - let base = std::env::temp_dir().join(format!("ccbud-usage-cl-{}", std::process::id())); - let _ = fs::remove_dir_all(&base); - let proj = base.join("projects").join("-p"); - // nested session dir + subagent transcript at arbitrary depth — recursive walk finds both - let deep = proj.join("s1").join("subagents"); - fs::create_dir_all(&deep).unwrap(); - - fs::write( - proj.join("s1.jsonl"), - // counted (110) - asst("m1", "r1", "claude-x", "2026-07-01T10:00:00Z", 100, 10) - // same (id, requestId) duplicate → collapsed - + &asst("m1", "r1", "claude-x", "2026-07-01T10:00:00Z", 100, 10) - // same id, DIFFERENT requestId, no sidechain → distinct entry (counted, 55) - + &asst("m1", "r2", "claude-x", "2026-07-01T10:05:00Z", 50, 5) - // undated → dropped - + &line(json!({ "type": "assistant", - "message": { "id": "m2", "model": "claude-x", "usage": { "input_tokens": 9, "output_tokens": 9 } } })) - // zero usage → dropped - + &asst("m3", "r3", "", "2026-07-01T10:06:00Z", 0, 0) - // synthetic model with tokens → counted (7), no model attribution - + &asst("m4", "r4", "", "2026-07-01T10:07:00Z", 5, 2) - // no type field at all (ccusage has no type gate) → counted (13) - + &line(json!({ "timestamp": "2026-07-01T10:08:00Z", "requestId": "r5", - "message": { "id": "m5", "model": "claude-x", - "usage": { "input_tokens": 10, "output_tokens": 3 } } })), - ) - .unwrap(); - // subagent transcript, nested cache_creation breakdown + fast speed suffix (counted, 3+4+6+7=20) - fs::write( - deep.join("agent-a.jsonl"), - line(json!({ "timestamp": "2026-07-01T11:00:00Z", "requestId": "r6", - "message": { "id": "m6", "model": "claude-x", - "usage": { "input_tokens": 3, "output_tokens": 4, "speed": "fast", - "cache_read_input_tokens": 6, - "cache_creation_input_tokens": 999, - "cache_creation": { "ephemeral_5m_input_tokens": 5, "ephemeral_1h_input_tokens": 2 } } } })), - ) - .unwrap(); - // sidechain replay: reuses m1 under a NEW requestId with isSidechain → collapses onto parent - fs::write( - proj.join("s2.jsonl"), - line(json!({ "type": "assistant", "timestamp": "2026-07-01T10:00:01Z", "requestId": "r9", - "isSidechain": true, - "message": { "id": "m1", "model": "claude-x", "usage": { "input_tokens": 100, "output_tokens": 10 } } })), - ) - .unwrap(); - - let config = json!({ "historyDirs": [ base.to_string_lossy() ] }); - let days = build_data(&config, "all"); - let (tokens, input, output, cache_read, requests, models) = sum(&days); - // m1(110) + m1/r2(55) + m4(7) + m5(13) + m6(20) - assert_eq!(requests, 5); - assert_eq!(input, 100 + 50 + 5 + 10 + 3); - assert_eq!(output, 10 + 5 + 2 + 3 + 4); - assert_eq!(cache_read, 6); - assert_eq!(tokens, 110 + 55 + 7 + 13 + 20); - // synthetic tokens counted but unattributed; fast suffix applied - assert_eq!(models.get("claude-x").copied(), Some(110 + 55 + 13)); - assert_eq!(models.get("claude-x-fast").copied(), Some(20)); - assert!(models.get("").is_none()); - - let _ = fs::remove_dir_all(&base); - } - - // History written through OLD ccbud gateway builds: every streamed response carries the - // constant id "msg_ccbud" (and often no requestId — the gateway didn't forward the header). - // Those ids must never act as de-dup keys, or weeks of history collapse into one turn. - #[test] - fn degenerate_gateway_ids_never_dedup() { - let base = std::env::temp_dir().join(format!("ccbud-usage-degen-{}", std::process::id())); - let _ = fs::remove_dir_all(&base); - let proj = base.join("projects").join("-p"); - fs::create_dir_all(&proj).unwrap(); - let no_req = |ts: &str, inp: i64| { - line(json!({ "type": "assistant", "timestamp": ts, - "message": { "id": "msg_ccbud", "model": "glm-4.7", - "usage": { "input_tokens": inp, "output_tokens": 1 } } })) - }; - fs::write( - proj.join("old-era.jsonl"), - no_req("2026-06-20T10:00:00Z", 100) - + &no_req("2026-06-21T10:00:00Z", 200) - + &no_req("2026-06-22T10:00:00Z", 300) - + &line(json!({ "type": "assistant", "timestamp": "2026-06-23T10:00:00Z", "requestId": "r1", - "message": { "id": "chatcmpl-ccbud", "model": "glm-4.7", - "usage": { "input_tokens": 400, "output_tokens": 1 } } })), - ) - .unwrap(); - let config = json!({ "historyDirs": [ base.to_string_lossy() ] }); - let days = build_data(&config, "all"); - let (_, input, _, _, requests, _) = sum(&days); - // all four turns count — four distinct days survive - assert_eq!(requests, 4); - assert_eq!(input, 100 + 200 + 300 + 400); - assert_eq!(days.len(), 4); - let _ = fs::remove_dir_all(&base); - } - - // The 对话 page's dir switcher persists synthetic views (recycle bin, imported bundles) into - // historyActive — those match no configured dir and previously zeroed every usage number. - #[test] - fn synthetic_or_stale_active_falls_back_to_all_dirs() { - let base = std::env::temp_dir().join(format!("ccbud-usage-active-{}", std::process::id())); - let _ = fs::remove_dir_all(&base); - let proj = base.join("projects").join("-p"); - fs::create_dir_all(&proj).unwrap(); - fs::write(proj.join("s.jsonl"), asst("a1", "r1", "m", "2026-07-01T10:00:00Z", 10, 1)).unwrap(); - let config = json!({ "historyDirs": [ base.to_string_lossy() ] }); - for active in ["all", "__trash__", "__imported__", "/no/such/dir"] { - let days = build_data(&config, active); - let (tokens, ..) = sum(&days); - assert_eq!(tokens, 11, "active={} must not zero the stats", active); - } - // a VALID selector still filters - let days = build_data(&config, base.to_string_lossy().as_ref()); - let (tokens, ..) = sum(&days); - assert_eq!(tokens, 11); - let _ = fs::remove_dir_all(&base); - } - - #[test] - fn invalid_utf8_does_not_truncate_a_file() { - let base = std::env::temp_dir().join(format!("ccbud-usage-u8-{}", std::process::id())); - let _ = fs::remove_dir_all(&base); - let proj = base.join("projects").join("-p"); - fs::create_dir_all(&proj).unwrap(); - let mut bytes = asst("u1", "r1", "m", "2026-07-01T10:00:00Z", 10, 1).into_bytes(); - bytes.extend_from_slice(b"{\"garbage\": \"\xff\xfe binary tool output\"}\n"); - bytes.extend_from_slice(asst("u2", "r2", "m", "2026-07-02T10:00:00Z", 20, 2).as_bytes()); - fs::write(proj.join("s.jsonl"), bytes).unwrap(); - - let config = json!({ "historyDirs": [ base.to_string_lossy() ] }); - let days = build_data(&config, "all"); - let (_, input, _, _, requests, _) = sum(&days); - // the record AFTER the invalid-UTF-8 line still counts - assert_eq!(requests, 2); - assert_eq!(input, 30); - let _ = fs::remove_dir_all(&base); - } - - fn tc(ts: &str, last: Option<(i64, i64, i64)>, total: Option<(i64, i64, i64)>) -> String { - let mut info = json!({}); - if let Some((i, c, o)) = last { - info["last_token_usage"] = json!({ "input_tokens": i, "cached_input_tokens": c, "output_tokens": o, - "total_tokens": i + o }); - } - if let Some((i, c, o)) = total { - info["total_token_usage"] = json!({ "input_tokens": i, "cached_input_tokens": c, "output_tokens": o, - "total_tokens": i + o }); - } - line(json!({ "timestamp": ts, "type": "event_msg", "payload": { "type": "token_count", "info": info } })) - } - - #[test] - fn codex_ccusage_semantics() { - let base = std::env::temp_dir().join(format!("ccbud-usage-cx-{}", std::process::id())); - let _ = fs::remove_dir_all(&base); - let day = base.join("sessions").join("2026").join("07").join("01"); - fs::create_dir_all(&day).unwrap(); - - // main session: model from turn_context; one last_token_usage turn; one turn WITHOUT - // last (only cumulative total) → counted as the diff from the baseline. - fs::write( - day.join("rollout-a.jsonl"), - line(json!({ "timestamp": "2026-07-01T12:00:00Z", "type": "session_meta", "payload": { "id": "a" } })) - + &line(json!({ "timestamp": "2026-07-01T12:00:01Z", "type": "turn_context", "payload": { "model": "gpt-5.5" } })) - + &tc("2026-07-01T12:00:02Z", Some((900, 600, 80)), Some((900, 600, 80))) - + &tc("2026-07-01T12:00:03Z", None, Some((1400, 900, 130))) // diff: 500/300/50 - + &tc("2026-07-01T12:00:04Z", None, None), // info without usage → skipped - ) - .unwrap(); - // resumed copy of the same session: identical events must de-dup, a new turn counts. - fs::write( - day.join("rollout-b.jsonl"), - line(json!({ "timestamp": "2026-07-01T12:10:00Z", "type": "turn_context", "payload": { "model": "gpt-5.5" } })) - + &tc("2026-07-01T12:00:02Z", Some((900, 600, 80)), None) // duplicate of a's turn 1 - + &tc("2026-07-01T12:10:01Z", Some((10, 0, 5)), None), // new turn (15) - ) - .unwrap(); - // archived copy of rollout-a (same relative path) → file-level de-dup, never read twice. - let arch = base.join("archived_sessions").join("2026").join("07").join("01"); - fs::create_dir_all(&arch).unwrap(); - fs::write(arch.join("rollout-a.jsonl"), tc("2026-07-01T12:00:02Z", Some((900, 600, 80)), None)).unwrap(); - // thread_spawn subagent: leading replay burst (same second) skipped, own turn counted, - // and the baseline carried from the replayed cumulative total. - fs::write( - day.join("rollout-sub.jsonl"), - line(json!({ "timestamp": "2026-07-01T13:00:00Z", "type": "session_meta", - "payload": { "id": "sub", "source": { "type": "thread_spawn" } } })) - + &tc("2026-07-01T13:00:01Z", Some((900, 600, 80)), Some((900, 600, 80))) - + &tc("2026-07-01T13:00:01Z", Some((500, 300, 50)), Some((1400, 900, 130))) - + &tc("2026-07-01T13:00:05Z", None, Some((1600, 900, 160))), // own turn: diff 200/0/30 - ) - .unwrap(); - - let config = json!({ "historyDirs": [ base.to_string_lossy() ] }); - let days = build_data(&config, "all"); - let (tokens, input, output, cache_read, requests, models) = sum(&days); - // a#1: in 900 (cached 600) out 80 → input 300, cacheRead 600, out 80 (980) - // a#2 (diff): in 500 (cached 300) out 50 → input 200, cacheRead 300, out 50 (550) - // b#2: 10/0/5 (15) - // sub own turn: 200/0/30 (230) - assert_eq!(requests, 4); - assert_eq!(input, 300 + 200 + 10 + 200); - assert_eq!(cache_read, 600 + 300); - assert_eq!(output, 80 + 50 + 5 + 30); - assert_eq!(tokens, 980 + 550 + 15 + 230); - assert_eq!(models.get("gpt-5.5").copied(), Some(980 + 550 + 15)); - // subagent file had no turn_context → fallback model - assert_eq!(models.get("gpt-5").copied(), Some(230)); - - let _ = fs::remove_dir_all(&base); - } -} - -#[cfg(test)] -mod real_data_probe { - use super::*; - - // Diagnostic harness (not an assertion): aggregate a REAL history dir and print per-range - // totals, so the implementation can be diffed against `ccusage` on the same data. - // Run: CCBUD_PROBE_DIR=~/.claude cargo test --lib probe_real_dir -- --ignored --nocapture - #[test] - #[ignore] - fn probe_real_dir() { - let Ok(dir) = std::env::var("CCBUD_PROBE_DIR") else { - eprintln!("set CCBUD_PROBE_DIR"); - return; - }; - // parse-level diagnostics: where do lines fall out of the pipeline? - let root = expand_tilde(&dir); - let mut files = vec![]; - collect_jsonl(&root.join("projects"), 0, &mut files); - let (mut n_files, mut n_usage_lines, mut n_parsed, mut n_no_ts, mut n_degen) = (0u64, 0u64, 0u64, 0u64, 0u64); - for file in &files { - n_files += 1; - let Some(mut lines) = LossyLines::open(file) else { continue }; - while let Some(l) = lines.next_line() { - let l = l.trim(); - if !l.contains("\"usage\"") { - continue; - } - n_usage_lines += 1; - match parse_claude_line(l) { - Some(rec) => { - n_parsed += 1; - if rec.id.as_deref().map(degenerate_id).unwrap_or(false) { - n_degen += 1; - } - } - None => { - // distinguish the "usage present but timestamp bad/missing" case - if let Ok(v) = serde_json::from_str::(l) { - if v.get("message").and_then(|m| m.get("usage")).is_some() - && v.get("timestamp").and_then(|t| t.as_str()).and_then(parse_ts).is_none() - { - n_no_ts += 1; - } - } - } - } - } - } - eprintln!( - "claude files={} usage-lines={} parsed={} dropped-no-ts={} degenerate-id={}", - n_files, n_usage_lines, n_parsed, n_no_ts, n_degen - ); - let config = json!({ "historyDirs": [dir] }); - let days = build_data(&config, "all"); - let now = Local::now().timestamp_millis(); - let mut keys: Vec<_> = days.keys().cloned().collect(); - keys.sort(); - for k in &keys { - let d = &days[k]; - eprintln!("{} tokens={} in={} out={} cr={} cc={} req={}", k, d.tokens, d.input, d.output, d.cache_read, d.cache_creation, d.requests); - } - for range in ["1d", "7d", "30d", "all"] { - let q = query(&days, range, now); - eprintln!("range {:>3}: tokens={} requests={}", range, q["tokens"], q["requests"]); - } - } -} - -#[cfg(test)] -mod diag_probe { - use super::*; - #[test] - #[ignore] - fn probe_diag() { - let Ok(dir) = std::env::var("CCBUD_PROBE_DIR") else { return }; - eprintln!("{}", diag(&json!({ "historyDirs": [dir] }), "all")); - } -} diff --git a/src-tauri/src/usage/build.rs b/src-tauri/src/usage/build.rs new file mode 100644 index 0000000..d2d955b --- /dev/null +++ b/src-tauri/src/usage/build.rs @@ -0,0 +1,130 @@ +// Whole-scan aggregation across both trees, plus the cache that keeps the popover's two +// usage_get calls per open off the disk. Moved verbatim from usage.rs. + +use serde_json::Value; +use std::collections::{HashMap, HashSet}; +use std::path::PathBuf; + +use super::claude::{dedup_claude, parse_claude_line, ClaudeRec}; +use super::codex::{codex_files, parse_codex_file, CodexUsage}; +use super::model::{bump, collect_jsonl, Day, LossyLines, UsageRec}; +use super::roots::active_roots; + +// --------------------------------------------------------------------------- +// aggregation +// --------------------------------------------------------------------------- + +pub(super) fn build_data(config: &Value, active: &str) -> HashMap { + let mut days: HashMap = HashMap::new(); + let roots = active_roots(config, active); + + // Claude Code: parse everything, then de-dup globally, then bucket. + let mut claude_recs: Vec = vec![]; + for root in &roots { + let mut files = vec![]; + collect_jsonl(&root.join("projects"), 0, &mut files); + files.sort(); + // Qoder projects/ trees can be macOS-protected: route those files through the guarded + // reader (helper fallback + cache, warmed in one batch) so 用量 counts the same sessions + // the 对话 view can browse. Wrapper records that repeat a message id are handled by the + // usual dedup (max-total wins) and partial snapshots lack output_tokens, so nothing + // double-counts. + let qoder_files: Vec = files + .iter() + .filter(|f| crate::qoder::looks_qoder_path(f)) + .cloned() + .collect(); + crate::qoder::prefetch(&qoder_files); + for file in files { + if crate::qoder::looks_qoder_path(&file) { + let Ok(bytes) = crate::qoder::read_bytes(&file) else { continue }; + for line in String::from_utf8_lossy(&bytes).lines() { + if let Some(rec) = parse_claude_line(line.trim()) { + claude_recs.push(rec); + } + } + continue; + } + let Some(mut lines) = LossyLines::open(&file) else { continue }; + while let Some(line) = lines.next_line() { + if let Some(rec) = parse_claude_line(line.trim()) { + claude_recs.push(rec); + } + } + } + } + for kept in dedup_claude(claude_recs) { + bump(&mut days, &kept.rec); + } + + // Codex: per-turn events, de-duped globally by (timestamp, model, tokens) so resumed/forked + // session copies collapse. + let mut events: Vec<(CodexUsage, i64, String)> = vec![]; + for root in &roots { + for file in codex_files(root) { + parse_codex_file(&file, &mut events); + } + } + let mut seen: HashSet<(i64, String, CodexUsage)> = HashSet::new(); + for (u, ts, model) in events { + if !seen.insert((ts, model.clone(), u)) { + continue; + } + bump( + &mut days, + &UsageRec { + ts, + model: Some(model), + input: (u.input - u.cached).max(0), + output: u.output, + cache_read: u.cached, + cache_creation: 0, + }, + ); + } + days +} + +// ---- usage cache ---- +// build_data scans every history .jsonl (~0.5s cold for ~1200 files), and the popover calls +// usage_get TWICE per open (heatmap "all" + stats range). Cache the scanned per-day map keyed by +// the active dirs, invalidated when history files change (notify watcher) — so the second per-open +// call + repeated opens are instant, and a startup/post-change warm makes the first open instant. +struct UsageCache { + sig: String, + days: HashMap, +} +static USAGE_CACHE: std::sync::Mutex> = std::sync::Mutex::new(None); + +fn dirs_sig(config: &Value, active: &str) -> String { + format!("{}|{:?}", active, active_roots(config, active)) +} + +pub(super) fn build_data_cached(config: &Value, active: &str) -> HashMap { + let sig = dirs_sig(config, active); + { + // recover a poisoned lock (a panicked scan thread must not disable caching forever) + let cache = USAGE_CACHE.lock().unwrap_or_else(|p| p.into_inner()); + if let Some(c) = cache.as_ref() { + if c.sig == sig { + return c.days.clone(); + } + } + } + let days = build_data(config, active); + let mut cache = USAGE_CACHE.lock().unwrap_or_else(|p| p.into_inner()); + *cache = Some(UsageCache { sig, days: days.clone() }); + days +} + +/// Drop the cached scan — call when history files change so the next read rescans. +pub fn invalidate_cache() { + let mut cache = USAGE_CACHE.lock().unwrap_or_else(|p| p.into_inner()); + *cache = None; +} + +/// Scan + cache now (off the click path). Call at startup and after history changes so the first +/// popover open is instant instead of paying the cold-scan cost. +pub fn warm_cache(config: &Value, active: &str) { + let _ = build_data_cached(config, active); +} diff --git a/src-tauri/src/usage/claude.rs b/src-tauri/src/usage/claude.rs new file mode 100644 index 0000000..7c5646c --- /dev/null +++ b/src-tauri/src/usage/claude.rs @@ -0,0 +1,107 @@ +// Claude Code (projects/ tree) line parsing and the global ccusage de-dup. Moved verbatim from +// usage.rs. + +use serde_json::Value; +use std::collections::HashMap; + +use super::model::UsageRec; +use super::roots::parse_ts; + +// --------------------------------------------------------------------------- +// Claude Code (projects/ tree) +// --------------------------------------------------------------------------- + +pub(super) struct ClaudeRec { + pub(super) id: Option, + pub(super) request_id: Option, + pub(super) sidechain: bool, + pub(super) rec: UsageRec, +} + +/// Parse one history line into a usage entry. Requires numeric `message.usage.input_tokens` / +/// `output_tokens` and a parseable `timestamp`; everything else is optional. +pub(super) fn parse_claude_line(line: &str) -> Option { + // cheap prefilter before JSON parse (ccusage scans for the same marker) + if !line.contains("\"usage\"") { + return None; + } + let r: Value = serde_json::from_str(line).ok()?; + let m = r.get("message")?; + let u = m.get("usage")?; + let input = u.get("input_tokens")?.as_i64()?; + let output = u.get("output_tokens")?.as_i64()?; + let cache_read = u.get("cache_read_input_tokens").and_then(|v| v.as_i64()).unwrap_or(0); + // nested ephemeral breakdown wins over the flat cache_creation_input_tokens + let cache_creation = match u.get("cache_creation").filter(|v| v.is_object()) { + Some(b) => { + b.get("ephemeral_5m_input_tokens").and_then(|v| v.as_i64()).unwrap_or(0) + + b.get("ephemeral_1h_input_tokens").and_then(|v| v.as_i64()).unwrap_or(0) + } + None => u.get("cache_creation_input_tokens").and_then(|v| v.as_i64()).unwrap_or(0), + }; + if input + output + cache_read + cache_creation <= 0 { + return None; // zero rows (synthetic error turns) carry no token information + } + let ts = r.get("timestamp").and_then(|v| v.as_str()).and_then(parse_ts)?; + let speed_fast = u.get("speed").and_then(|v| v.as_str()) == Some("fast"); + let model = m.get("model").and_then(|v| v.as_str()).and_then(|s| { + if s.is_empty() || s == "" { + None // tokens still count; no model attribution + } else if speed_fast { + Some(format!("{}-fast", s)) + } else { + Some(s.to_string()) + } + }); + Some(ClaudeRec { + id: m.get("id").and_then(|v| v.as_str()).filter(|s| !s.is_empty()).map(String::from), + request_id: r.get("requestId").and_then(|v| v.as_str()).filter(|s| !s.is_empty()).map(String::from), + sidechain: r.get("isSidechain").and_then(|v| v.as_bool()).unwrap_or(false), + rec: UsageRec { ts, model, input, output, cache_read, cache_creation }, + }) +} + +/// Message ids that older ccbud gateway builds stamped on EVERY translated response — known +/// non-unique, so they must never act as a de-dup key (an id-keyed de-dup would collapse whole +/// weeks of history written through the gateway into a single counted turn). +pub(super) fn degenerate_id(id: &str) -> bool { + id == "msg_ccbud" || id == "chatcmpl-ccbud" || id == "resp_ccbud" +} + +/// Global de-dup, ccusage semantics: key (message.id, requestId); entries without an id are always +/// kept. A miss on the exact key falls back to the id-only bucket when either side is a sidechain +/// (a `/btw` replay reuses the parent's message.id under a new requestId). On a duplicate the +/// non-sidechain copy wins, then the higher token total. +pub(super) fn dedup_claude(recs: Vec) -> Vec { + let mut kept: Vec = vec![]; + let mut by_exact: HashMap<(String, Option), usize> = HashMap::new(); + let mut by_id: HashMap = HashMap::new(); + for cand in recs { + let Some(id) = cand.id.clone().filter(|i| !degenerate_id(i)) else { + kept.push(cand); + continue; + }; + let exact = (id.clone(), cand.request_id.clone()); + let slot = by_exact.get(&exact).copied().or_else(|| { + by_id.get(&id).copied().filter(|&i| cand.sidechain || kept[i].sidechain) + }); + match slot { + Some(i) => { + let cur = &kept[i]; + let replace = (cur.sidechain && !cand.sidechain) + || (cur.sidechain == cand.sidechain && cand.rec.total() > cur.rec.total()); + if replace { + kept[i] = cand; + } + by_exact.insert(exact, i); + } + None => { + let i = kept.len(); + by_exact.insert(exact, i); + by_id.entry(id).or_insert(i); + kept.push(cand); + } + } + } + kept +} diff --git a/src-tauri/src/usage/codex.rs b/src-tauri/src/usage/codex.rs new file mode 100644 index 0000000..e6e770b --- /dev/null +++ b/src-tauri/src/usage/codex.rs @@ -0,0 +1,190 @@ +// Codex (sessions/ + archived_sessions/ trees) rollout parsing. Moved verbatim from usage.rs. + +use serde_json::Value; +use std::collections::HashSet; +use std::path::{Path, PathBuf}; + +use super::model::{collect_jsonl, LossyLines}; +use super::roots::parse_ts; + +// --------------------------------------------------------------------------- +// Codex (sessions/ + archived_sessions/ trees) +// --------------------------------------------------------------------------- + +#[derive(Clone, Copy, Default, PartialEq, Eq, Hash)] +pub(super) struct CodexUsage { + pub(super) input: i64, + pub(super) cached: i64, + pub(super) output: i64, + pub(super) reasoning: i64, + total: i64, +} + +/// Lenient token-usage decode (ccusage accepts several field aliases per component). +fn codex_usage_of(v: &Value) -> Option { + let o = v.as_object()?; + let g = |keys: &[&str]| keys.iter().find_map(|k| o.get(*k).and_then(|v| v.as_i64())).unwrap_or(0); + let input = g(&["input_tokens", "prompt_tokens", "input"]); + let cached = g(&["cached_input_tokens", "cache_read_input_tokens", "cached_tokens"]); + let output = g(&["output_tokens", "completion_tokens", "output"]); + let reasoning = g(&["reasoning_output_tokens", "reasoning_tokens"]); + let total = match o.get("total_tokens").and_then(|v| v.as_i64()) { + Some(t) if t > 0 || input + output + reasoning == 0 => t, + _ => input + output + reasoning, + }; + Some(CodexUsage { input, cached, output, reasoning, total }) +} + +fn codex_usage_sub(cur: CodexUsage, prev: Option) -> CodexUsage { + let p = prev.unwrap_or_default(); + CodexUsage { + input: (cur.input - p.input).max(0), + cached: (cur.cached - p.cached).max(0), + output: (cur.output - p.output).max(0), + reasoning: (cur.reasoning - p.reasoning).max(0), + total: (cur.total - p.total).max(0), + } +} + +fn codex_model_of(v: Option<&Value>) -> Option { + let o = v?.as_object()?; + o.get("model") + .or_else(|| o.get("model_name")) + .or_else(|| o.get("metadata").and_then(|m| m.get("model"))) + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .map(String::from) +} + +/// Whether this rollout is a `thread_spawn` subagent session (marker in the file head). +fn codex_is_subagent(file: &Path) -> bool { + use std::io::Read; + let Ok(mut f) = std::fs::File::open(file) else { return false }; + let mut buf = [0u8; 16 * 1024]; + let n = f.read(&mut buf).unwrap_or(0); + buf[..n].windows(b"thread_spawn".len()).any(|w| w == b"thread_spawn") +} + +/// A subagent file replays the parent's token_count history as a leading burst that shares one +/// timestamp-second — detect that second (the first two usage events landing on the same second), +/// so the replay can be skipped while the cumulative baseline still advances. +fn codex_replay_second(file: &Path) -> Option { + let mut first: Option = None; + let mut lines = LossyLines::open(file)?; + while let Some(line) = lines.next_line() { + let Some((ts, payload)) = codex_token_count_line(&line) else { continue }; + let info = payload.get("info"); + let has_usage = info + .map(|i| i.get("last_token_usage").is_some() || i.get("total_token_usage").is_some()) + .unwrap_or(false); + if !has_usage { + continue; + } + let second: String = ts.chars().take(19).collect(); + match &first { + None => first = Some(second), + Some(f) => return if *f == second { Some(second) } else { None }, + } + } + None +} + +/// Parse a line as a `token_count` event → (timestamp, payload). None for everything else. +fn codex_token_count_line(line: &str) -> Option<(String, Value)> { + if !line.contains("token_count") { + return None; + } + let r: Value = serde_json::from_str(line).ok()?; + if r.get("type").and_then(|v| v.as_str()) != Some("event_msg") { + return None; + } + let p = r.get("payload")?; + if p.get("type").and_then(|v| v.as_str()) != Some("token_count") { + return None; + } + let ts = r.get("timestamp").and_then(|v| v.as_str())?.to_string(); + Some((ts, p.clone())) +} + +/// Parse one Codex rollout file into per-turn usage events (ccusage semantics — see module doc). +pub(super) fn parse_codex_file(file: &Path, out: &mut Vec<(CodexUsage, i64, String)>) { + let replay_second = if codex_is_subagent(file) { codex_replay_second(file) } else { None }; + let mut skip_replay = replay_second.is_some(); + let mut current_model: Option = None; + let mut prev_totals: Option = None; + let Some(mut lines) = LossyLines::open(file) else { return }; + while let Some(line) = lines.next_line() { + let s = line.trim(); + if s.is_empty() { + continue; + } + // turn_context carries the active model + if s.contains("turn_context") { + if let Ok(r) = serde_json::from_str::(s) { + if r.get("type").and_then(|v| v.as_str()) == Some("turn_context") { + if let Some(m) = codex_model_of(r.get("payload")) { + current_model = Some(m); + } + continue; + } + } + } + let Some((ts_str, payload)) = codex_token_count_line(s) else { continue }; + let info = payload.get("info").filter(|i| !i.is_null()); + let total = info.and_then(|i| i.get("total_token_usage")).and_then(codex_usage_of); + let last = info.and_then(|i| i.get("last_token_usage")).and_then(codex_usage_of); + // leading parent-history replay in a subagent file: skip, but keep the baseline moving + if skip_replay { + let second: String = ts_str.chars().take(19).collect(); + if Some(&second) == replay_second.as_ref() { + if let Some(t) = total { + prev_totals = Some(t); + } + continue; + } + skip_replay = false; + } + let usage = last.or_else(|| total.map(|t| codex_usage_sub(t, prev_totals))); + if let Some(t) = total { + prev_totals = Some(t); + } + let Some(mut u) = usage else { continue }; + if u.input + u.cached + u.output + u.reasoning == 0 { + continue; + } + let Some(ts) = parse_ts(&ts_str) else { continue }; + u.cached = u.cached.min(u.input); // input is INCLUSIVE of cached + let model = codex_model_of(Some(&payload)) + .or_else(|| codex_model_of(info)) + .or_else(|| current_model.clone()) + .unwrap_or_else(|| "gpt-5".to_string()); + out.push((u, ts, model)); + } +} + +/// Collect a work dir's Codex rollout files: sessions/ plus archived_sessions/, where an archived +/// copy of the same relative path loses to the active sessions/ copy. +pub(super) fn codex_files(root: &Path) -> Vec { + let mut out: Vec = vec![]; + let mut seen_rel: HashSet = HashSet::new(); + for sub in ["sessions", "archived_sessions"] { + let dir = root.join(sub); + let mut files = vec![]; + collect_jsonl(&dir, 0, &mut files); + files.sort(); + for f in files { + // Grok Build shares the sessions/ root but keys children by percent-encoded cwd + // (`%2FUsers%2F…//chat_history.jsonl` + events/updates sidecar jsonl). Those + // must never hit the Codex token parser — wasteful and would mix formats if a line + // ever looked like a token_count event. Skip any path under a Grok-encoded dir. + if f.components().any(|c| crate::grok::is_cwd_dir_name(&c.as_os_str().to_string_lossy())) { + continue; + } + let rel = f.strip_prefix(&dir).map(|p| p.to_path_buf()).unwrap_or_else(|_| f.clone()); + if seen_rel.insert(rel) { + out.push(f); + } + } + } + out +} diff --git a/src-tauri/src/usage/diag.rs b/src-tauri/src/usage/diag.rs new file mode 100644 index 0000000..b487012 --- /dev/null +++ b/src-tauri/src/usage/diag.rs @@ -0,0 +1,79 @@ +// The scan diagnostic line and the module's public entry point. Moved verbatim from usage.rs. + +use chrono::Local; +use serde_json::Value; + +use super::build::{build_data, build_data_cached}; +use super::claude::{dedup_claude, degenerate_id, parse_claude_line, ClaudeRec}; +use super::codex::codex_files; +use super::model::{collect_jsonl, LossyLines}; +use super::query::query; +use super::roots::{active_roots, parse_ts}; + +/// One-line scan diagnostic for the gateway log — which dirs resolved, how many files/lines +/// reached each pipeline stage, and the day span. Makes an empty/partial aggregation visible +/// without a debugger ("only today shows up" → the counters name the stage that dropped it). +pub fn diag(config: &Value, active: &str) -> String { + let roots = active_roots(config, active); + let mut claude_files = 0usize; + let mut codex_file_count = 0usize; + let (mut usage_lines, mut parsed, mut zero_rows, mut no_ts, mut degen) = (0usize, 0usize, 0usize, 0usize, 0usize); + let mut recs: Vec = vec![]; + for root in &roots { + let mut files = vec![]; + collect_jsonl(&root.join("projects"), 0, &mut files); + claude_files += files.len(); + for f in &files { + let Some(mut lines) = LossyLines::open(f) else { continue }; + while let Some(l) = lines.next_line() { + let l = l.trim(); + if !l.contains("\"usage\"") { + continue; + } + usage_lines += 1; + match parse_claude_line(l) { + Some(r) => { + parsed += 1; + if r.id.as_deref().map(degenerate_id).unwrap_or(false) { + degen += 1; + } + recs.push(r); + } + None => { + if let Ok(v) = serde_json::from_str::(l) { + if v.get("message").and_then(|m| m.get("usage")).is_some() { + if v.get("timestamp").and_then(|t| t.as_str()).and_then(parse_ts).is_none() { + no_ts += 1; + } else { + zero_rows += 1; + } + } + } + } + } + } + } + codex_file_count += codex_files(root).len(); + } + let kept = dedup_claude(recs).len(); + let days = build_data(config, active); + let mut keys: Vec<_> = days.keys().cloned().collect(); + keys.sort(); + let span = match (keys.first(), keys.last()) { + (Some(a), Some(b)) => format!("{}..{}", a, b), + _ => "-".to_string(), + }; + format!( + "usage scan: active={} roots={:?} claude-files={} codex-files={} usage-lines={} parsed={} kept={} days={} span={} dropped(no-ts)={} dropped(zero/invalid)={} degenerate-ids={}", + active, + roots.iter().map(|r| r.to_string_lossy().to_string()).collect::>(), + claude_files, codex_file_count, usage_lines, parsed, kept, keys.len(), span, no_ts, zero_rows, degen + ) +} + +/// Public entry: aggregate the active dirs and return the usage stats payload for `range`. +pub fn usage_get(config: &Value, active: &str, range: &str) -> Value { + let days = build_data_cached(config, active); + let now = Local::now().timestamp_millis(); + query(&days, range, now) +} diff --git a/src-tauri/src/usage/diag_probe.rs b/src-tauri/src/usage/diag_probe.rs new file mode 100644 index 0000000..3e8348f --- /dev/null +++ b/src-tauri/src/usage/diag_probe.rs @@ -0,0 +1,8 @@ +use super::*; +use serde_json::json; +#[test] +#[ignore] +fn probe_diag() { + let Ok(dir) = std::env::var("CCBUD_PROBE_DIR") else { return }; + eprintln!("{}", diag(&json!({ "historyDirs": [dir] }), "all")); +} diff --git a/src-tauri/src/usage/mod.rs b/src-tauri/src/usage/mod.rs new file mode 100644 index 0000000..4f63ac8 --- /dev/null +++ b/src-tauri/src/usage/mod.rs @@ -0,0 +1,53 @@ +// Usage analytics — aggregation semantics ported from ccusage (github.com/ccusage/ccusage), +// scoped to the two agents ccbud fronts: Claude Code and Codex. +// +// Per active work dir, two session trees contribute: +// +// Claude Code `projects/**/*.jsonl` (recursive, any depth — sessions, nested session dirs, +// subagent transcripts all included by construction): +// - every line whose `message.usage` carries numeric input/output tokens counts — no +// `type=="assistant"` gate (ccusage parity); +// - a line without a parseable RFC3339 `timestamp` is DROPPED (never guessed); +// - cache-creation prefers the nested `cache_creation.ephemeral_{5m,1h}_input_tokens` +// breakdown over the flat `cache_creation_input_tokens`; +// - `` models keep their tokens but get no model attribution; `usage.speed=="fast"` +// appends a `-fast` suffix to the model; +// - global de-dup by (message.id, requestId) — entries without a message.id are never +// de-duped; a sidechain replay that reuses the parent's message.id under a NEW requestId +// collapses onto the parent (non-sidechain wins, then higher token total). +// +// Codex `sessions/**/*.jsonl` + `archived_sessions/**/*.jsonl` (an archived copy of the same +// relative path is skipped — the active sessions/ copy wins): +// - `token_count` events: prefer `info.last_token_usage` (the turn delta); fall back to +// diffing consecutive `info.total_token_usage` snapshots; the cumulative baseline always +// advances so either form stays correct; +// - `thread_spawn` subagent files replay the parent's history as a leading burst of +// token_count lines sharing one timestamp-second — those are skipped (baseline still +// advances) so parent turns aren't counted twice; +// - identical (timestamp, model, tokens) events across files (resumed/forked sessions) +// de-dup globally; +// - model comes from the event payload/info, else the last `turn_context`, else "gpt-5"; +// `input_tokens` is INCLUSIVE of `cached_input_tokens` — the cached part is split out into +// cacheRead and the remainder becomes input. +// +// Day bucketing is local-timezone (chrono::Local), matching ccusage's system-timezone default. + +#![allow(dead_code)] +mod build; +mod claude; +mod codex; +mod diag; +mod model; +mod query; +mod roots; +#[cfg(test)] +mod diag_probe; +#[cfg(test)] +mod real_data_probe; +#[cfg(test)] +mod tests; +#[cfg(test)] +mod tests_codex; + +pub use build::{invalidate_cache, warm_cache}; +pub use diag::{diag, usage_get}; diff --git a/src-tauri/src/usage/model.rs b/src-tauri/src/usage/model.rs new file mode 100644 index 0000000..db445df --- /dev/null +++ b/src-tauri/src/usage/model.rs @@ -0,0 +1,95 @@ +// The per-day bucket, the counted usage event both trees produce, and the two file-walking +// primitives they share. Moved verbatim from usage.rs. + +use std::collections::HashMap; +use std::io::BufRead; +use std::path::{Path, PathBuf}; + +use super::roots::{hour_of, key_of}; + +#[derive(Default, Clone)] +pub(super) struct Day { + pub(super) tokens: i64, + pub(super) input: i64, + pub(super) output: i64, + pub(super) cache_read: i64, + pub(super) cache_creation: i64, + pub(super) requests: i64, + pub(super) models: HashMap, + pub(super) providers: HashMap, + pub(super) hours: HashMap, +} + +/// One counted usage event, whichever tree it came from. +pub(super) struct UsageRec { + pub(super) ts: i64, + pub(super) model: Option, + pub(super) input: i64, + pub(super) output: i64, + pub(super) cache_read: i64, + pub(super) cache_creation: i64, +} + +impl UsageRec { + pub(super) fn total(&self) -> i64 { + self.input + self.output + self.cache_read + self.cache_creation + } +} + +pub(super) fn bump(days: &mut HashMap, rec: &UsageRec) { + let day = days.entry(key_of(rec.ts)).or_default(); + day.requests += 1; + day.tokens += rec.total(); + day.input += rec.input; + day.output += rec.output; + day.cache_read += rec.cache_read; + day.cache_creation += rec.cache_creation; + if let Some(m) = &rec.model { + *day.models.entry(m.clone()).or_insert(0) += rec.total(); + } + *day.hours.entry(hour_of(rec.ts)).or_insert(0) += rec.total(); +} + +/// Recursively collect `*.jsonl` under `dir`, any depth (ccusage walks the whole tree — nested +/// session dirs and subagent transcripts are picked up by construction). Depth-capped as a +/// symlink-loop guard. +pub(super) fn collect_jsonl(dir: &Path, depth: u32, out: &mut Vec) { + if depth > 8 { + return; + } + let entries = match std::fs::read_dir(dir) { + Ok(e) => e, + Err(_) => return, + }; + for ent in entries.flatten() { + let p = ent.path(); + if p.is_dir() { + collect_jsonl(&p, depth + 1, out); + } else if p.is_file() && p.extension().and_then(|e| e.to_str()) == Some("jsonl") { + out.push(p); + } + } +} + +/// Byte-based lossy line reader. History files can embed invalid UTF-8 inside tool output — +/// a strict `BufRead::lines` errors there and would silently discard the REST of the file +/// (ccusage reads raw bytes for the same reason). +pub(super) struct LossyLines { + reader: std::io::BufReader, + buf: Vec, +} + +impl LossyLines { + pub(super) fn open(file: &Path) -> Option { + std::fs::File::open(file) + .ok() + .map(|f| Self { reader: std::io::BufReader::new(f), buf: Vec::with_capacity(64 * 1024) }) + } + pub(super) fn next_line(&mut self) -> Option { + self.buf.clear(); + match self.reader.read_until(b'\n', &mut self.buf) { + Ok(0) | Err(_) => None, + Ok(_) => Some(String::from_utf8_lossy(&self.buf).into_owned()), + } + } +} diff --git a/src-tauri/src/usage/query.rs b/src-tauri/src/usage/query.rs new file mode 100644 index 0000000..a663672 --- /dev/null +++ b/src-tauri/src/usage/query.rs @@ -0,0 +1,151 @@ +// Range selection, streaks, heatmap and the stats payload the renderer consumes. Moved verbatim +// from usage.rs. + +use chrono::{Datelike, Local, TimeZone}; +use serde_json::{json, Value}; +use std::collections::{HashMap, HashSet}; + +use super::model::Day; +use super::roots::{key_of, ms_of_key, start_of_day, DAY_MS, HEATMAP_WEEKS}; + +fn range_keys(days: &HashMap, range: &str, now: i64) -> Vec { + let mut all: Vec = days.keys().cloned().collect(); + all.sort(); + if range == "all" { + return all; + } + let n = match range { + "1d" => 1, + "30d" => 30, + _ => 7, + }; + let cut = start_of_day(now - (n - 1) * DAY_MS); + all.into_iter().filter(|k| ms_of_key(k) >= cut).collect() +} + +fn top_key(map: &HashMap) -> Option { + map.iter().max_by_key(|(_, v)| **v).map(|(k, _)| k.clone()) +} + +fn streaks(days: &HashMap, now: i64) -> (i64, i64) { + let mut active: Vec = days + .iter() + .filter(|(_, d)| d.requests > 0) + .map(|(k, _)| ms_of_key(k)) + .collect(); + active.sort(); + let set: HashSet = active.iter().cloned().collect(); + let (mut longest, mut run, mut prev): (i64, i64, Option) = (0, 0, None); + for t in &active { + run = if prev.map(|p| t - p == DAY_MS).unwrap_or(false) { run + 1 } else { 1 }; + prev = Some(*t); + if run > longest { + longest = run; + } + } + let mut cur = 0; + let mut t = start_of_day(now); + if !set.contains(&t) { + t -= DAY_MS; + } + while set.contains(&t) { + cur += 1; + t -= DAY_MS; + } + (cur, longest) +} + +fn build_heatmap(days: &HashMap, weeks: i64, now: i64) -> Vec { + let today = start_of_day(now); + let span = weeks * 7; + let mut start = today - (span - 1) * DAY_MS; + let dow = Local.timestamp_millis_opt(start).single().map(|d| d.weekday().num_days_from_sunday() as i64).unwrap_or(0); + start -= dow * DAY_MS; + let mut cells: Vec<(String, i64)> = vec![]; + let mut max = 1i64; + let mut t = start; + while t <= today { + let k = key_of(t); + let tok = days.get(&k).map(|d| d.tokens).unwrap_or(0); + if tok > max { + max = tok; + } + cells.push((k, tok)); + t += DAY_MS; + } + cells + .into_iter() + .map(|(date, tokens)| { + let r = tokens as f64 / max as f64; + let level = if tokens == 0 { + 0 + } else if r > 0.66 { + 4 + } else if r > 0.33 { + 3 + } else if r > 0.1 { + 2 + } else { + 1 + }; + json!({ "date": date, "tokens": tokens, "level": level }) + }) + .collect() +} + +pub(super) fn query(days: &HashMap, range: &str, now: i64) -> Value { + let keys = range_keys(days, range, now); + let (mut tokens, mut input, mut output, mut cache_read, mut cache_creation, mut requests) = (0i64, 0i64, 0i64, 0i64, 0i64, 0i64); + let mut models: HashMap = HashMap::new(); + let mut providers: HashMap = HashMap::new(); + let mut hours: HashMap = HashMap::new(); + let mut active_days = 0; + for k in &keys { + if let Some(d) = days.get(k) { + tokens += d.tokens; + input += d.input; + output += d.output; + cache_read += d.cache_read; + cache_creation += d.cache_creation; + requests += d.requests; + if d.requests > 0 { + active_days += 1; + } + for (m, v) in &d.models { + *models.entry(m.clone()).or_insert(0) += v; + } + for (p, v) in &d.providers { + *providers.entry(p.clone()).or_insert(0) += v; + } + for (h, v) in &d.hours { + *hours.entry(*h).or_insert(0) += v; + } + } + } + let mut by_model: Vec = models + .iter() + .map(|(m, t)| json!({ "model": m, "tokens": t, "pct": if tokens > 0 { *t as f64 / tokens as f64 } else { 0.0 } })) + .collect(); + by_model.sort_by(|a, b| b["tokens"].as_i64().unwrap_or(0).cmp(&a["tokens"].as_i64().unwrap_or(0))); + let mut by_provider: Vec = providers + .iter() + .map(|(p, t)| json!({ "provider": p, "tokens": t, "pct": if tokens > 0 { *t as f64 / tokens as f64 } else { 0.0 } })) + .collect(); + by_provider.sort_by(|a, b| b["tokens"].as_i64().unwrap_or(0).cmp(&a["tokens"].as_i64().unwrap_or(0))); + let peak_hour = hours.iter().max_by_key(|(_, v)| **v).map(|(h, _)| *h as i64); + let (cur, longest) = streaks(days, now); + + json!({ + "range": range, + "tokens": tokens, "input": input, "output": output, "cacheRead": cache_read, "cacheCreation": cache_creation, + "requests": requests, "activeDays": active_days, + "peakHour": peak_hour, + "favoriteModel": top_key(&models), + "favoriteProvider": top_key(&providers), + "byModel": by_model, + "byProvider": by_provider, + "currentStreak": cur, + "longestStreak": longest, + "heatmap": build_heatmap(days, HEATMAP_WEEKS, now), + }) +} diff --git a/src-tauri/src/usage/real_data_probe.rs b/src-tauri/src/usage/real_data_probe.rs new file mode 100644 index 0000000..6847047 --- /dev/null +++ b/src-tauri/src/usage/real_data_probe.rs @@ -0,0 +1,70 @@ +use super::build::build_data; +use super::claude::{degenerate_id, parse_claude_line}; +use super::model::{collect_jsonl, LossyLines}; +use super::query::query; +use super::roots::{expand_tilde, parse_ts}; +use chrono::Local; +use serde_json::{json, Value}; + +// Diagnostic harness (not an assertion): aggregate a REAL history dir and print per-range +// totals, so the implementation can be diffed against `ccusage` on the same data. +// Run: CCBUD_PROBE_DIR=~/.claude cargo test --lib probe_real_dir -- --ignored --nocapture +#[test] +#[ignore] +fn probe_real_dir() { + let Ok(dir) = std::env::var("CCBUD_PROBE_DIR") else { + eprintln!("set CCBUD_PROBE_DIR"); + return; + }; + // parse-level diagnostics: where do lines fall out of the pipeline? + let root = expand_tilde(&dir); + let mut files = vec![]; + collect_jsonl(&root.join("projects"), 0, &mut files); + let (mut n_files, mut n_usage_lines, mut n_parsed, mut n_no_ts, mut n_degen) = (0u64, 0u64, 0u64, 0u64, 0u64); + for file in &files { + n_files += 1; + let Some(mut lines) = LossyLines::open(file) else { continue }; + while let Some(l) = lines.next_line() { + let l = l.trim(); + if !l.contains("\"usage\"") { + continue; + } + n_usage_lines += 1; + match parse_claude_line(l) { + Some(rec) => { + n_parsed += 1; + if rec.id.as_deref().map(degenerate_id).unwrap_or(false) { + n_degen += 1; + } + } + None => { + // distinguish the "usage present but timestamp bad/missing" case + if let Ok(v) = serde_json::from_str::(l) { + if v.get("message").and_then(|m| m.get("usage")).is_some() + && v.get("timestamp").and_then(|t| t.as_str()).and_then(parse_ts).is_none() + { + n_no_ts += 1; + } + } + } + } + } + } + eprintln!( + "claude files={} usage-lines={} parsed={} dropped-no-ts={} degenerate-id={}", + n_files, n_usage_lines, n_parsed, n_no_ts, n_degen + ); + let config = json!({ "historyDirs": [dir] }); + let days = build_data(&config, "all"); + let now = Local::now().timestamp_millis(); + let mut keys: Vec<_> = days.keys().cloned().collect(); + keys.sort(); + for k in &keys { + let d = &days[k]; + eprintln!("{} tokens={} in={} out={} cr={} cc={} req={}", k, d.tokens, d.input, d.output, d.cache_read, d.cache_creation, d.requests); + } + for range in ["1d", "7d", "30d", "all"] { + let q = query(&days, range, now); + eprintln!("range {:>3}: tokens={} requests={}", range, q["tokens"], q["requests"]); + } +} diff --git a/src-tauri/src/usage/roots.rs b/src-tauri/src/usage/roots.rs new file mode 100644 index 0000000..82d5abb --- /dev/null +++ b/src-tauri/src/usage/roots.rs @@ -0,0 +1,78 @@ +// Active work-dir resolution and the local-timezone day-key math every aggregation stage keys +// on. Moved verbatim from usage.rs. + +use chrono::{Datelike, Local, TimeZone, Timelike}; +use serde_json::Value; +use std::path::PathBuf; + +pub(super) const DAY_MS: i64 = 86_400_000; +pub(super) const HEATMAP_WEEKS: i64 = 26; + +fn home() -> PathBuf { + std::env::var("HOME").map(PathBuf::from).unwrap_or_else(|_| PathBuf::from(".")) +} +pub(super) fn expand_tilde(p: &str) -> PathBuf { + if let Some(rest) = p.strip_prefix("~/") { + home().join(rest) + } else if p == "~" { + home() + } else { + PathBuf::from(p) + } +} + +/// Active work dirs (honors the directory switcher). A selector that matches no configured dir — +/// the synthetic recycle-bin / imported-bundle views ("__trash__", "__imported__"), or a stale +/// value from an older config — falls back to ALL dirs: a filter must never zero the stats. +pub(super) fn active_roots(config: &Value, active: &str) -> Vec { + let mut all = vec![]; + let mut selected = vec![]; + if let Some(arr) = config.get("historyDirs").and_then(|v| v.as_array()) { + for d in arr { + if let Some(s) = d.as_str() { + all.push(expand_tilde(s)); + if active == s { + selected.push(expand_tilde(s)); + } + } + } + } + if active != "all" && !selected.is_empty() { + selected + } else { + all + } +} + +pub(super) fn parse_ts(s: &str) -> Option { + chrono::DateTime::parse_from_rfc3339(s).ok().map(|d| d.timestamp_millis()) +} +pub(super) fn key_of(ms: i64) -> String { + match Local.timestamp_millis_opt(ms).single() { + Some(d) => format!("{:04}-{:02}-{:02}", d.year(), d.month(), d.day()), + None => "1970-01-01".to_string(), + } +} +pub(super) fn start_of_day(ms: i64) -> i64 { + match Local.timestamp_millis_opt(ms).single() { + Some(d) => { + let day = d.date_naive().and_hms_opt(0, 0, 0).unwrap(); + Local.from_local_datetime(&day).single().map(|x| x.timestamp_millis()).unwrap_or(ms) + } + None => ms, + } +} +pub(super) fn ms_of_key(k: &str) -> i64 { + let parts: Vec = k.split('-').filter_map(|x| x.parse().ok()).collect(); + if parts.len() != 3 { + return 0; + } + let nd = chrono::NaiveDate::from_ymd_opt(parts[0] as i32, parts[1] as u32, parts[2] as u32); + match nd.and_then(|d| d.and_hms_opt(0, 0, 0)) { + Some(dt) => Local.from_local_datetime(&dt).single().map(|x| x.timestamp_millis()).unwrap_or(0), + None => 0, + } +} +pub(super) fn hour_of(ms: i64) -> u32 { + Local.timestamp_millis_opt(ms).single().map(|d| d.hour()).unwrap_or(0) +} diff --git a/src-tauri/src/usage/tests.rs b/src-tauri/src/usage/tests.rs new file mode 100644 index 0000000..a2ab3c3 --- /dev/null +++ b/src-tauri/src/usage/tests.rs @@ -0,0 +1,173 @@ +use super::build::build_data; +use super::model::Day; +use serde_json::{json, Value}; +use std::collections::HashMap; +use std::fs; + +pub(super) fn line(v: Value) -> String { + format!("{}\n", v) +} +fn asst(id: &str, req: &str, model: &str, ts: &str, inp: i64, out: i64) -> String { + line(json!({ "type": "assistant", "timestamp": ts, "requestId": req, + "message": { "id": id, "model": model, + "usage": { "input_tokens": inp, "output_tokens": out } } })) +} + +pub(super) fn sum(days: &HashMap) -> (i64, i64, i64, i64, i64, HashMap) { + let (mut tokens, mut input, mut output, mut cache_read, mut requests) = (0i64, 0i64, 0i64, 0i64, 0i64); + let mut models: HashMap = HashMap::new(); + for d in days.values() { + tokens += d.tokens; + input += d.input; + output += d.output; + cache_read += d.cache_read; + requests += d.requests; + for (m, v) in &d.models { + *models.entry(m.clone()).or_insert(0) += v; + } + } + (tokens, input, output, cache_read, requests, models) +} + +#[test] +fn claude_ccusage_semantics() { + let base = std::env::temp_dir().join(format!("ccbud-usage-cl-{}", std::process::id())); + let _ = fs::remove_dir_all(&base); + let proj = base.join("projects").join("-p"); + // nested session dir + subagent transcript at arbitrary depth — recursive walk finds both + let deep = proj.join("s1").join("subagents"); + fs::create_dir_all(&deep).unwrap(); + + fs::write( + proj.join("s1.jsonl"), + // counted (110) + asst("m1", "r1", "claude-x", "2026-07-01T10:00:00Z", 100, 10) + // same (id, requestId) duplicate → collapsed + + &asst("m1", "r1", "claude-x", "2026-07-01T10:00:00Z", 100, 10) + // same id, DIFFERENT requestId, no sidechain → distinct entry (counted, 55) + + &asst("m1", "r2", "claude-x", "2026-07-01T10:05:00Z", 50, 5) + // undated → dropped + + &line(json!({ "type": "assistant", + "message": { "id": "m2", "model": "claude-x", "usage": { "input_tokens": 9, "output_tokens": 9 } } })) + // zero usage → dropped + + &asst("m3", "r3", "", "2026-07-01T10:06:00Z", 0, 0) + // synthetic model with tokens → counted (7), no model attribution + + &asst("m4", "r4", "", "2026-07-01T10:07:00Z", 5, 2) + // no type field at all (ccusage has no type gate) → counted (13) + + &line(json!({ "timestamp": "2026-07-01T10:08:00Z", "requestId": "r5", + "message": { "id": "m5", "model": "claude-x", + "usage": { "input_tokens": 10, "output_tokens": 3 } } })), + ) + .unwrap(); + // subagent transcript, nested cache_creation breakdown + fast speed suffix (counted, 3+4+6+7=20) + fs::write( + deep.join("agent-a.jsonl"), + line(json!({ "timestamp": "2026-07-01T11:00:00Z", "requestId": "r6", + "message": { "id": "m6", "model": "claude-x", + "usage": { "input_tokens": 3, "output_tokens": 4, "speed": "fast", + "cache_read_input_tokens": 6, + "cache_creation_input_tokens": 999, + "cache_creation": { "ephemeral_5m_input_tokens": 5, "ephemeral_1h_input_tokens": 2 } } } })), + ) + .unwrap(); + // sidechain replay: reuses m1 under a NEW requestId with isSidechain → collapses onto parent + fs::write( + proj.join("s2.jsonl"), + line(json!({ "type": "assistant", "timestamp": "2026-07-01T10:00:01Z", "requestId": "r9", + "isSidechain": true, + "message": { "id": "m1", "model": "claude-x", "usage": { "input_tokens": 100, "output_tokens": 10 } } })), + ) + .unwrap(); + + let config = json!({ "historyDirs": [ base.to_string_lossy() ] }); + let days = build_data(&config, "all"); + let (tokens, input, output, cache_read, requests, models) = sum(&days); + // m1(110) + m1/r2(55) + m4(7) + m5(13) + m6(20) + assert_eq!(requests, 5); + assert_eq!(input, 100 + 50 + 5 + 10 + 3); + assert_eq!(output, 10 + 5 + 2 + 3 + 4); + assert_eq!(cache_read, 6); + assert_eq!(tokens, 110 + 55 + 7 + 13 + 20); + // synthetic tokens counted but unattributed; fast suffix applied + assert_eq!(models.get("claude-x").copied(), Some(110 + 55 + 13)); + assert_eq!(models.get("claude-x-fast").copied(), Some(20)); + assert!(models.get("").is_none()); + + let _ = fs::remove_dir_all(&base); +} + +// History written through OLD ccbud gateway builds: every streamed response carries the +// constant id "msg_ccbud" (and often no requestId — the gateway didn't forward the header). +// Those ids must never act as de-dup keys, or weeks of history collapse into one turn. +#[test] +fn degenerate_gateway_ids_never_dedup() { + let base = std::env::temp_dir().join(format!("ccbud-usage-degen-{}", std::process::id())); + let _ = fs::remove_dir_all(&base); + let proj = base.join("projects").join("-p"); + fs::create_dir_all(&proj).unwrap(); + let no_req = |ts: &str, inp: i64| { + line(json!({ "type": "assistant", "timestamp": ts, + "message": { "id": "msg_ccbud", "model": "glm-4.7", + "usage": { "input_tokens": inp, "output_tokens": 1 } } })) + }; + fs::write( + proj.join("old-era.jsonl"), + no_req("2026-06-20T10:00:00Z", 100) + + &no_req("2026-06-21T10:00:00Z", 200) + + &no_req("2026-06-22T10:00:00Z", 300) + + &line(json!({ "type": "assistant", "timestamp": "2026-06-23T10:00:00Z", "requestId": "r1", + "message": { "id": "chatcmpl-ccbud", "model": "glm-4.7", + "usage": { "input_tokens": 400, "output_tokens": 1 } } })), + ) + .unwrap(); + let config = json!({ "historyDirs": [ base.to_string_lossy() ] }); + let days = build_data(&config, "all"); + let (_, input, _, _, requests, _) = sum(&days); + // all four turns count — four distinct days survive + assert_eq!(requests, 4); + assert_eq!(input, 100 + 200 + 300 + 400); + assert_eq!(days.len(), 4); + let _ = fs::remove_dir_all(&base); +} + +// The 对话 page's dir switcher persists synthetic views (recycle bin, imported bundles) into +// historyActive — those match no configured dir and previously zeroed every usage number. +#[test] +fn synthetic_or_stale_active_falls_back_to_all_dirs() { + let base = std::env::temp_dir().join(format!("ccbud-usage-active-{}", std::process::id())); + let _ = fs::remove_dir_all(&base); + let proj = base.join("projects").join("-p"); + fs::create_dir_all(&proj).unwrap(); + fs::write(proj.join("s.jsonl"), asst("a1", "r1", "m", "2026-07-01T10:00:00Z", 10, 1)).unwrap(); + let config = json!({ "historyDirs": [ base.to_string_lossy() ] }); + for active in ["all", "__trash__", "__imported__", "/no/such/dir"] { + let days = build_data(&config, active); + let (tokens, ..) = sum(&days); + assert_eq!(tokens, 11, "active={} must not zero the stats", active); + } + // a VALID selector still filters + let days = build_data(&config, base.to_string_lossy().as_ref()); + let (tokens, ..) = sum(&days); + assert_eq!(tokens, 11); + let _ = fs::remove_dir_all(&base); +} + +#[test] +fn invalid_utf8_does_not_truncate_a_file() { + let base = std::env::temp_dir().join(format!("ccbud-usage-u8-{}", std::process::id())); + let _ = fs::remove_dir_all(&base); + let proj = base.join("projects").join("-p"); + fs::create_dir_all(&proj).unwrap(); + let mut bytes = asst("u1", "r1", "m", "2026-07-01T10:00:00Z", 10, 1).into_bytes(); + bytes.extend_from_slice(b"{\"garbage\": \"\xff\xfe binary tool output\"}\n"); + bytes.extend_from_slice(asst("u2", "r2", "m", "2026-07-02T10:00:00Z", 20, 2).as_bytes()); + fs::write(proj.join("s.jsonl"), bytes).unwrap(); + + let config = json!({ "historyDirs": [ base.to_string_lossy() ] }); + let days = build_data(&config, "all"); + let (_, input, _, _, requests, _) = sum(&days); + // the record AFTER the invalid-UTF-8 line still counts + assert_eq!(requests, 2); + assert_eq!(input, 30); + let _ = fs::remove_dir_all(&base); +} diff --git a/src-tauri/src/usage/tests_codex.rs b/src-tauri/src/usage/tests_codex.rs new file mode 100644 index 0000000..5bfdf0b --- /dev/null +++ b/src-tauri/src/usage/tests_codex.rs @@ -0,0 +1,78 @@ +use super::build::build_data; +use super::tests::{line, sum}; +use serde_json::json; +use std::fs; + +fn tc(ts: &str, last: Option<(i64, i64, i64)>, total: Option<(i64, i64, i64)>) -> String { + let mut info = json!({}); + if let Some((i, c, o)) = last { + info["last_token_usage"] = json!({ "input_tokens": i, "cached_input_tokens": c, "output_tokens": o, + "total_tokens": i + o }); + } + if let Some((i, c, o)) = total { + info["total_token_usage"] = json!({ "input_tokens": i, "cached_input_tokens": c, "output_tokens": o, + "total_tokens": i + o }); + } + line(json!({ "timestamp": ts, "type": "event_msg", "payload": { "type": "token_count", "info": info } })) +} + +#[test] +fn codex_ccusage_semantics() { + let base = std::env::temp_dir().join(format!("ccbud-usage-cx-{}", std::process::id())); + let _ = fs::remove_dir_all(&base); + let day = base.join("sessions").join("2026").join("07").join("01"); + fs::create_dir_all(&day).unwrap(); + + // main session: model from turn_context; one last_token_usage turn; one turn WITHOUT + // last (only cumulative total) → counted as the diff from the baseline. + fs::write( + day.join("rollout-a.jsonl"), + line(json!({ "timestamp": "2026-07-01T12:00:00Z", "type": "session_meta", "payload": { "id": "a" } })) + + &line(json!({ "timestamp": "2026-07-01T12:00:01Z", "type": "turn_context", "payload": { "model": "gpt-5.5" } })) + + &tc("2026-07-01T12:00:02Z", Some((900, 600, 80)), Some((900, 600, 80))) + + &tc("2026-07-01T12:00:03Z", None, Some((1400, 900, 130))) // diff: 500/300/50 + + &tc("2026-07-01T12:00:04Z", None, None), // info without usage → skipped + ) + .unwrap(); + // resumed copy of the same session: identical events must de-dup, a new turn counts. + fs::write( + day.join("rollout-b.jsonl"), + line(json!({ "timestamp": "2026-07-01T12:10:00Z", "type": "turn_context", "payload": { "model": "gpt-5.5" } })) + + &tc("2026-07-01T12:00:02Z", Some((900, 600, 80)), None) // duplicate of a's turn 1 + + &tc("2026-07-01T12:10:01Z", Some((10, 0, 5)), None), // new turn (15) + ) + .unwrap(); + // archived copy of rollout-a (same relative path) → file-level de-dup, never read twice. + let arch = base.join("archived_sessions").join("2026").join("07").join("01"); + fs::create_dir_all(&arch).unwrap(); + fs::write(arch.join("rollout-a.jsonl"), tc("2026-07-01T12:00:02Z", Some((900, 600, 80)), None)).unwrap(); + // thread_spawn subagent: leading replay burst (same second) skipped, own turn counted, + // and the baseline carried from the replayed cumulative total. + fs::write( + day.join("rollout-sub.jsonl"), + line(json!({ "timestamp": "2026-07-01T13:00:00Z", "type": "session_meta", + "payload": { "id": "sub", "source": { "type": "thread_spawn" } } })) + + &tc("2026-07-01T13:00:01Z", Some((900, 600, 80)), Some((900, 600, 80))) + + &tc("2026-07-01T13:00:01Z", Some((500, 300, 50)), Some((1400, 900, 130))) + + &tc("2026-07-01T13:00:05Z", None, Some((1600, 900, 160))), // own turn: diff 200/0/30 + ) + .unwrap(); + + let config = json!({ "historyDirs": [ base.to_string_lossy() ] }); + let days = build_data(&config, "all"); + let (tokens, input, output, cache_read, requests, models) = sum(&days); + // a#1: in 900 (cached 600) out 80 → input 300, cacheRead 600, out 80 (980) + // a#2 (diff): in 500 (cached 300) out 50 → input 200, cacheRead 300, out 50 (550) + // b#2: 10/0/5 (15) + // sub own turn: 200/0/30 (230) + assert_eq!(requests, 4); + assert_eq!(input, 300 + 200 + 10 + 200); + assert_eq!(cache_read, 600 + 300); + assert_eq!(output, 80 + 50 + 5 + 30); + assert_eq!(tokens, 980 + 550 + 15 + 230); + assert_eq!(models.get("gpt-5.5").copied(), Some(980 + 550 + 15)); + // subagent file had no turn_context → fallback model + assert_eq!(models.get("gpt-5").copied(), Some(230)); + + let _ = fs::remove_dir_all(&base); +} diff --git a/src-tauri/src/ziputil.rs b/src-tauri/src/ziputil.rs deleted file mode 100644 index bfb91a4..0000000 --- a/src-tauri/src/ziputil.rs +++ /dev/null @@ -1,291 +0,0 @@ -// Minimal ZIP reader/writer for conversation bundles. Rust port of src/main/zipStore.js — the byte -// layout is proven there by test/zip.test.js (round-trip + system `unzip`), so this mirror stays in -// lockstep with it. -// -// A conversation with subagents exports as a .zip whose FIRST level is the main session .jsonl and -// whose `subagents/` directory holds the per-subagent files; re-importing restores that layout. -// Only the round-trip slice of the spec is implemented: -// - write: STORE or raw-DEFLATE per entry (whichever is smaller), no zip64, no data descriptors. -// - read : parse via the central directory (so OS-repacked zips with data descriptors still read), -// handling STORE (0) and DEFLATE (8); unreadable members are skipped, never panic. - -#![allow(dead_code)] - -use flate2::read::DeflateDecoder; -use flate2::write::DeflateEncoder; -use flate2::Compression; -use std::io::{Read, Write}; - -pub struct Entry { - pub name: String, - pub data: Vec, -} - -fn crc32(data: &[u8]) -> u32 { - let mut crc: u32 = 0xffff_ffff; - for &b in data { - crc ^= b as u32; - for _ in 0..8 { - let mask = (crc & 1).wrapping_neg(); - crc = (crc >> 1) ^ (0xedb8_8320 & mask); - } - } - !crc -} - -fn deflate(data: &[u8]) -> Option> { - let mut enc = DeflateEncoder::new(Vec::new(), Compression::default()); - enc.write_all(data).ok()?; - enc.finish().ok() -} - -/// Build a .zip from entries. STORE unless raw-DEFLATE is strictly smaller. -pub fn build(entries: &[Entry]) -> Vec { - let mut local: Vec = Vec::new(); - let mut central: Vec = Vec::new(); - let mut offset: u32 = 0; - for e in entries { - let name = e.name.as_bytes(); - let crc = crc32(&e.data); - let deflated = deflate(&e.data); - let (method, payload): (u16, &[u8]) = match &deflated { - Some(d) if d.len() < e.data.len() => (8, d.as_slice()), - _ => (0, e.data.as_slice()), - }; - let comp_size = payload.len() as u32; - let uncomp_size = e.data.len() as u32; - - // local file header - local.extend_from_slice(&0x0403_4b50u32.to_le_bytes()); - local.extend_from_slice(&20u16.to_le_bytes()); // version needed - local.extend_from_slice(&0u16.to_le_bytes()); // flags - local.extend_from_slice(&method.to_le_bytes()); - local.extend_from_slice(&0u16.to_le_bytes()); // mod time - local.extend_from_slice(&0x21u16.to_le_bytes()); // mod date = 1980-01-01 - local.extend_from_slice(&crc.to_le_bytes()); - local.extend_from_slice(&comp_size.to_le_bytes()); - local.extend_from_slice(&uncomp_size.to_le_bytes()); - local.extend_from_slice(&(name.len() as u16).to_le_bytes()); - local.extend_from_slice(&0u16.to_le_bytes()); // extra length - local.extend_from_slice(name); - local.extend_from_slice(payload); - - // central directory header - central.extend_from_slice(&0x0201_4b50u32.to_le_bytes()); - central.extend_from_slice(&20u16.to_le_bytes()); // version made by - central.extend_from_slice(&20u16.to_le_bytes()); // version needed - central.extend_from_slice(&0u16.to_le_bytes()); // flags - central.extend_from_slice(&method.to_le_bytes()); - central.extend_from_slice(&0u16.to_le_bytes()); // mod time - central.extend_from_slice(&0x21u16.to_le_bytes()); // mod date - central.extend_from_slice(&crc.to_le_bytes()); - central.extend_from_slice(&comp_size.to_le_bytes()); - central.extend_from_slice(&uncomp_size.to_le_bytes()); - central.extend_from_slice(&(name.len() as u16).to_le_bytes()); - central.extend_from_slice(&0u16.to_le_bytes()); // extra length - central.extend_from_slice(&0u16.to_le_bytes()); // comment length - central.extend_from_slice(&0u16.to_le_bytes()); // disk number start - central.extend_from_slice(&0u16.to_le_bytes()); // internal attrs - central.extend_from_slice(&0u32.to_le_bytes()); // external attrs - central.extend_from_slice(&offset.to_le_bytes()); // relative offset of local header - central.extend_from_slice(name); - - offset += 30 + name.len() as u32 + comp_size; - } - let central_start = offset; - let central_size = central.len() as u32; - let mut out = local; - out.extend_from_slice(¢ral); - // end of central directory record - out.extend_from_slice(&0x0605_4b50u32.to_le_bytes()); - out.extend_from_slice(&0u16.to_le_bytes()); // this disk - out.extend_from_slice(&0u16.to_le_bytes()); // disk with central dir - out.extend_from_slice(&(entries.len() as u16).to_le_bytes()); // entries this disk - out.extend_from_slice(&(entries.len() as u16).to_le_bytes()); // total entries - out.extend_from_slice(¢ral_size.to_le_bytes()); - out.extend_from_slice(¢ral_start.to_le_bytes()); - out.extend_from_slice(&0u16.to_le_bytes()); // comment length - out -} - -fn rd_u16(buf: &[u8], at: usize) -> Option { - buf.get(at..at + 2).map(|s| u16::from_le_bytes([s[0], s[1]])) -} -fn rd_u32(buf: &[u8], at: usize) -> Option { - buf.get(at..at + 4).map(|s| u32::from_le_bytes([s[0], s[1], s[2], s[3]])) -} - -/// Parse a .zip → entries. Best-effort: unreadable/unsupported members are skipped. -pub fn read(buf: &[u8]) -> Vec { - let mut out: Vec = Vec::new(); - if buf.len() < 22 { - return out; - } - // Locate the End Of Central Directory record by scanning backwards for its signature. - let mut eocd: Option = None; - let top = buf.len() - 22; - let floor = top.saturating_sub(65535); - let mut i = top; - loop { - if rd_u32(buf, i) == Some(0x0605_4b50) { - eocd = Some(i); - break; - } - if i <= floor { - break; - } - i -= 1; - } - let eocd = match eocd { - Some(e) => e, - None => return out, - }; - let count = rd_u16(buf, eocd + 10).unwrap_or(0) as usize; - let mut p = rd_u32(buf, eocd + 16).unwrap_or(0) as usize; // central directory offset - for _ in 0..count { - if rd_u32(buf, p) != Some(0x0201_4b50) { - break; - } - let method = rd_u16(buf, p + 10).unwrap_or(0); - let comp_size = rd_u32(buf, p + 20).unwrap_or(0) as usize; - let name_len = rd_u16(buf, p + 28).unwrap_or(0) as usize; - let extra_len = rd_u16(buf, p + 30).unwrap_or(0) as usize; - let comment_len = rd_u16(buf, p + 32).unwrap_or(0) as usize; - let local_off = rd_u32(buf, p + 42).unwrap_or(0) as usize; - let name = buf - .get(p + 46..(p + 46 + name_len).min(buf.len())) - .map(|s| String::from_utf8_lossy(s).into_owned()) - .unwrap_or_default(); - // The local header repeats name/extra lengths; trust it for the data offset. - if rd_u32(buf, local_off) == Some(0x0403_4b50) { - let lh_name = rd_u16(buf, local_off + 26).unwrap_or(0) as usize; - let lh_extra = rd_u16(buf, local_off + 28).unwrap_or(0) as usize; - let data_start = local_off + 30 + lh_name + lh_extra; - let data_end = data_start + comp_size; - if data_end <= buf.len() { - let payload = &buf[data_start..data_end]; - let data = match method { - 0 => Some(payload.to_vec()), - 8 => { - let mut v = Vec::new(); - DeflateDecoder::new(payload).read_to_end(&mut v).ok().map(|_| v) - } - _ => None, - }; - if let Some(data) = data { - out.push(Entry { name, data }); - } - } - } - p += 46 + name_len + extra_len + comment_len; - } - out -} - -fn norm(name: &str) -> String { - name.replace('\\', "/").trim_start_matches("./").to_string() -} -fn in_subagents(name: &str) -> bool { - norm(name).split('/').any(|seg| seg == "subagents") -} -fn depth(name: &str) -> usize { - norm(name).matches('/').count() -} -fn base_name(name: &str) -> String { - norm(name).split('/').filter(|s| !s.is_empty()).last().unwrap_or("").to_string() -} - -/// Split a bundle's entries into (main, subagents), mirroring zipStore.js splitBundle: the main -/// session is the shallowest top-level *.jsonl (never under a subagents/ segment); subagents are the -/// agent-* transcript / meta files under any subagents/ directory. Tolerant of a wrapping folder. -/// Returns (Some((name, data)), Vec<(name, data)>); main is None when no session file is present. -pub fn split_bundle(entries: Vec) -> (Option<(String, Vec)>, Vec<(String, Vec)>) { - let mut main: Option = None; - for (i, e) in entries.iter().enumerate() { - if !e.name.to_lowercase().ends_with(".jsonl") || in_subagents(&e.name) { - continue; - } - match main { - Some(m) if depth(&entries[m].name) <= depth(&e.name) => {} - _ => main = Some(i), - } - } - let mut subagents: Vec<(String, Vec)> = Vec::new(); - for e in &entries { - if !in_subagents(&e.name) { - continue; - } - let base = base_name(&e.name); - let lower = base.to_lowercase(); - if lower.starts_with("agent-") && (lower.ends_with(".jsonl") || lower.ends_with(".meta.json")) { - subagents.push((base, e.data.clone())); - } - } - let main_out = main.map(|i| (base_name(&entries[i].name), entries[i].data.clone())); - (main_out, subagents) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn crc32_check_value() { - // Standard CRC-32 check value for "123456789". - assert_eq!(crc32(b"123456789"), 0xcbf4_3926); - } - - #[test] - fn round_trips_store_and_deflate() { - let big = b"{\"type\":\"assistant\"}\n".repeat(4000); // very compressible → DEFLATE - let bin = vec![0u8, 1, 2, 3, 255, 254, 10, 13, 0, 42]; - let entries = vec![ - Entry { name: "main.jsonl".into(), data: b"hi\n".to_vec() }, - Entry { name: "subagents/agent-aaa.jsonl".into(), data: big.clone() }, - Entry { name: "subagents/agent-aaa.meta.json".into(), data: b"{\"toolUseId\":\"tu1\"}".to_vec() }, - Entry { name: "blob.bin".into(), data: bin.clone() }, - ]; - let zip = build(&entries); - assert_eq!(u32::from_le_bytes([zip[0], zip[1], zip[2], zip[3]]), 0x0403_4b50); - assert!(zip.len() < big.len(), "deflate should shrink: zip={} raw={}", zip.len(), big.len()); - - let read_back = read(&zip); - assert_eq!(read_back.len(), entries.len()); - for src in &entries { - let got = read_back.iter().find(|r| r.name == src.name).expect("entry present"); - assert_eq!(got.data, src.data, "payload mismatch for {}", src.name); - } - } - - #[test] - fn split_bundle_recovers_main_and_subagents() { - let entries = vec![ - Entry { name: "main.jsonl".into(), data: b"m".to_vec() }, - Entry { name: "subagents/agent-aaa.jsonl".into(), data: b"a".to_vec() }, - Entry { name: "subagents/agent-aaa.meta.json".into(), data: b"{}".to_vec() }, - Entry { name: "blob.bin".into(), data: b"x".to_vec() }, - ]; - let (main, subs) = split_bundle(entries); - assert_eq!(main.as_ref().map(|(n, _)| n.as_str()), Some("main.jsonl")); - assert_eq!(subs.len(), 2); - assert!(subs.iter().all(|(n, _)| !n.contains('/'))); - assert!(subs.iter().any(|(n, _)| n == "agent-aaa.meta.json")); - } - - #[test] - fn split_bundle_tolerates_wrapping_folder() { - let entries = vec![ - Entry { name: "bundle/sess.jsonl".into(), data: b"m".to_vec() }, - Entry { name: "bundle/subagents/agent-x.jsonl".into(), data: b"a".to_vec() }, - ]; - let (main, subs) = split_bundle(entries); - assert_eq!(main.as_ref().map(|(n, _)| n.as_str()), Some("sess.jsonl")); - assert_eq!(subs.len(), 1); - } - - #[test] - fn read_tolerates_garbage() { - assert_eq!(read(b"not a zip at all").len(), 0); - assert_eq!(read(&[]).len(), 0); - } -} diff --git a/src-tauri/src/ziputil/bundle.rs b/src-tauri/src/ziputil/bundle.rs new file mode 100644 index 0000000..effcd27 --- /dev/null +++ b/src-tauri/src/ziputil/bundle.rs @@ -0,0 +1,47 @@ +// Conversation-bundle shape: the FIRST level holds the main session .jsonl and `subagents/` +// holds the per-subagent files. split_bundle recovers that layout from a flat entry list. + +use super::write::Entry; + +fn norm(name: &str) -> String { + name.replace('\\', "/").trim_start_matches("./").to_string() +} +fn in_subagents(name: &str) -> bool { + norm(name).split('/').any(|seg| seg == "subagents") +} +fn depth(name: &str) -> usize { + norm(name).matches('/').count() +} +fn base_name(name: &str) -> String { + norm(name).split('/').filter(|s| !s.is_empty()).last().unwrap_or("").to_string() +} + +/// Split a bundle's entries into (main, subagents), mirroring zipStore.js splitBundle: the main +/// session is the shallowest top-level *.jsonl (never under a subagents/ segment); subagents are the +/// agent-* transcript / meta files under any subagents/ directory. Tolerant of a wrapping folder. +/// Returns (Some((name, data)), Vec<(name, data)>); main is None when no session file is present. +pub fn split_bundle(entries: Vec) -> (Option<(String, Vec)>, Vec<(String, Vec)>) { + let mut main: Option = None; + for (i, e) in entries.iter().enumerate() { + if !e.name.to_lowercase().ends_with(".jsonl") || in_subagents(&e.name) { + continue; + } + match main { + Some(m) if depth(&entries[m].name) <= depth(&e.name) => {} + _ => main = Some(i), + } + } + let mut subagents: Vec<(String, Vec)> = Vec::new(); + for e in &entries { + if !in_subagents(&e.name) { + continue; + } + let base = base_name(&e.name); + let lower = base.to_lowercase(); + if lower.starts_with("agent-") && (lower.ends_with(".jsonl") || lower.ends_with(".meta.json")) { + subagents.push((base, e.data.clone())); + } + } + let main_out = main.map(|i| (base_name(&entries[i].name), entries[i].data.clone())); + (main_out, subagents) +} diff --git a/src-tauri/src/ziputil/mod.rs b/src-tauri/src/ziputil/mod.rs new file mode 100644 index 0000000..153fe76 --- /dev/null +++ b/src-tauri/src/ziputil/mod.rs @@ -0,0 +1,23 @@ +// Minimal ZIP reader/writer for conversation bundles. Rust port of src/main/zipStore.js — the byte +// layout is proven there by test/zip.test.js (round-trip + system `unzip`), so this mirror stays in +// lockstep with it. +// +// A conversation with subagents exports as a .zip whose FIRST level is the main session .jsonl and +// whose `subagents/` directory holds the per-subagent files; re-importing restores that layout. +// Only the round-trip slice of the spec is implemented: +// - write: STORE or raw-DEFLATE per entry (whichever is smaller), no zip64, no data descriptors. +// - read : parse via the central directory (so OS-repacked zips with data descriptors still read), +// handling STORE (0) and DEFLATE (8); unreadable members are skipped, never panic. + +#![allow(dead_code)] + + +mod bundle; +mod read; +mod write; +#[cfg(test)] +mod tests; + +pub use bundle::split_bundle; +pub use read::read; +pub use write::{build, Entry}; diff --git a/src-tauri/src/ziputil/read.rs b/src-tauri/src/ziputil/read.rs new file mode 100644 index 0000000..84a27ca --- /dev/null +++ b/src-tauri/src/ziputil/read.rs @@ -0,0 +1,80 @@ +// Reading: parse via the central directory (so OS-repacked zips with data descriptors still +// read), handling STORE (0) and DEFLATE (8). Unreadable members are skipped, never panic. + +use super::write::Entry; +use flate2::read::DeflateDecoder; +use std::io::Read; + +fn rd_u16(buf: &[u8], at: usize) -> Option { + buf.get(at..at + 2).map(|s| u16::from_le_bytes([s[0], s[1]])) +} +fn rd_u32(buf: &[u8], at: usize) -> Option { + buf.get(at..at + 4).map(|s| u32::from_le_bytes([s[0], s[1], s[2], s[3]])) +} + +/// Parse a .zip → entries. Best-effort: unreadable/unsupported members are skipped. +pub fn read(buf: &[u8]) -> Vec { + let mut out: Vec = Vec::new(); + if buf.len() < 22 { + return out; + } + // Locate the End Of Central Directory record by scanning backwards for its signature. + let mut eocd: Option = None; + let top = buf.len() - 22; + let floor = top.saturating_sub(65535); + let mut i = top; + loop { + if rd_u32(buf, i) == Some(0x0605_4b50) { + eocd = Some(i); + break; + } + if i <= floor { + break; + } + i -= 1; + } + let eocd = match eocd { + Some(e) => e, + None => return out, + }; + let count = rd_u16(buf, eocd + 10).unwrap_or(0) as usize; + let mut p = rd_u32(buf, eocd + 16).unwrap_or(0) as usize; // central directory offset + for _ in 0..count { + if rd_u32(buf, p) != Some(0x0201_4b50) { + break; + } + let method = rd_u16(buf, p + 10).unwrap_or(0); + let comp_size = rd_u32(buf, p + 20).unwrap_or(0) as usize; + let name_len = rd_u16(buf, p + 28).unwrap_or(0) as usize; + let extra_len = rd_u16(buf, p + 30).unwrap_or(0) as usize; + let comment_len = rd_u16(buf, p + 32).unwrap_or(0) as usize; + let local_off = rd_u32(buf, p + 42).unwrap_or(0) as usize; + let name = buf + .get(p + 46..(p + 46 + name_len).min(buf.len())) + .map(|s| String::from_utf8_lossy(s).into_owned()) + .unwrap_or_default(); + // The local header repeats name/extra lengths; trust it for the data offset. + if rd_u32(buf, local_off) == Some(0x0403_4b50) { + let lh_name = rd_u16(buf, local_off + 26).unwrap_or(0) as usize; + let lh_extra = rd_u16(buf, local_off + 28).unwrap_or(0) as usize; + let data_start = local_off + 30 + lh_name + lh_extra; + let data_end = data_start + comp_size; + if data_end <= buf.len() { + let payload = &buf[data_start..data_end]; + let data = match method { + 0 => Some(payload.to_vec()), + 8 => { + let mut v = Vec::new(); + DeflateDecoder::new(payload).read_to_end(&mut v).ok().map(|_| v) + } + _ => None, + }; + if let Some(data) = data { + out.push(Entry { name, data }); + } + } + } + p += 46 + name_len + extra_len + comment_len; + } + out +} diff --git a/src-tauri/src/ziputil/tests.rs b/src-tauri/src/ziputil/tests.rs new file mode 100644 index 0000000..9015837 --- /dev/null +++ b/src-tauri/src/ziputil/tests.rs @@ -0,0 +1,63 @@ +use super::bundle::split_bundle; +use super::read::read; +use super::write::{build, crc32, Entry}; + +#[test] +fn crc32_check_value() { + // Standard CRC-32 check value for "123456789". + assert_eq!(crc32(b"123456789"), 0xcbf4_3926); +} + +#[test] +fn round_trips_store_and_deflate() { + let big = b"{\"type\":\"assistant\"}\n".repeat(4000); // very compressible → DEFLATE + let bin = vec![0u8, 1, 2, 3, 255, 254, 10, 13, 0, 42]; + let entries = vec![ + Entry { name: "main.jsonl".into(), data: b"hi\n".to_vec() }, + Entry { name: "subagents/agent-aaa.jsonl".into(), data: big.clone() }, + Entry { name: "subagents/agent-aaa.meta.json".into(), data: b"{\"toolUseId\":\"tu1\"}".to_vec() }, + Entry { name: "blob.bin".into(), data: bin.clone() }, + ]; + let zip = build(&entries); + assert_eq!(u32::from_le_bytes([zip[0], zip[1], zip[2], zip[3]]), 0x0403_4b50); + assert!(zip.len() < big.len(), "deflate should shrink: zip={} raw={}", zip.len(), big.len()); + + let read_back = read(&zip); + assert_eq!(read_back.len(), entries.len()); + for src in &entries { + let got = read_back.iter().find(|r| r.name == src.name).expect("entry present"); + assert_eq!(got.data, src.data, "payload mismatch for {}", src.name); + } +} + +#[test] +fn split_bundle_recovers_main_and_subagents() { + let entries = vec![ + Entry { name: "main.jsonl".into(), data: b"m".to_vec() }, + Entry { name: "subagents/agent-aaa.jsonl".into(), data: b"a".to_vec() }, + Entry { name: "subagents/agent-aaa.meta.json".into(), data: b"{}".to_vec() }, + Entry { name: "blob.bin".into(), data: b"x".to_vec() }, + ]; + let (main, subs) = split_bundle(entries); + assert_eq!(main.as_ref().map(|(n, _)| n.as_str()), Some("main.jsonl")); + assert_eq!(subs.len(), 2); + assert!(subs.iter().all(|(n, _)| !n.contains('/'))); + assert!(subs.iter().any(|(n, _)| n == "agent-aaa.meta.json")); +} + +#[test] +fn split_bundle_tolerates_wrapping_folder() { + let entries = vec![ + Entry { name: "bundle/sess.jsonl".into(), data: b"m".to_vec() }, + Entry { name: "bundle/subagents/agent-x.jsonl".into(), data: b"a".to_vec() }, + ]; + let (main, subs) = split_bundle(entries); + assert_eq!(main.as_ref().map(|(n, _)| n.as_str()), Some("sess.jsonl")); + assert_eq!(subs.len(), 1); +} + +#[test] +fn read_tolerates_garbage() { + assert_eq!(read(b"not a zip at all").len(), 0); + assert_eq!(read(&[]).len(), 0); +} diff --git a/src-tauri/src/ziputil/write.rs b/src-tauri/src/ziputil/write.rs new file mode 100644 index 0000000..2bc169e --- /dev/null +++ b/src-tauri/src/ziputil/write.rs @@ -0,0 +1,98 @@ +// Writing: CRC-32, raw DEFLATE, and the local-header + central-directory layout. +// STORE or DEFLATE per entry (whichever is smaller), no zip64, no data descriptors. + +use flate2::write::DeflateEncoder; +use flate2::Compression; +use std::io::Write; + +pub struct Entry { + pub name: String, + pub data: Vec, +} + +pub(super) fn crc32(data: &[u8]) -> u32 { + let mut crc: u32 = 0xffff_ffff; + for &b in data { + crc ^= b as u32; + for _ in 0..8 { + let mask = (crc & 1).wrapping_neg(); + crc = (crc >> 1) ^ (0xedb8_8320 & mask); + } + } + !crc +} + +fn deflate(data: &[u8]) -> Option> { + let mut enc = DeflateEncoder::new(Vec::new(), Compression::default()); + enc.write_all(data).ok()?; + enc.finish().ok() +} + +/// Build a .zip from entries. STORE unless raw-DEFLATE is strictly smaller. +pub fn build(entries: &[Entry]) -> Vec { + let mut local: Vec = Vec::new(); + let mut central: Vec = Vec::new(); + let mut offset: u32 = 0; + for e in entries { + let name = e.name.as_bytes(); + let crc = crc32(&e.data); + let deflated = deflate(&e.data); + let (method, payload): (u16, &[u8]) = match &deflated { + Some(d) if d.len() < e.data.len() => (8, d.as_slice()), + _ => (0, e.data.as_slice()), + }; + let comp_size = payload.len() as u32; + let uncomp_size = e.data.len() as u32; + + // local file header + local.extend_from_slice(&0x0403_4b50u32.to_le_bytes()); + local.extend_from_slice(&20u16.to_le_bytes()); // version needed + local.extend_from_slice(&0u16.to_le_bytes()); // flags + local.extend_from_slice(&method.to_le_bytes()); + local.extend_from_slice(&0u16.to_le_bytes()); // mod time + local.extend_from_slice(&0x21u16.to_le_bytes()); // mod date = 1980-01-01 + local.extend_from_slice(&crc.to_le_bytes()); + local.extend_from_slice(&comp_size.to_le_bytes()); + local.extend_from_slice(&uncomp_size.to_le_bytes()); + local.extend_from_slice(&(name.len() as u16).to_le_bytes()); + local.extend_from_slice(&0u16.to_le_bytes()); // extra length + local.extend_from_slice(name); + local.extend_from_slice(payload); + + // central directory header + central.extend_from_slice(&0x0201_4b50u32.to_le_bytes()); + central.extend_from_slice(&20u16.to_le_bytes()); // version made by + central.extend_from_slice(&20u16.to_le_bytes()); // version needed + central.extend_from_slice(&0u16.to_le_bytes()); // flags + central.extend_from_slice(&method.to_le_bytes()); + central.extend_from_slice(&0u16.to_le_bytes()); // mod time + central.extend_from_slice(&0x21u16.to_le_bytes()); // mod date + central.extend_from_slice(&crc.to_le_bytes()); + central.extend_from_slice(&comp_size.to_le_bytes()); + central.extend_from_slice(&uncomp_size.to_le_bytes()); + central.extend_from_slice(&(name.len() as u16).to_le_bytes()); + central.extend_from_slice(&0u16.to_le_bytes()); // extra length + central.extend_from_slice(&0u16.to_le_bytes()); // comment length + central.extend_from_slice(&0u16.to_le_bytes()); // disk number start + central.extend_from_slice(&0u16.to_le_bytes()); // internal attrs + central.extend_from_slice(&0u32.to_le_bytes()); // external attrs + central.extend_from_slice(&offset.to_le_bytes()); // relative offset of local header + central.extend_from_slice(name); + + offset += 30 + name.len() as u32 + comp_size; + } + let central_start = offset; + let central_size = central.len() as u32; + let mut out = local; + out.extend_from_slice(¢ral); + // end of central directory record + out.extend_from_slice(&0x0605_4b50u32.to_le_bytes()); + out.extend_from_slice(&0u16.to_le_bytes()); // this disk + out.extend_from_slice(&0u16.to_le_bytes()); // disk with central dir + out.extend_from_slice(&(entries.len() as u16).to_le_bytes()); // entries this disk + out.extend_from_slice(&(entries.len() as u16).to_le_bytes()); // total entries + out.extend_from_slice(¢ral_size.to_le_bytes()); + out.extend_from_slice(¢ral_start.to_le_bytes()); + out.extend_from_slice(&0u16.to_le_bytes()); // comment length + out +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 4865036..73ed647 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "../node_modules/@tauri-apps/cli/config.schema.json", "productName": "CCBuddy", - "version": "1.3.8", + "version": "1.3.9", "identifier": "dev.ccbud.gateway", "build": { "frontendDist": "../src/renderer", diff --git a/src/main/bootstrap.js b/src/main/bootstrap.js deleted file mode 100644 index b9cd82e..0000000 --- a/src/main/bootstrap.js +++ /dev/null @@ -1,102 +0,0 @@ -'use strict'; - -/* - * Hot-update bootstrap — the app's real Electron entry point (package.json "main"). - * - * Before the actual app code runs, this resolves WHICH copy of the app to load: - * - the version baked into the installed bundle ("shell"), or - * - a newer JS-only bundle the updater downloaded into /hot//. - * - * Because a hot bundle is just interpreted JS loaded by the already-installed native shell, - * applying one needs no code-signing / reinstall — only the JS/renderer layer changes. Native - * changes (Electron bump, bundled binaries) are gated by the manifest's minShellVersion and - * fall back to a full installer instead (see updater.js). - * - * Safety: promotion of a staged bundle and rollback of a bundle that fails to boot both happen - * here, and the require is wrapped so a broken bundle can never brick the app — it falls back to - * the packaged shell. main.js confirms a successful boot (clears `trying`) via updater.js. - */ - -const { app } = require('electron'); -const fs = require('fs'); -const path = require('path'); -const hp = require('./hotpaths'); - -const PACKAGED_ROOT = app.getAppPath(); // dir (or app.asar) that contains src/main/main.js - -function entryExists(root) { - try { return !!root && fs.existsSync(hp.mainEntry(root)); } catch (_) { return false; } -} - -// Resolve the bundle root to load, performing pending-promotion and crash-rollback on the way. -function resolveRoot(userData) { - let state; - try { state = hp.readState(userData); } catch (_) { return PACKAGED_ROOT; } - let dirty = false; - - // 1) A staged bundle is waiting → promote it to active for this launch. - if (state.pending && state.pending.dir) { - const stagedRoot = hp.bundleDir(userData, state.pending.dir); - if (entryExists(stagedRoot)) { - state.previous = state.active || null; - state.active = state.pending; - state.trying = state.active.version || null; // unconfirmed until main.js says it booted - } - state.pending = null; - dirty = true; - } else if (state.trying && state.active && state.trying === state.active.version) { - // 2) Last launch promoted this active bundle but never confirmed a clean boot - // (likely crashed during startup) → roll back to the previous known-good / packaged. - const bad = state.active; - state.active = state.previous || null; - state.previous = null; - state.trying = null; - dirty = true; - // best-effort: drop the bad bundle so it isn't retried - try { if (bad && bad.dir) fs.rmSync(hp.bundleDir(userData, bad.dir), { recursive: true, force: true }); } catch (_) {} - } - - if (dirty) { try { hp.writeState(userData, state); } catch (_) {} } - - if (state.active && state.active.dir) { - const activeRoot = hp.bundleDir(userData, state.active.dir); - if (entryExists(activeRoot)) return activeRoot; - // active points at a missing/broken dir → clear it and use packaged - try { state.active = null; state.trying = null; hp.writeState(userData, state); } catch (_) {} - } - return PACKAGED_ROOT; -} - -function loadMain(root) { - // Mark which root won so main.js / updater can report the running JS version accurately. - try { process.env.CCBUD_APP_ROOT = root; } catch (_) {} - require(hp.mainEntry(root)); -} - -(function bootstrap() { - let userData; - try { userData = app.getPath('userData'); } catch (_) { userData = null; } - - const root = userData ? resolveRoot(userData) : PACKAGED_ROOT; - try { - loadMain(root); - } catch (e) { - // A staged bundle threw on load — quarantine it and fall back to the packaged shell so the - // app still starts. (If the packaged shell itself throws, there's nothing left to do.) - if (root !== PACKAGED_ROOT && userData) { - try { - const state = hp.readState(userData); - const bad = state.active; - state.active = state.previous || null; - state.previous = null; - state.trying = null; - hp.writeState(userData, state); - if (bad && bad.dir) { try { fs.rmSync(hp.bundleDir(userData, bad.dir), { recursive: true, force: true }); } catch (_) {} } - } catch (_) {} - try { console.error('[ccbud] hot bundle failed to load, falling back to packaged:', e && e.message); } catch (_) {} - loadMain(PACKAGED_ROOT); - } else { - throw e; - } - } -})(); diff --git a/src/main/claude.js b/src/main/claude.js deleted file mode 100644 index be2ef0f..0000000 --- a/src/main/claude.js +++ /dev/null @@ -1,117 +0,0 @@ -'use strict'; - -/** - * One-click integration with Claude Code's user settings (~/.claude/settings.json). - * - * Connect: point Claude Code at the local gateway by writing env.ANTHROPIC_BASE_URL / - * env.ANTHROPIC_AUTH_TOKEN, and CLEAR any model-name overrides so Claude Code - * sends its native claude-* names — the gateway then auto-maps them to whichever - * provider is active. The user's original values are backed up first. - * Disconnect: restore the exact prior state from the backup. - * - * The settings path is overridable via CCBUD_CLAUDE_SETTINGS (used by tests so the real - * user config is never touched). - */ - -const fs = require('fs'); -const path = require('path'); -const os = require('os'); - -// Model-selection keys we clear while connected (so the gateway controls routing). -const MODEL_ENV_KEYS = [ - 'ANTHROPIC_MODEL', - 'ANTHROPIC_SMALL_FAST_MODEL', - 'ANTHROPIC_DEFAULT_HAIKU_MODEL', - 'ANTHROPIC_DEFAULT_SONNET_MODEL', - 'ANTHROPIC_DEFAULT_OPUS_MODEL', -]; -const ALL_BACKUP_KEYS = ['ANTHROPIC_BASE_URL', 'ANTHROPIC_AUTH_TOKEN', ...MODEL_ENV_KEYS]; - -function settingsPath() { - return process.env.CCBUD_CLAUDE_SETTINGS || path.join(os.homedir(), '.claude', 'settings.json'); -} - -function readSettings() { - try { - const raw = fs.readFileSync(settingsPath(), 'utf8'); - const obj = JSON.parse(raw); - return obj && typeof obj === 'object' ? obj : {}; - } catch (_) { - return {}; - } -} - -function writeSettings(obj) { - const p = settingsPath(); - fs.mkdirSync(path.dirname(p), { recursive: true }); - const tmp = p + '.ccbud.tmp'; - fs.writeFileSync(tmp, JSON.stringify(obj, null, 2)); - fs.renameSync(tmp, p); -} - -function endpoint(port) { - return `http://localhost:${port}`; -} - -function isGatewayUrl(url, port) { - if (!url) return false; - try { - const u = new URL(url); - const p = u.port || (u.protocol === 'https:' ? '443' : '80'); - return (u.hostname === 'localhost' || u.hostname === '127.0.0.1') && String(p) === String(port); - } catch (_) { - return false; - } -} - -function isConnected(port) { - const s = readSettings(); - return isGatewayUrl(s.env && s.env.ANTHROPIC_BASE_URL, port); -} - -/** Connect Claude Code to the gateway. `store` is the app config store (get/save). */ -function connect(port, token, store) { - const s = readSettings(); - s.env = s.env || {}; - - // Back up the original values exactly once (preserve across reconnects). - const cfg = store.get(); - if (!cfg.claudeBackup) { - const backup = { model: 'model' in s ? s.model : undefined, env: {} }; - for (const k of ALL_BACKUP_KEYS) backup.env[k] = k in s.env ? s.env[k] : undefined; - store.save(Object.assign({}, cfg, { claudeBackup: backup })); - } - - s.env.ANTHROPIC_BASE_URL = endpoint(port); - s.env.ANTHROPIC_AUTH_TOKEN = token; - for (const k of MODEL_ENV_KEYS) delete s.env[k]; - delete s.model; // let Claude Code send native claude-* names; the gateway maps them - writeSettings(s); -} - -/** Disconnect Claude Code: restore the backed-up state (or just remove our keys). */ -function disconnect(store) { - const s = readSettings(); - s.env = s.env || {}; - const cfg = store.get(); - const b = cfg.claudeBackup; - - if (b) { - const restore = (k) => { - if (b.env[k] === undefined) delete s.env[k]; - else s.env[k] = b.env[k]; - }; - for (const k of ALL_BACKUP_KEYS) restore(k); - if (b.model === undefined) delete s.model; - else s.model = b.model; - store.save(Object.assign({}, cfg, { claudeBackup: null })); - } else { - delete s.env.ANTHROPIC_BASE_URL; - delete s.env.ANTHROPIC_AUTH_TOKEN; - } - - if (s.env && Object.keys(s.env).length === 0) delete s.env; - writeSettings(s); -} - -module.exports = { settingsPath, readSettings, isConnected, connect, disconnect, endpoint, MODEL_ENV_KEYS }; diff --git a/src/main/claudeDesktop.js b/src/main/claudeDesktop.js deleted file mode 100644 index 4705f88..0000000 --- a/src/main/claudeDesktop.js +++ /dev/null @@ -1,179 +0,0 @@ -'use strict'; - -/** - * One-click integration with the Claude Desktop app's "Third-Party Inference". - * - * Unlike Claude Code (a plain ~/.claude/settings.json we write directly), Claude Desktop - * (bundle `com.anthropic.claudefordesktop`) reads its third-party-inference settings from macOS - * *Managed Preferences*, delivered as a Configuration Profile (.mobileconfig). So: - * - * connect(): generate a profile pre-filled with the local ccbud gateway, then hand it to macOS. - * `profiles` CLI "no longer supports installs", so the user approves it once in - * System Settings › Profiles (admin password). This is NOT a risky operation — it - * only changes where inference is sent; macOS just requires you to confirm it. - * disconnect(): `profiles remove -identifier …` still works, run via an admin prompt → ~one-click - * restore. Falls back to opening System Settings if that path is unavailable. - * - * Schema (extracted from Claude Desktop's own profile generator): - * PayloadType (inner) = com.anthropic.claudefordesktop, with managed keys: - * inferenceProvider="gateway", inferenceCredentialKind="static", - * inferenceGatewayBaseUrl, inferenceGatewayApiKey, inferenceGatewayAuthScheme="bearer". - */ - -const fs = require('fs'); -const path = require('path'); -const os = require('os'); -const crypto = require('crypto'); -const { exec, execFile, execFileSync } = require('child_process'); -const { CLAUDE_TIER_MODELS } = require('./claudeModels'); - -const BUNDLE_ID = 'com.anthropic.claudefordesktop'; -const PROFILE_IDENTIFIER = 'dev.ccbud.gateway.claude-desktop-inference'; -const PROFILES_PANE = 'x-apple.systempreferences:com.apple.preferences.configurationprofiles'; - -const isMac = () => process.platform === 'darwin'; -const endpoint = (port) => `http://localhost:${port || 8788}`; -const profilePath = () => - path.join(os.homedir(), '.ccbud', 'claude-desktop-inference.mobileconfig'); - -function appInstalled() { - if (!isMac()) return false; - const candidates = [ - '/Applications/Claude.app', - path.join(os.homedir(), 'Applications', 'Claude.app'), - path.join(os.homedir(), 'Library', 'Application Support', 'Claude'), - ]; - return candidates.some((p) => { try { return fs.existsSync(p); } catch (_) { return false; } }); -} - -// Stable UUIDs so re-generating the profile updates it in place rather than duplicating. -function uuidFrom(seed) { - const h = crypto.createHash('sha1').update(String(seed)).digest('hex'); - return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20, 32)}`.toUpperCase(); -} -const xmlEsc = (s) => String(s).replace(/&/g, '&').replace(//g, '>'); - -function buildProfile(port, token) { - // Claude Desktop's Gateway picker needs an explicit model list, stored as a SINGLE JSON string. - // Names carry Anthropic keywords so its client-side validation accepts them, and they match what - // ccbud returns from /v1/models; the gateway then tier-maps each onto the active provider. - const inferenceModels = JSON.stringify(CLAUDE_TIER_MODELS.map((m) => Object.assign( - { name: m.name, anthropicFamilyTier: m.tier }, m.familyDefault ? { isFamilyDefault: true } : {}, - ))); - const settings = { - inferenceProvider: 'gateway', - inferenceCredentialKind: 'static', - inferenceGatewayBaseUrl: endpoint(port), - inferenceGatewayApiKey: token || 'ccbud-local', - inferenceGatewayAuthScheme: 'bearer', - inferenceModels, - }; - const body = Object.entries(settings) - .map(([k, v]) => ` ${k}\n ${xmlEsc(v)}`).join('\n'); - return ` - - - - PayloadContent - - - PayloadType - ${BUNDLE_ID} - PayloadIdentifier - ${PROFILE_IDENTIFIER}.settings - PayloadUUID - ${uuidFrom(PROFILE_IDENTIFIER + '.settings')} - PayloadVersion - 1 - PayloadDisplayName - Claude Desktop Third-Party Inference (CC Buddy) -${body} - - - PayloadDisplayName - CC Buddy · Claude Desktop 第三方推理 - PayloadDescription - 将 Claude 桌面版的模型推理指向本地 CC Buddy 网关(${endpoint(port)})。可随时移除以还原为官方推理。 - PayloadIdentifier - ${PROFILE_IDENTIFIER} - PayloadOrganization - CC Buddy - PayloadRemovalDisallowed - - PayloadScope - User - PayloadType - Configuration - PayloadUUID - ${uuidFrom(PROFILE_IDENTIFIER)} - PayloadVersion - 1 - - -`; -} - -// Read the effective managed baseUrl Claude Desktop would use (mirrors how it reads managed prefs). -function managedBaseUrl() { - let user = ''; - try { user = os.userInfo().username; } catch (_) {} - const paths = [`/Library/Managed Preferences/${BUNDLE_ID}.plist`]; - if (user) paths.push(`/Library/Managed Preferences/${user}/${BUNDLE_ID}.plist`); - for (const p of paths) { - try { - if (!fs.existsSync(p)) continue; - const out = execFileSync('/usr/bin/plutil', ['-extract', 'inferenceGatewayBaseUrl', 'raw', '-o', '-', p], { - encoding: 'utf8', timeout: 4000, - }).trim(); - if (out) return out; - } catch (_) {} - } - return null; -} - -function status(port) { - return { - supported: isMac(), - installed: appInstalled(), - connected: isMac() && managedBaseUrl() === endpoint(port), - endpoint: endpoint(port), - }; -} - -// Write the profile and open it + the Profiles pane for the user to approve (one-time macOS step). -function connect(port, token) { - if (!isMac()) return { ok: false, reason: 'unsupported' }; - if (!appInstalled()) return { ok: false, reason: 'notInstalled' }; - const file = profilePath(); - try { - fs.mkdirSync(path.dirname(file), { recursive: true }); - fs.writeFileSync(file, buildProfile(port, token), 'utf8'); - } catch (e) { - return { ok: false, reason: 'write', message: e.message }; - } - exec(`/usr/bin/open ${JSON.stringify(file)}`, () => { - setTimeout(() => exec(`/usr/bin/open ${JSON.stringify(PROFILES_PANE)}`, () => {}), 1200); - }); - return { ok: true, needsApproval: true, path: file }; -} - -// Remove the profile via an admin prompt (~one-click); fall back to System Settings on failure. -function disconnect() { - if (!isMac()) return Promise.resolve({ ok: false, reason: 'unsupported' }); - return new Promise((resolve) => { - const osa = `do shell script "/usr/bin/profiles remove -identifier ${PROFILE_IDENTIFIER}" with administrator privileges`; - execFile('/usr/bin/osascript', ['-e', osa], (err, _stdout, stderr) => { - if (!err) { resolve({ ok: true, removed: true }); return; } - const msg = String((stderr || '') + (err && err.message ? err.message : '')); - if (/-128|User canceled/i.test(msg)) { resolve({ ok: false, cancelled: true }); return; } - // CLI removal unavailable → open System Settings so the user can remove it manually. - exec(`/usr/bin/open ${JSON.stringify(PROFILES_PANE)}`, () => {}); - resolve({ ok: true, removed: false, needsApproval: true }); - }); - }); -} - -module.exports = { - appInstalled, status, connect, disconnect, buildProfile, profilePath, - BUNDLE_ID, PROFILE_IDENTIFIER, -}; diff --git a/src/main/claudeModels.js b/src/main/claudeModels.js deleted file mode 100644 index e7719f9..0000000 --- a/src/main/claudeModels.js +++ /dev/null @@ -1,24 +0,0 @@ -'use strict'; - -/** - * Standard Claude tier model names ccbud advertises to clients. - * - * The gateway accepts any claude-* name and tier-maps it onto the active provider (see resolveRouting): - * opus/sonnet → the provider's main model, haiku → its small/fast model. Claude Desktop's "Gateway" - * mode needs an explicit model list (`inferenceModels`) whose names (a) pass its client-side validation - * that rejects names without an Anthropic keyword, and (b) match what the gateway returns from - * /v1/models. Exposing these three names in BOTH places lets a freshly-installed Claude Desktop pick a - * model and drive the gateway with zero per-user setup — the actual upstream is the user's provider. - * - * Version numbers are cosmetic here: ccbud never forwards these names to Anthropic; it routes by tier. - * Keep this list in sync with gateway.rs CLAUDE_TIER_MODELS. - */ -const CLAUDE_TIER_MODELS = [ - { name: 'claude-fable-5', tier: 'opus' }, - { name: 'claude-opus-4-8', tier: 'opus' }, - { name: 'claude-sonnet-5', tier: 'sonnet', familyDefault: true }, - { name: 'claude-haiku-4-5', tier: 'haiku' }, - { name: 'claude-haiku-4-5-20251001', tier: 'haiku' }, -]; - -module.exports = { CLAUDE_TIER_MODELS }; diff --git a/src/main/codex.js b/src/main/codex.js deleted file mode 100644 index a6c04a3..0000000 --- a/src/main/codex.js +++ /dev/null @@ -1,723 +0,0 @@ -'use strict'; - -/** - * Codex CLI session support — reads OpenAI Codex's on-disk rollout logs - * (`~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl`) and normalizes them into the SAME - * session/message shape the renderer consumes for Claude Code history, so the 对话 view - * (list / detail / search / live-follow / export) browses both without renderer forks. - * JS twin of src-tauri/src/codex.rs — keep the two in lockstep. - * - * A rollout line is `{timestamp, type, payload}` with type ∈ {session_meta, turn_context, - * response_item, event_msg, compacted}. Conversation content lives in response_item payloads; - * event_msg mostly duplicates it; token_count carries usage, and user_message is a bounded title - * fallback when an image-heavy response_item is too large for the list view's head read. - * Very old Codex builds wrote payload objects directly per line (no envelope) — handled by - * treating such a line as its own payload. - * - * Tool calls map onto the tool vocabulary the renderer already draws natively: - * shell/exec_command/local_shell_call → Bash, update_plan → TodoWrite, view_image → Read, - * web_search → WebSearch, apply_patch → ApplyPatch (a codex-specific card). - * - * Title/tags/soft-delete: Codex files belong to another tool, so per-conversation - * customization never rewrites them — it lives in a sidecar map at ~/.ccbud/codex-meta.json - * (shared with the Tauri build), keyed by the rollout file stem. - */ - -const fs = require('fs'); -const path = require('path'); -const os = require('os'); - -/** Codex's DEFAULT sessions tree (CODEX_HOME-aware, like the codex CLI). Only the auto-add - * migration keys off this — browsing walks `/sessions` of every configured work dir. */ -function sessionsRoot() { - if (process.env.CCBUD_CODEX_DIR) return process.env.CCBUD_CODEX_DIR; // test override - const ch = (process.env.CODEX_HOME || '').trim(); - return ch ? path.join(ch, 'sessions') : path.join(os.homedir(), '.codex', 'sessions'); -} -// The DEFAULT config dir as a history-dir entry string (`~/.codex`), used by the one-time -// startup migration that adds it to historyDirs. -function codexLabel() { - const dir = path.dirname(sessionsRoot()); - const home = os.homedir(); - if (dir === home) return '~'; - return dir.startsWith(home + path.sep) ? '~' + dir.slice(home.length) : dir; -} -function rootExists() { - try { return fs.statSync(sessionsRoot()).isDirectory(); } catch (_) { return false; } -} - -/** Walk every rollout .jsonl under a sessions tree (date-sharded, walked generically). */ -function walkSessions(root, cb) { - const walk = (dir, depth) => { - if (depth > 6) return; - let entries; - try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch (_) { return; } - for (const e of entries) { - const p = path.join(dir, e.name); - if (e.isDirectory()) walk(p, depth + 1); - else if (e.isFile() && e.name.endsWith('.jsonl')) cb(p); - } - }; - walk(root, 0); -} - -/** - * Format sniff on parsed records — routes files that LOOK like Codex rollouts (incl. copies - * imported into the app store). Claude Code records never use these type tags, and old-format - * bare Codex items lack Claude's `.message` wrapper. - */ -function looksCodex(recs) { - return (recs || []).slice(0, 8).some((r) => { - if (!r || typeof r !== 'object') return false; - switch (r.type) { - case 'session_meta': case 'turn_context': case 'event_msg': case 'compacted': return true; - case 'response_item': return r.payload !== undefined; - case 'message': case 'function_call': case 'function_call_output': - case 'reasoning': case 'local_shell_call': return r.message === undefined; // old envelope-less rollout - default: return r.record_type !== undefined; - } - }); -} - -/** (type, payload, timestamp) of a rollout line, tolerating the old envelope-less format. */ -function splitLine(rec) { - const ts = rec.timestamp || null; - const t = rec.type || ''; - if (rec.payload !== undefined) return { t, p: rec.payload || {}, ts }; - if (['message', 'function_call', 'function_call_output', 'reasoning', 'local_shell_call', - 'custom_tool_call', 'custom_tool_call_output', 'web_search_call'].includes(t)) { - return { t: 'response_item', p: rec, ts }; - } - if (!t && rec.id !== undefined && rec.timestamp !== undefined) return { t: 'session_meta', p: rec, ts }; - return { t, p: rec, ts }; -} - -// The FIRST SessionMeta belongs to this physical rollout. A forked/subagent rollout may copy -// ancestor SessionMeta records after it, so later ones must never replace the canonical thread -// identity. `session_id` is shared by the whole multi-agent tree; `id` is the unique thread key. -function canonicalThreadMeta(payload) { - payload = payload && typeof payload === 'object' ? payload : {}; - const source = payload.source && typeof payload.source === 'object' ? payload.source : {}; - const threadSource = payload.thread_source && typeof payload.thread_source === 'object' ? payload.thread_source : {}; - const subagent = source.subagent || source.sub_agent || threadSource.subagent || threadSource.sub_agent || null; - let detail = null; - if (subagent && typeof subagent === 'object') { - detail = subagent.thread_spawn || subagent.review || subagent.compact || subagent.other || null; - if (!detail || typeof detail !== 'object') { - detail = Object.values(subagent).find((value) => value && typeof value === 'object' && !Array.isArray(value)) || null; - } - } - detail = detail && typeof detail === 'object' ? detail : {}; - // `id` is mandatory in a valid Codex SessionMeta. Never promote the tree-shared session_id to - // canonical status; malformed/legacy records can still fall back to the filename for display. - const threadId = payload.id || payload.thread_id || null; - const rootSessionId = payload.session_id || threadId; - const parentThreadId = payload.parent_thread_id || detail.parent_thread_id || null; - const threadSourceKind = typeof payload.thread_source === 'string' ? payload.thread_source.toLowerCase() : ''; - return { - threadId, - rootSessionId, - parentThreadId, - forkedFromId: payload.forked_from_id || null, - isSubagent: !!subagent || threadSourceKind === 'subagent' - || !!payload.agent_path || !!payload.agent_nickname - || (!!parentThreadId && threadId !== rootSessionId), - // Current Codex writes these canonical fields at SessionMeta top level. Older multi-agent - // rollouts kept them only inside source.subagent., so retain that as a fallback. - agentPath: payload.agent_path || detail.agent_path || null, - agentNickname: payload.agent_nickname || detail.agent_nickname || null, - agentRole: payload.agent_role || payload.agent_type || detail.agent_role || detail.agent_type || null, - agentDepth: Number.isFinite(detail.depth) ? detail.depth : null, - }; -} - -function subagentTitle(n) { - if (!n || !n.isSubagent) return ''; - const pathLabel = String(n.agentPath || '').replace(/^\/?root\/?/, ''); - return [n.agentNickname, pathLabel].filter(Boolean).join(' · ') || 'Codex subagent'; -} - -function isCanonicalThreadId(value) { - return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(String(value || '')); -} - -// Harness-injected user turns (environment/permissions/instructions wrappers) that aren't -// human prose — hidden from the timeline, exactly like Claude's isMeta records. -function isMetaUserText(t) { - t = String(t || '').replace(/^\s+/, ''); - return ['', '', ' t.startsWith(p)); -} - -// Codex injects the workspace AGENTS instructions as a user-role transport message. Keep it in -// the transcript (the renderer turns it into readable Markdown), but mark it as metadata so it -// never becomes the conversation title or a user navigation point. -function isAgentsBootstrap(t) { - const source = String(t || '').trimStart(); - return /^#\s+AGENTS\.md instructions for [^\r\n]+/i.test(source) - && /]*>[\s\S]*?<\/INSTRUCTIONS>/i.test(source); -} - -// Codex records a loaded Skill as a synthetic user turn. Preserve the embedded snapshot instead -// of re-reading SKILL.md: it is the exact version that influenced this historical turn. Only an -// entire, well-formed envelope is recognized so quoted markup remains ordinary prose. -function skillLoadBlock(t) { - const source = String(t || ''); - const match = /^\s*\s*([\s\S]*?)<\/name>\s*([\s\S]*?)<\/path>([\s\S]*)<\/skill>\s*$/i.exec(source); - if (!match) return null; - const name = match[1].trim(); - const path = match[2].trim(); - if (!name || !path) return null; - return { type: 'skill_load', name, path, snapshot: match[3] }; -} - -function joinedText(content, kinds) { - if (typeof content === 'string') return content; - if (!Array.isArray(content)) return ''; - return content - .filter((b) => b && kinds.includes(b.type)) - .map((b) => b.text || '') - .join('\n'); -} - -// A Codex image prompt is serialized as three transport blocks around the actual prose: -// , input_image, -// Surface only the safe/readable name while preserving the real input_image block separately. -function imageTransportLabel(text) { - const source = String(text || '').trim(); - if (!/^]*>$/i.test(source)) return null; - const match = /\bname\s*=\s*(?:["']([^"']+)["']|(\[[^\]]+\])|([^\s>]+))/i.exec(source); - return match ? (match[1] || match[2] || match[3] || '').trim() : '[Image]'; -} - -function joinedUserText(content) { - if (!Array.isArray(content)) return typeof content === 'string' ? content : ''; - const hasImage = content.some((b) => b && b.type === 'input_image'); - return content - .filter((b) => b && (b.type === 'input_text' || b.type === 'text')) - .map((b) => { - const text = b.text || ''; - if (!hasImage) return text; - if (/^\s*<\/image>\s*$/i.test(text)) return ''; - const label = imageTransportLabel(text); - return label == null ? text : label; - }) - .filter(Boolean) - .join('\n'); -} - -function eventUserDisplayText(payload) { - const message = String((payload && payload.message) || '').trim(); - const imageCount = (Array.isArray(payload && payload.images) ? payload.images.length : 0) - + (Array.isArray(payload && payload.local_images) ? payload.local_images.length : 0); - const labels = []; - for (let i = 0; i < imageCount; i++) labels.push('[Image #' + (i + 1) + ']'); - return (labels.join(' ') + (labels.length && message ? ' ' : '') + message).trim(); -} - -function eventUserTitleFromRecord(rec) { - if (!rec || typeof rec !== 'object') return ''; - const { t, p } = splitLine(rec); - if (t !== 'event_msg' || !p || p.type !== 'user_message') return ''; - const text = eventUserDisplayText(p); - return text ? firstUserText([{ role: 'user', content: [{ type: 'text', text }] }]) : ''; -} - -function firstEventUserTitle(recs) { - for (const rec of recs || []) { - const title = eventUserTitleFromRecord(rec); - if (title) return title; - } - return ''; -} - -// List rows normally shape only the first 128 KiB. An image-first response_item can be one much -// larger JSON line, so the bounded read drops it and everything after it. If no title was found, -// scan forward without retaining oversized lines and use Codex's following user_message event. -function scanEventUserTitle(file) { - const CHUNK = 64 * 1024; - const MAX_SCAN = 64 * 1024 * 1024; - const MAX_LINE = 256 * 1024; - let fd = null; - try { - fd = fs.openSync(file, 'r'); - const buf = Buffer.allocUnsafe(CHUNK); - let scanned = 0; - let parts = []; - let lineBytes = 0; - let dropping = false; - - const append = (piece) => { - if (dropping || !piece.length) return; - if (lineBytes + piece.length > MAX_LINE) { - dropping = true; - parts = []; - lineBytes = 0; - return; - } - parts.push(Buffer.from(piece)); - lineBytes += piece.length; - }; - const finishLine = () => { - let title = ''; - if (!dropping && lineBytes) { - try { title = eventUserTitleFromRecord(JSON.parse(Buffer.concat(parts, lineBytes).toString('utf8').trim())); } catch (_) {} - } - parts = []; - lineBytes = 0; - dropping = false; - return title; - }; - - while (scanned < MAX_SCAN) { - const n = fs.readSync(fd, buf, 0, Math.min(CHUNK, MAX_SCAN - scanned), null); - if (!n) break; - scanned += n; - let start = 0; - for (let i = 0; i < n; i++) { - if (buf[i] !== 10) continue; - append(buf.subarray(start, i)); - const title = finishLine(); - if (title) return title; - start = i + 1; - } - append(buf.subarray(start, n)); - } - return finishLine(); - } catch (_) { - return ''; - } finally { - if (fd != null) { try { fs.closeSync(fd); } catch (_) {} } - } -} - -/** argv → display command: unwrap the ["bash","-lc", script] convention, else shell-ish join. */ -function joinArgv(cmd) { - if (typeof cmd === 'string') return cmd; - if (!Array.isArray(cmd)) return ''; - const parts = cmd.map((x) => (typeof x === 'string' ? x : String(x == null ? '' : x))); - if (parts.length === 3 && ['bash', 'sh', 'zsh', 'dash'].includes(parts[0]) && ['-lc', '-c'].includes(parts[1])) { - return parts[2]; - } - return parts.map((p) => (!p || /[\s"']/.test(p) ? JSON.stringify(p) : p)).join(' '); -} - -/** Codex tool name + parsed arguments → [renderer tool name, renderer input]. */ -function mapTool(name, args) { - args = args && typeof args === 'object' ? args : {}; - const s = (k) => (typeof args[k] === 'string' ? args[k] : ''); - switch (name) { - case 'shell': case 'local_shell': case 'container.exec': { - const input = { command: joinArgv(args.command) }; - const desc = s('justification') || s('workdir'); - if (desc) input.description = desc; - return ['Bash', input]; - } - case 'shell_command': return ['Bash', { command: s('command') }]; - case 'exec_command': return ['Bash', { command: s('cmd') || s('command') }]; - case 'apply_patch': return ['ApplyPatch', { patch: s('input') || s('patch') }]; - case 'update_plan': - return ['TodoWrite', { - todos: (Array.isArray(args.plan) ? args.plan : []).map((st) => ({ - content: (st && st.step) || '', - status: (st && st.status) || 'pending', - })), - }]; - case 'view_image': return ['Read', { file_path: s('path') }]; - case 'web_search': return ['WebSearch', { query: s('query') }]; - default: return [name, args]; - } -} - -/** - * Tool output payload → { text, err }. Unwraps codex's JSON-wrapped shell output - * ({"output","metadata":{exit_code}}) and reads exec_command's "exited with code N" header. - */ -function shapeOutput(out) { - if (out && typeof out === 'object') { - const text = typeof out.content === 'string' ? out.content : JSON.stringify(out, null, 2); - return { text, err: out.success === false }; - } - const s = typeof out === 'string' ? out : ''; - try { - const v = JSON.parse(s); - if (v && typeof v === 'object') { - if (typeof v.output === 'string') { - const code = (v.metadata && typeof v.metadata.exit_code === 'number') ? v.metadata.exit_code : 0; - return { text: v.output, err: code !== 0 }; - } - if (typeof v.content === 'string') return { text: v.content, err: v.success === false }; - } - } catch (_) {} - const m = s.slice(0, 240).match(/exited with code (\d+)/); - if (m) return { text: s, err: m[1] !== '0' }; - return { text: s, err: false }; -} - -/** data-URL input_image → Claude-style image source block, else null. */ -function imageBlock(url) { - const m = /^data:([^;]+);base64,(.*)$/s.exec(String(url || '')); - if (!m) return null; - return { type: 'image', source: { type: 'base64', media_type: m[1], data: m[2] } }; -} - -/** Normalize parsed rollout records into the renderer's message model. */ -function normalize(recs) { - const messages = []; - const totals = { in: 0, out: 0, cacheRead: 0, cacheCreation: 0, turns: 0 }; - let model = null, cwd = null, sessionId = null, threadId = null, parentThreadId = null; - let forkedFromId = null, isSubagent = false, agentPath = null, agentNickname = null; - let agentRole = null, agentDepth = null, gitBranch = null, version = null, sawSessionMeta = false; - - for (const rec of recs || []) { - if (!rec || typeof rec !== 'object') continue; - const { t, p, ts } = splitLine(rec); - const withTs = (m) => { if (ts) m.ts = ts; return m; }; - if (t === 'session_meta') { - if (!sawSessionMeta) { - sawSessionMeta = true; - const identity = canonicalThreadMeta(p); - threadId = identity.threadId; - sessionId = identity.rootSessionId; - parentThreadId = identity.parentThreadId; - forkedFromId = identity.forkedFromId; - isSubagent = identity.isSubagent; - agentPath = identity.agentPath; - agentNickname = identity.agentNickname; - agentRole = identity.agentRole; - agentDepth = identity.agentDepth; - } - if (!sessionId) sessionId = p.session_id || p.id || null; - if (!cwd) cwd = p.cwd || null; - if (!version) version = p.cli_version || null; - if (!gitBranch) gitBranch = (p.git && p.git.branch) || null; - } else if (t === 'turn_context') { - if (p.model) model = p.model; - if (!cwd) cwd = p.cwd || null; - } else if (t === 'compacted') { - const text = String(p.message || '').trim(); - if (text) messages.push(withTs({ role: 'user', content: [{ type: 'text', text }] })); - } else if (t === 'event_msg') { - if (p.type === 'token_count') { - const u = p.info && p.info.last_token_usage; - if (u) { - const input = u.input_tokens || 0, cached = u.cached_input_tokens || 0, output = u.output_tokens || 0; - if (input + cached + output > 0) { - const usage = { - inputTokens: Math.max(input - cached, 0), - outputTokens: output, - cacheRead: cached, - cacheCreation: 0, - }; - totals.in += usage.inputTokens; totals.out += output; totals.cacheRead += cached; totals.turns += 1; - // Per-turn usage rides the turn's last assistant message (one token_count per turn). - for (let i = messages.length - 1; i >= 0; i--) { - if (messages[i].role === 'assistant' && !messages[i].usage) { messages[i].usage = usage; break; } - } - } - } - } else if (p.type === 'turn_aborted') { - messages.push(withTs({ role: 'user', content: [{ type: 'text', text: '[Request interrupted by user]' }] })); - } - } else if (t === 'response_item') { - const it = p.type; - if (it === 'message') { - const content = p.content; - if (p.role === 'assistant') { - const text = joinedText(content, ['output_text', 'text']); - if (text.trim()) { - const m = { role: 'assistant', content: [{ type: 'text', text }] }; - if (model) m.modelActual = model; - messages.push(withTs(m)); - } - } else if (p.role === 'user') { - const text = joinedUserText(content); - const skill = skillLoadBlock(text); - if (skill) { - messages.push(withTs({ role: 'user', _meta: true, content: [skill] })); - continue; - } - if (isMetaUserText(text)) continue; - const blocks = []; - if (text.trim()) blocks.push({ type: 'text', text }); - if (Array.isArray(content)) { - for (const b of content) { - if (b && b.type === 'input_image') { const img = imageBlock(b.image_url); if (img) blocks.push(img); } - } - } - if (blocks.length) { - const message = { role: 'user', content: blocks }; - if (isAgentsBootstrap(text)) message._meta = true; - messages.push(withTs(message)); - } - } // system / developer turns: harness plumbing, not conversation - } else if (it === 'reasoning') { - let txt = joinedText(p.summary, ['summary_text', 'text']); - const extra = joinedText(p.content, ['reasoning_text', 'text']); - if (extra.trim()) txt = txt.trim() ? txt + '\n\n' + extra : extra; - if (txt.trim()) { - const m = { role: 'assistant', content: [{ type: 'thinking', thinking: txt }] }; - if (model) m.modelActual = model; - messages.push(withTs(m)); - } - } else if (it === 'function_call') { - let args = {}; - if (typeof p.arguments === 'string') { try { args = JSON.parse(p.arguments); } catch (_) {} } - else if (p.arguments && typeof p.arguments === 'object') args = p.arguments; - const [tname, input] = mapTool(p.name || 'tool', args); - const m = { role: 'assistant', content: [{ type: 'tool_use', id: p.call_id || p.id || '', name: tname, input }] }; - if (model) m.modelActual = model; - messages.push(withTs(m)); - } else if (it === 'local_shell_call') { - const cmd = (p.action && p.action.command) || null; - const m = { role: 'assistant', content: [{ type: 'tool_use', id: p.call_id || p.id || '', name: 'Bash', input: { command: joinArgv(cmd) } }] }; - if (model) m.modelActual = model; - messages.push(withTs(m)); - } else if (it === 'custom_tool_call') { - const inputS = typeof p.input === 'string' ? p.input : ''; - const [tname, input] = p.name === 'apply_patch' - ? ['ApplyPatch', { patch: inputS }] - : [p.name || 'tool', { input: inputS }]; - const m = { role: 'assistant', content: [{ type: 'tool_use', id: p.call_id || p.id || '', name: tname, input }] }; - if (model) m.modelActual = model; - messages.push(withTs(m)); - } else if (it === 'function_call_output' || it === 'custom_tool_call_output') { - const { text, err } = shapeOutput(p.output); - const tr = { type: 'tool_result', tool_use_id: p.call_id || '', content: text }; - if (err) tr.is_error = true; - messages.push(withTs({ role: 'user', content: [tr] })); - } else if (it === 'web_search_call') { - const q = (p.action && p.action.query) || ''; - const m = { role: 'assistant', content: [{ type: 'tool_use', id: p.id || p.call_id || '', name: 'WebSearch', input: { query: q } }] }; - if (model) m.modelActual = model; - messages.push(withTs(m)); - } - } - } - - const firstTs = (messages.find((m) => m.ts) || {}).ts || null; - let lastTs = null; - for (let i = messages.length - 1; i >= 0; i--) if (messages[i].ts) { lastTs = messages[i].ts; break; } - return { - messages, totals, model, firstTs, lastTs, cwd, sessionId, threadId, parentThreadId, - forkedFromId, isSubagent, agentPath, agentNickname, agentRole, agentDepth, gitBranch, version, - }; -} - -/** (cwd, canonical threadId) from a Codex head — used to name an imported store copy. */ -function headIds(recs) { - for (const rec of recs || []) { - if (!rec || typeof rec !== 'object') continue; - const { t, p } = splitLine(rec); - // Every subagent in a tree shares session_id. The FIRST SessionMeta.id is the only safe - // import key; using session_id makes sibling rollouts overwrite/skip one another. - if (t === 'session_meta') return { - cwd: p.cwd || null, - sessionId: p.id || p.thread_id || null, - rootSessionId: p.session_id || p.id || null, - }; - } - return { cwd: null, sessionId: null, rootSessionId: null }; -} - -/* ---------- sidecar customization (~/.ccbud/codex-meta.json): { "": {title?, tagList?, delete?} } ---------- */ - -function ccbudHome() { return process.env.CCBUD_HOME || path.join(os.homedir(), '.ccbud'); } -function sidecarPath() { return path.join(ccbudHome(), 'codex-meta.json'); } - -let sidecarCache = null; // { mtime, map } -function sidecarMtime() { - try { return fs.statSync(sidecarPath()).mtimeMs; } catch (_) { return 0; } -} -function readSidecar() { - const mt = sidecarMtime(); - if (sidecarCache && sidecarCache.mtime === mt) return sidecarCache.map; - let map = {}; - try { - const v = JSON.parse(fs.readFileSync(sidecarPath(), 'utf8')); - if (v && typeof v === 'object' && !Array.isArray(v)) map = v; - } catch (_) {} - sidecarCache = { mtime: mt, map }; - return map; -} -function writeSidecar(map) { - try { - fs.mkdirSync(ccbudHome(), { recursive: true }); - const tmp = sidecarPath() + '.tmp'; - fs.writeFileSync(tmp, JSON.stringify(map, null, 2), 'utf8'); - fs.renameSync(tmp, sidecarPath()); - sidecarCache = { mtime: sidecarMtime(), map }; - return true; - } catch (_) { return false; } -} -function stemOf(file) { return path.basename(String(file || ''), '.jsonl'); } -function sidecarMeta(file) { - const c = readSidecar()[stemOf(file)]; - if (!c || typeof c !== 'object') return { title: null, tags: [], deleted: false }; - return { - title: typeof c.title === 'string' && c.title.trim() ? c.title.trim() : null, - tags: Array.isArray(c.tagList) ? c.tagList.filter((t) => typeof t === 'string' && t.trim()).map((t) => t.trim()) : [], - deleted: c.delete === true, - }; -} -function isDeleted(file) { return sidecarMeta(file).deleted; } - -/** - * setCcbud-equivalent for codex sessions: same patch semantics ({title?, tags?, delete?}), - * persisted to the sidecar instead of the rollout file (never mutate another tool's data). - */ -function setMeta(file, patch) { - patch = patch || {}; - const stem = stemOf(file); - if (!stem) return { ok: false, reason: 'empty' }; - const map = Object.assign({}, readSidecar()); - const next = Object.assign({}, map[stem] || {}); - if ('title' in patch) { const t = String(patch.title || '').trim(); if (t) next.title = t; else delete next.title; } - if ('tags' in patch) { - const arr = []; - for (const x of (patch.tags || [])) { const t = typeof x === 'string' ? x.trim() : ''; if (t && arr.indexOf(t) < 0) arr.push(t); } - if (arr.length) next.tagList = arr; else delete next.tagList; - } - if ('delete' in patch) { if (patch.delete) next.delete = true; else delete next.delete; } - if (Object.keys(next).length) map[stem] = next; else delete map[stem]; - return writeSidecar(map) ? { ok: true } : { ok: false, reason: 'write' }; -} - -/** Drop a session's sidecar entry (after its rollout file is deleted forever). */ -function removeMeta(file) { - const stem = stemOf(file); - const map = Object.assign({}, readSidecar()); - if (stem in map) { delete map[stem]; writeSidecar(map); } -} - -/* ---------- list/detail shapes (codex flavors of history.js sessionMeta / getSession) ---------- */ - -function firstUserText(messages) { return require('./history').firstUserText(messages); } -function baseName(p) { - if (!p) return null; - const parts = String(p).split('/').filter(Boolean); - return parts.length ? parts[parts.length - 1] : p; -} - -/** - * List-row meta from already-parsed head records. `dm` is the dir descriptor - * ({ id:'__codex__', … } for the live tree, the imported dir for store snapshots). - */ -// In-file __ccbud__ for imported codex COPIES (our own files; history.setCcbud writes it there). -// The Electron readCcbud carries no delete flag (recycle bin is Tauri-side), hence deleted:false. -function fileCcbud(recs) { - const cc = require('./history').readCcbud(recs); - return { title: cc.title, tags: cc.tags, deleted: false }; -} -// Imported snapshots are marked by their provenance sidecar; live rollouts have none. -function hasImportSidecar(file) { - try { return fs.statSync(String(file).replace(/\.jsonl$/, '.import.json')).isFile(); } catch (_) { return false; } -} - -function sessionMetaFrom(file, recs, dm, st) { - const n = normalize(recs); - // Live rollouts customize via the sidecar (never rewrite another tool's files); imported - // COPIES are our own files, where the in-file __ccbud__ applies. - const cc = hasImportSidecar(file) ? fileCcbud(recs) : sidecarMeta(file); - let transcriptTitle = firstUserText(n.messages) || firstEventUserTitle(recs); - if (!transcriptTitle && (!st || st.size > 131072)) transcriptTitle = scanEventUserTitle(file); - const autoTitle = subagentTitle(n) || transcriptTitle; - const stem = stemOf(file); - const rowScope = (dm && dm.id) || ''; - return { - id: 'codex:' + rowScope + ':' + stem, - file, - source: 'codex', - dirId: dm ? dm.id : null, - dirLabel: dm ? dm.label : null, - sessionId: n.threadId || n.sessionId || stem, - threadId: n.threadId || n.sessionId || stem, - canonicalThreadIdValid: isCanonicalThreadId(n.threadId), - rootSessionId: n.sessionId || n.threadId || stem, - parentThreadId: n.parentThreadId, - forkedFromId: n.forkedFromId, - cwd: n.cwd, - project: baseName(n.cwd), - gitBranch: n.gitBranch, - title: cc.title || autoTitle, - autoTitle, - tags: cc.tags, - model: n.model, - isSubagent: n.isSubagent, - agentPath: n.agentPath, - agentNickname: n.agentNickname, - agentRole: n.agentRole, - agentDepth: n.agentDepth, - imported: !!(dm && dm.imported), - deleted: cc.deleted || false, - lastActivity: st ? st.mtimeMs : 0, - sizeKB: st ? Math.round(st.size / 1024) : 0, - }; -} - -/** Full-detail shape from already-parsed records (history.getSession routes here). */ -function sessionFromRecs(file, recs) { - const n = normalize(recs); - let imported = null; - try { imported = JSON.parse(fs.readFileSync(String(file).replace(/\.jsonl$/, '.import.json'), 'utf8')); } catch (_) {} - // Same sidecar-vs-in-file split as sessionMetaFrom. - const cc = imported ? fileCcbud(recs) : sidecarMeta(file); - const autoTitle = subagentTitle(n) || firstUserText(n.messages) || firstEventUserTitle(recs); - const stem = stemOf(file); - return { - meta: { - id: 'codex:' + stem, - file, - source: 'codex', - assistant: 'Codex', - title: cc.title || autoTitle, - autoTitle, - tags: cc.tags, - summary: null, - sessionId: n.threadId || n.sessionId || stem, - threadId: n.threadId || n.sessionId || stem, - canonicalThreadIdValid: isCanonicalThreadId(n.threadId), - rootSessionId: n.sessionId || n.threadId || stem, - parentThreadId: n.parentThreadId, - forkedFromId: n.forkedFromId, - cwd: n.cwd, - project: baseName(n.cwd), - gitBranch: n.gitBranch, - version: n.version, - isSubagent: n.isSubagent, - agentPath: n.agentPath, - agentNickname: n.agentNickname, - agentRole: n.agentRole, - agentDepth: n.agentDepth, - deleted: cc.deleted || false, - imported: !!imported, - importedFrom: imported ? imported.originalPath : null, - importedAt: imported ? imported.importedAt : null, - model: n.model, - totals: n.totals, - messages: n.messages.length, - subagentCount: 0, - firstTs: n.firstTs, - lastTs: n.lastTs, - }, - messages: n.messages, - subagents: {}, - }; -} - -module.exports = { - codexLabel, - sessionsRoot, - rootExists, - walkSessions, - hasImportSidecar, - looksCodex, - normalize, - headIds, - sidecarMeta, - isDeleted, - setMeta, - removeMeta, - sessionMetaFrom, - sessionFromRecs, -}; diff --git a/src/main/countTokens.js b/src/main/countTokens.js deleted file mode 100644 index 6086bd5..0000000 --- a/src/main/countTokens.js +++ /dev/null @@ -1,95 +0,0 @@ -'use strict'; - -/** - * Local token estimator for the `POST /v1/messages/count_tokens` fallback. - * - * Claude Code calls count_tokens BEFORE sending, to size the context (when to - * auto-compact, etc.). Many Anthropic-compatible providers don't implement the - * endpoint and answer 404, which breaks that accounting. When the gateway can't get - * a real count it estimates one here. - * - * Approach (see research notes): o200k_base (the closest publicly-available tokenizer - * to Claude 3/4 — the official @anthropic-ai/tokenizer is a stale Claude-2 vocab that - * over-counts CJK) for the text, plus a calibrated STRUCTURAL overhead that count_tokens - * adds for message framing / system / tools. We deliberately round UP a little: a slight - * over-count just makes Claude Code compact a touch early, whereas under-counting could - * let a request overflow the real upstream limit. - */ - -let _enc = null; // null = not yet tried, false = unavailable, else a Tiktoken instance -function encoder() { - if (_enc === null) { - try { - const { Tiktoken } = require('js-tiktoken/lite'); - const rank = require('js-tiktoken/ranks/o200k_base'); - _enc = new Tiktoken(rank && rank.default ? rank.default : rank); - } catch (_) { - _enc = false; // fall back to a char heuristic below - } - } - return _enc || null; -} - -function safeJson(v) { - try { return v == null ? '' : JSON.stringify(v); } catch (_) { return ''; } -} - -// Calibrated against the real count_tokens endpoint. o200k text + these overheads land -// a touch above the true count across single/multi-message, +system and +tools requests. -const BASE = 5; // per-request framing (BOS / wrapper) -const PER_MSG = 4; // per-message wrapper (role + delimiters) -const SYS = 4; // system-prompt framing -const TOOLS = 15; // fixed tools→system injection framing (NOT per-tool) -const IMAGE = 1600; // images are size-priced; flat conservative estimate (rarely hit here) -const SAFETY = 1.06; // round a little high, never under-count - -/** - * Estimate the input_tokens for an Anthropic Messages request body. - * Mirrors what count_tokens charges: system + every message's text/tool_use/tool_result - * + the tool definitions, plus structural overhead. - */ -function estimateInputTokens(body) { - const enc = encoder(); - const count = enc - ? (s) => (s ? enc.encode(String(s)).length : 0) - : (s) => (s ? Math.ceil(String(s).length / 4) : 0); // crude fallback if tokenizer missing - - body = body || {}; - let t = 0; - - const sys = body.system; - if (typeof sys === 'string') t += count(sys); - else if (Array.isArray(sys)) for (const b of sys) if (b && b.type === 'text') t += count(b.text); - - const msgs = Array.isArray(body.messages) ? body.messages : []; - for (const m of msgs) { - const c = m && m.content; - if (typeof c === 'string') { t += count(c); continue; } - if (!Array.isArray(c)) continue; - for (const b of c) { - if (!b || typeof b !== 'object') continue; - if (b.type === 'text') t += count(b.text); - else if (b.type === 'tool_use') t += count(b.name) + count(safeJson(b.input)); - else if (b.type === 'tool_result') { - if (typeof b.content === 'string') t += count(b.content); - else if (Array.isArray(b.content)) for (const x of b.content) if (x && x.type === 'text') t += count(x.text); - } else if (b.type === 'image') t += IMAGE; - } - } - - const tools = Array.isArray(body.tools) ? body.tools : []; - for (const tool of tools) { - if (!tool) continue; - t += count(tool.name) + count(tool.description) + count(safeJson(tool.input_schema)); - } - - const overhead = BASE + PER_MSG * msgs.length + (sys ? SYS : 0) + (tools.length ? TOOLS : 0); - return Math.max(1, Math.ceil((t + overhead) * SAFETY)); -} - -// Is the local tokenizer actually loaded (vs the crude char fallback)? For diagnostics/tests. -function tokenizerReady() { - return encoder() != null; -} - -module.exports = { estimateInputTokens, tokenizerReady }; diff --git a/src/main/export-assets/runtime-analytics.js b/src/main/export-assets/runtime-analytics.js new file mode 100644 index 0000000..8fcff38 --- /dev/null +++ b/src/main/export-assets/runtime-analytics.js @@ -0,0 +1,61 @@ +/* ccbud export viewer runtime. Renders window.__CONV__ into a Claude-styled, themeable, + searchable single-file app with a sidebar outline and expandable tools/subagents. */ + +/* ---- usage analytics (Microsoft Clarity) ---- + Runs first (inside the generator's nonce'd script block) so viewer-runtime errors are + captured too; the injected tag is allowed by the export CSP's clarity.ms origins (see + exporthtml.rs / exportHtml.js). Offline viewers just queue into the stub and send + nothing. Only element identifiers ever become event names — message text, paths and + titles are never sent. */ +(function () { + try { + var PROJECT_ID = 'xij8wflxsj'; + window.clarity = window.clarity || function () { (window.clarity.q = window.clarity.q || []).push(arguments); }; + if (!document.getElementById('clarity-script')) { + var s = document.createElement('script'); + s.async = true; s.id = 'clarity-script'; + s.src = 'https://www.clarity.ms/tag/' + PROJECT_ID; + (document.head || document.documentElement).appendChild(s); + } + var track = function (n) { try { window.clarity('event', String(n).slice(0, 250)); } catch (e) {} }; + var tag = function (k, v) { try { if (v != null && v !== '') window.clarity('set', k, String(v).slice(0, 250)); } catch (e) {} }; + var meta = (window.__CONV__ && window.__CONV__.meta) || {}; + tag('surface', 'export'); + tag('assistant', meta.assistant || 'Claude'); + tag('appVersion', window.__CCBUD_VERSION__); // version of the app that generated this export + track('export:open'); + var name = function (el) { + for (var n = el, d = 0; n && n.nodeType === 1 && d < 15; n = n.parentElement, d++) { + if (n.id) return /^m\d+$/.test(n.id) ? '#msg' : '#' + n.id; + var cls = typeof n.className === 'string' ? n.className.trim().split(/\s+/)[0] : ''; + if (cls) return n.tagName.toLowerCase() + '.' + cls; + } + return el && el.nodeType === 1 ? el.tagName.toLowerCase() : 'unknown'; + }; + document.addEventListener('click', function (e) { + if (e.target && e.target.nodeType === 1) track('click:' + name(e.target)); + }, true); + var searched = false; + document.addEventListener('input', function (e) { + if (!searched && e.target && e.target.id === 'q') { searched = true; track('export:search'); } + }, true); + // Error messages can embed local paths or URLs — redact those before tagging. + var scrubError = function (s) { + return String(s == null ? 'unknown' : s) + .replace(/(?:file|https?):\/\/[^\s'")]+/gi, '') + .replace(/(^|[\s'"(=:,])(?:~\/|\/)[^\s'")]+/g, '$1') + .replace(/[A-Za-z]:\\[^\s'")]+/g, '') + .slice(0, 120); + }; + window.addEventListener('error', function (e) { + if (e && e.target && e.target !== window && e.target.nodeType === 1) return; + track('error:js'); tag('lastError', scrubError(e && e.message)); + try { window.clarity('upgrade', 'js-error'); } catch (err) {} + }, true); + window.addEventListener('unhandledrejection', function (e) { + var r = e && e.reason; + track('error:unhandled-rejection'); tag('lastError', scrubError(r && r.message ? r.message : r)); + }); + } catch (e) {} +})(); + diff --git a/src/main/export-assets/runtime-messages.js b/src/main/export-assets/runtime-messages.js new file mode 100644 index 0000000..1c3ed53 --- /dev/null +++ b/src/main/export-assets/runtime-messages.js @@ -0,0 +1,168 @@ +/* ccbud export viewer runtime — part 3/4: message/thread rendering (Codex bootstrap folding, + injected-block stripping, blocks, turn meta, thread + meta line). Continues the render + IIFE opened in runtime-render.js. */ + function formatCodexBootstrap(text) { + var source = String(text || ''); + var agents = /^\s*#\s+AGENTS\.md instructions for ([^\r\n]+)[\s\S]*?]*>([\s\S]*?)<\/INSTRUCTIONS>/i.exec(source); + if (!agents) return null; + + var env = /]*>([\s\S]*?)<\/environment_context>/i.exec(source); + var parts = ['# AGENTS.md instructions for ' + agents[1].trim()]; + var instructions = agents[2].trim(); + if (instructions) { + var lines = instructions.split(/\r?\n/).filter(function (line) { return line.trim(); }); + parts.push(lines.length === 1 + ? '**INSTRUCTIONS:** ' + lines[0].trim() + : '**INSTRUCTIONS:**\n\n' + instructions); + } + + if (env) { + var block = env[1]; + var tag = function (name) { + var match = new RegExp('<' + name + '\\b[^>]*>([\\s\\S]*?)<\\/' + name + '>', 'i').exec(block); + return match ? match[1].trim() : ''; + }; + var attr = function (name, attribute) { + var match = new RegExp("<" + name + "\\b[^>]*\\b" + attribute + "=[\"']([^\"']+)[\"']", "i").exec(block); + return match ? match[1].trim() : ''; + }; + var code = function (value) { + var tick = String.fromCharCode(96); + return value ? tick + value + tick : ''; + }; + var roots = []; + var rootRe = /]*>([\s\S]*?)<\/root>/gi; + var root; + while ((root = rootRe.exec(block)) !== null) { + if (root[1].trim()) roots.push(code(root[1].trim())); + } + var fields = [ + ['environment_context', code(tag('cwd'))], + ['shell', tag('shell')], + ['current_date', tag('current_date')], + ['timezone', tag('timezone')], + ['workspace_roots', roots.join(', ')], + ['permission_profile', attr('permission_profile', 'type')], + ['file_system', attr('file_system', 'type')], + ].filter(function (field) { return field[1]; }); + if (fields.length) { + parts.push(fields.map(function (field) { return '**' + field[0] + ':** ' + field[1]; }).join(' \n')); + } + } + + var rest = source.replace(agents[0], ''); + if (env) rest = rest.replace(env[0], ''); + rest = rest.trim(); + if (rest) parts.push(rest); + return parts.join('\n\n').trim(); + } + function stripInjected(text) { + var source = String(text || ''); + var bootstrap = formatCodexBootstrap(source); + if (bootstrap != null) source = bootstrap; + if (/^\s*]*>[\s\S]*<\/skill>\s*$/i.test(source)) return ''; + return source + .replace(/]*>[\s\S]*?<\/task-notification>/gi, function (block) { + var result = /]*>([\s\S]*?)<\/result>/i.exec(block); + return result ? '\n' + result[1].trim() + '\n' : ''; + }) + .replace(/[\s\S]*?<\/system-reminder>/g, '') + .replace(/[\s\S]*?<\/command-[a-z-]+>/g, '') + .replace(/[\s\S]*?<\/local-command-[a-z]+>/g, '') + .trim(); + } + function renderBlocks(content, cleanUserText) { + var blocks = Array.isArray(content) ? content : (typeof content === 'string' ? [{ type: 'text', text: content }] : []); + var out = ''; + blocks.forEach(function (b) { + if (!b) return; + if (b.type === 'text') { var text = cleanUserText ? stripInjected(b.text) : b.text; if (text && text.trim()) out += '
' + md(text) + '
'; } + else if (b.type === 'thinking') { if (b.thinking && b.thinking.trim()) { var first = b.thinking.split('\n').filter(function (x) { return x.trim(); })[0] || ''; out += '
💭 思考 · ' + esc(trunc(first, 64)) + '
' + md(b.thinking) + '
'; } } + else if (b.type === 'skill_load') out += renderSkillLoad(b); + else if (b.type === 'tool_use') out += renderTool(b); + else if (b.type === 'image') { var s = b.source || {}; out += s.data ? '' : '
🖼 image' + (s.oversized ? ' (large, omitted)' : '') + '
'; } + }); + return out; + } + function turnMeta(m) { + var bits = []; if (m.model) bits.push(esc(m.model)); + if (m.usage) { + var tokenTotal = (m.usage.in || 0) + (m.usage.out || 0) + (m.usage.cacheRead || 0) + (m.usage.cacheCreation || 0); + if (m.usage.credits == null || tokenTotal > 0) bits.push(fmtTok(m.usage.in) + '↑ ' + fmtTok(m.usage.out) + '↓'); + if (m.usage.credits != null) bits.push(fmtCredits(m.usage.credits) + ' Credits'); + } + if (m.usage && m.usage.cacheRead) bits.push(fmtTok(m.usage.cacheRead) + ' cache'); + return bits.length ? '
' + bits.map(function (b) { return '' + b + ''; }).join('') + '
' : ''; + } + function msgVisible(m) { + var bl = Array.isArray(m.content) ? m.content : (typeof m.content === 'string' ? [{ type: 'text', text: m.content }] : []); + if (m.role === 'user') { var vis = bl.filter(function (b) { return b && (b.type === 'text' || b.type === 'image'); }); if (!vis.length) return false; var hasImage = vis.some(function (b) { return b.type === 'image'; }); var txt = vis.map(function (b) { return b.type === 'text' ? stripInjected(b.text) : ''; }).filter(Boolean).join('\n'); return hasImage || !!txt; } + return bl.some(function (b) { return b && (b.type === 'text' || b.type === 'thinking' || b.type === 'tool_use' || b.type === 'image'); }); + } + // tags for sidebar/filtering + function msgTags(m) { + var t = { tool: 0, sub: 0 }; + (Array.isArray(m.content) ? m.content : []).forEach(function (b) { if (b && b.type === 'tool_use') { t.tool++; if ((D.subagents || {})[b.id]) t.sub++; } }); + return t; + } + function renderThread(msgs) { + var out = ''; + (msgs || []).forEach(function (m) { + var blocks = Array.isArray(m.content) ? m.content : (typeof m.content === 'string' ? [{ type: 'text', text: m.content }] : []); + var skillBlocks = blocks.filter(function (b) { return b && b.type === 'skill_load'; }); + if (skillBlocks.length) out += '
' + skillBlocks.map(renderSkillLoad).join('') + '
'; + var regularBlocks = blocks.filter(function (b) { return !b || b.type !== 'skill_load'; }); + var regular = Object.assign({}, m, { content: regularBlocks }); + if (!msgVisible(regular)) return; + var body = renderBlocks(regularBlocks, m.role === 'user'); if (!body) return; + if (m.role === 'user') out += '
👤 你
' + body + '
'; + else { var tg = msgTags(m); out += '
' + esc(AST) + '
' + body + turnMeta(m) + '
'; } + }); + return out; + } + + // ===== build shell ===== + var meta = D.meta || {}; + var AST = meta.assistant || 'Claude'; // assistant display name (Codex rollouts export with "Codex") + function metaLine() { + var p = []; + if (meta.model) p.push('' + esc(meta.model) + ''); + if (meta.project) p.push(esc(meta.project)); + if (meta.turns) p.push(meta.turns + ' 轮'); + if (meta.inTok != null && meta.tokenUsageAvailable !== false) p.push(fmtTok(meta.inTok) + '↑ ' + fmtTok(meta.outTok) + '↓'); + if (meta.credits != null) p.push(fmtCredits(meta.credits) + ' Credits'); + if (meta.cacheTok) p.push(fmtTok(meta.cacheTok) + ' 缓存'); + if (meta.subagentCount) p.push(meta.subagentCount + ' 子代理'); + return p.join(' · '); + } + var threadHtml = renderThread(D.messages) || '
空对话
'; + var orphanKeys = Object.keys(D.subagents || {}).filter(function (k) { return !USED[k]; }); + var orphanHtml = orphanKeys.length + ? '
🤖其他子代理 (' + orphanKeys.length + ')
下列子代理未在主时间线中找到明确的调用点(可能由工作流派生或调用记录已省略),单独列出以便查看:
' + orphanKeys.map(function (k) { return renderSubagent(D.subagents[k]); }).join('') + '
' + : ''; + var app = document.getElementById('app'); + app.innerHTML = + '
' + + '' + + '

' + esc(meta.title || '对话') + '

' + metaLine() + '
' + + '
' + + '' + + '' + + '
' + + '
' + + '
' + + '' + + '
' + threadHtml + orphanHtml + '
' + + '
' + + '
'; + + var content = app.querySelector('.content'); + var thread = document.getElementById('thread'); + diff --git a/src/main/export-assets/runtime-render.js b/src/main/export-assets/runtime-render.js new file mode 100644 index 0000000..24775e2 --- /dev/null +++ b/src/main/export-assets/runtime-render.js @@ -0,0 +1,101 @@ +/* ccbud export viewer runtime — part 2/4: shared helpers, tool cards and subagent blocks. + The four runtime-*.js parts are concatenated VERBATIM (in name order, see exporthtml.rs) + into one ' - + '' - + '' - + '' - + ''; -} - -/** Convenience: parse a session file and build its standalone HTML in one call. */ -function buildExportHtml(file) { return htmlFromData(buildData(file)); } - -module.exports = { buildExportHtml, buildData, htmlFromData }; diff --git a/src/main/history.js b/src/main/history.js deleted file mode 100644 index e6b1900..0000000 --- a/src/main/history.js +++ /dev/null @@ -1,763 +0,0 @@ -'use strict'; - -/** - * Reads Claude Code's on-disk session history across ONE OR MORE config directories - * (each a `/projects//.jsonl` tree). The default config dir is - * ~/.claude, but Claude Code can run against others (CLAUDE_CONFIG_DIR / --config); the user - * registers those in settings and ccbud aggregates / switches between them. - * - * - getDirs() supplies [{ id, label, projectsDir }]; the watcher watches ALL of them, while - * listSessions/listProjects can be filtered to one (the directory switcher) or 'all'. - * - BROWSE: listProjects()/getSession() — project→session tree + rich message model. - * - WATCH: fs.watch each projects dir, emit 'changed' { files } on growth so the renderer - * live-follows; 'correlate' for each new assistant line (tests / future use). - * - * Test override: CCBUD_HISTORY_DIR points the default single dir at a temp projects tree. - */ - -const fs = require('fs'); -const path = require('path'); -const os = require('os'); -const { EventEmitter } = require('events'); -const codex = require('./codex'); - -function defaultDirs() { - const root = process.env.CCBUD_HISTORY_DIR || path.join(os.homedir(), '.claude', 'projects'); - return [{ id: 'default', label: '~/.claude', projectsDir: root }]; -} - -/** Best-effort decode of an encoded project dir name → cwd (lossy fallback; record cwd wins). */ -function decodeDirName(name) { - if (!name) return null; - return '/' + String(name).replace(/^-+/, '').replace(/-/g, '/'); -} - -function baseName(p) { - if (!p) return null; - const parts = String(p).split('/').filter(Boolean); - return parts.length ? parts[parts.length - 1] : p; -} - -function usageOf(u) { - if (!u) return null; - const usage = { - inputTokens: u.input_tokens || 0, - outputTokens: u.output_tokens || 0, - cacheRead: u.cache_read_input_tokens || 0, - cacheCreation: u.cache_creation_input_tokens || 0, - }; - // Qoder records billing credits even when its transcript exposes no token counts. Keep these - // fields optional so the normalized shape for Claude Code sessions remains unchanged. - if (typeof u.credits === 'number' && Number.isFinite(u.credits)) usage.credits = u.credits; - if (typeof u.original_credits === 'number' && Number.isFinite(u.original_credits)) usage.originalCredits = u.original_credits; - if (typeof u.context_usage_ratio === 'number' && Number.isFinite(u.context_usage_ratio)) usage.contextUsageRatio = u.context_usage_ratio; - return usage; -} - -function lineToMessage(rec) { - if (!rec || (rec.type !== 'user' && rec.type !== 'assistant') || !rec.message) return null; - const m = rec.message; - if (!m.role) return null; - const out = { - role: m.role, - content: m.content, - _id: m.id || null, - _ts: rec.timestamp || null, - _uuid: rec.uuid || null, - _parent: rec.parentUuid || null, - _sidechain: !!rec.isSidechain, - _meta: !!rec.isMeta, - }; - if (rec.type === 'assistant') { - out._model = m.model || null; - out._usage = usageOf(m.usage); - out._stopReason = m.stop_reason || null; - } - return out; -} - -function contentText(content) { - if (typeof content === 'string') return content; - if (Array.isArray(content)) return content.filter((b) => b && b.type === 'text').map((b) => b.text || '').join(' '); - return ''; -} - -// A slash-command turn is stored as XML tags, e.g. -// /model fable-5 -// Surface it as a readable "/model fable-5" label instead of leaking the raw tags. -function commandLabel(raw) { - const name = (raw.match(/([^<]*)<\/command-name>/) || [])[1]; - if (!name) return ''; - const args = (raw.match(/([^<]*)<\/command-args>/) || [])[1] || ''; - return (name.trim() + ' ' + args.trim()).trim(); -} - -function firstUserText(messages) { - let fallbackCmd = ''; // first slash-command label, used only if no prose turn exists - for (const m of messages) { - if (!m || m.role !== 'user' || m._meta) continue; - const raw = contentText(m.content).trim(); - if (!raw) continue; - if (raw.startsWith('<')) { if (!fallbackCmd) fallbackCmd = commandLabel(raw); continue; } - const t = raw.replace(/\s+/g, ' '); - if (/^(\[Request interrupted|Caveat:)/.test(t)) continue; - return t.slice(0, 90); - } - // No prose turn: prefer a parsed "/cmd" label over dumping raw XML; empty → renderer - // substitutes a localized "(conversation)". - return fallbackCmd.slice(0, 90); -} - -// Optional per-conversation customization the app writes onto a session line as `__ccbud__` -// (custom title + user tags). It's an extra field on a meta line — invisible to Claude Code itself. -// See setCcbud for the writer. Returns { title|null, tags[] }. -function readCcbud(recs) { - const r = recs.find((x) => x && typeof x === 'object' && x.__ccbud__); - const c = r ? r.__ccbud__ : null; - return { - title: c && typeof c.title === 'string' && c.title.trim() ? c.title.trim() : null, - tags: c && Array.isArray(c.tagList) - ? c.tagList.filter((t) => typeof t === 'string' && t.trim()).map((t) => t.trim()) - : [], - }; -} - -function parseLines(buf) { - const out = []; - for (const line of buf.split('\n')) { - const s = line.trim(); - if (!s) continue; - try { out.push(JSON.parse(s)); } catch (_) {} - } - return out; -} - -function readChunk(file, size, max) { - let fd = null; - try { - fd = fs.openSync(file, 'r'); - const len = Math.min(size, max); - const chunks = []; - const first = Buffer.alloc(len); - const firstRead = fs.readSync(fd, first, 0, len, 0); - chunks.push(first.subarray(0, firstRead)); - let offset = firstRead; - const firstPrefix = first.subarray(0, Math.min(firstRead, 4096)).toString('utf8'); - const codexMetaMatch = /"type"\s*:\s*"session_meta"/.exec(firstPrefix); - const codexSessionMeta = !!codexMetaMatch && codexMetaMatch.index < 512; - // A Codex SessionMeta can itself exceed the ordinary list window (base instructions and - // dynamic tools live on that line). Complete that first record instead of handing the parser - // a truncated JSON object; this mirrors Codex's own line-oriented metadata reader. - // Only extend when the FIRST record itself exceeds the window. If the window already contains - // a newline, its final partial record can be ignored by parseLines; chasing that line could - // otherwise pull a multi-megabyte image/tool result into every list refresh. - while (codexSessionMeta && offset < size && first.subarray(0, firstRead).indexOf(10) < 0) { - const next = Buffer.alloc(Math.min(65536, size - offset)); - const n = fs.readSync(fd, next, 0, next.length, offset); - if (!n) break; - const part = next.subarray(0, n); - const newline = part.indexOf(10); - if (newline >= 0) { - chunks.push(part.subarray(0, newline + 1)); - break; - } - chunks.push(part); - offset += n; - } - return Buffer.concat(chunks).toString('utf8'); - } catch (_) { return ''; } - finally { if (fd != null) try { fs.closeSync(fd); } catch (_) {} } -} - -// Shape parsed records into the renderer's message model (+ rollup totals / model / span). Shared -// by getSession and the subagent reader so a subagent's timeline renders identically to the main one. -function shapeMessages(recs) { - const messages = []; - const totals = { in: 0, out: 0, cacheRead: 0, cacheCreation: 0, turns: 0 }; - let credits = 0, hasCredits = false; - let model = null, firstTs = null, lastTs = null; - for (const r of recs) { - const lm = lineToMessage(r); - if (!lm || lm._meta) continue; - if (lm._ts) { if (!firstTs) firstTs = lm._ts; lastTs = lm._ts; } - const msg = { role: lm.role, content: lm.content }; - if (lm._sidechain) msg.isSidechain = true; - if (lm._ts) msg.ts = lm._ts; - if (r.type === 'assistant') { - if (lm._model) { msg.modelActual = lm._model; model = lm._model; } - if (lm._usage) msg.usage = lm._usage; - if (lm._stopReason) msg.stopReason = lm._stopReason; - const u = lm._usage; - if (u) { - totals.in += u.inputTokens; totals.out += u.outputTokens; - totals.cacheRead += u.cacheRead; totals.cacheCreation += u.cacheCreation; - if (typeof u.credits === 'number' && Number.isFinite(u.credits)) { - credits += u.credits; - hasCredits = true; - } - totals.turns += 1; - } - } - messages.push(msg); - } - if (hasCredits) { - totals.credits = credits; - // Qoder currently writes real credit usage but zero for all four token counters. Expose that - // distinction explicitly so callers can render an unavailable value instead of a fake zero. - if (totals.in === 0 && totals.out === 0 && totals.cacheRead === 0 && totals.cacheCreation === 0) { - totals.tokenUsageAvailable = false; - } - } - return { messages, totals, model, firstTs, lastTs }; -} - -// A skill-forked subagent transcript opens with a sentinel user line -// "Base directory for this skill: /" — the last path segment names the skill. -// Fallback attribution only: the spawning `Skill` tool_use in the parent thread (applySkillNames) -// is authoritative and overrides this when present. -const SKILL_BASE_DIR_PREFIX = 'Base directory for this skill: '; -function skillFromRecs(recs) { - for (const r of recs) { - if (!r || r.type !== 'user' || !r.message || r.isMeta) continue; - const raw = contentText(r.message.content).trim(); - if (!raw.startsWith(SKILL_BASE_DIR_PREFIX)) return null; // only the opening prompt carries the sentinel - const line = raw.slice(SKILL_BASE_DIR_PREFIX.length).split('\n')[0].trim(); - const segs = line.split(/[\\/]/).filter(Boolean); - return segs.length ? segs[segs.length - 1] : null; - } - return null; -} - -// Primary skill attribution: a subagent spawned by the `Skill` tool is named by the spawning -// tool_use's input.skill (matched by tool_use id — the subagents map key), in whichever thread -// the call lives (main or a nested subagent). Overrides the sentinel fallback from skillFromRecs. -function applySkillNames(mainMessages, subs) { - const keys = Object.keys(subs); - if (!keys.length) return; - const scan = (msgs) => (msgs || []).forEach((m) => { - const blocks = m && Array.isArray(m.content) ? m.content : []; - for (const b of blocks) { - if (!b || b.type !== 'tool_use' || b.name !== 'Skill' || !subs[b.id]) continue; - const s = b.input && typeof b.input.skill === 'string' ? b.input.skill.trim() : ''; - if (s) subs[b.id].skill = s; - } - }); - scan(mainMessages); - for (const k of keys) scan(subs[k].messages); -} - -// Read a session's subagent dialogues — //subagents/agent-.{jsonl,meta.json} -// — keyed by the spawning Task/Agent tool_use id (agent-.meta.json's toolUseId), so the "对话" -// view can nest each subagent's timeline under the call that spawned it. Mirrors the HTML export. -// Returns {} when the session has no subagents directory. -function readSubagents(file) { - const dir = path.join(path.dirname(file), path.basename(file, '.jsonl'), 'subagents'); - let entries; - try { entries = fs.readdirSync(dir); } catch (_) { return {}; } - const byTool = {}; - for (const name of entries) { - if (!/^agent-.*\.jsonl$/.test(name)) continue; - const agentId = name.replace(/^agent-/, '').replace(/\.jsonl$/, ''); - let meta = {}; - try { meta = JSON.parse(fs.readFileSync(path.join(dir, 'agent-' + agentId + '.meta.json'), 'utf8')); } catch (_) {} - let raw; - try { raw = fs.readFileSync(path.join(dir, name), 'utf8'); } catch (_) { continue; } - const recs = parseLines(raw); - const shaped = shapeMessages(recs); - const key = meta.toolUseId || ('agent:' + agentId); - byTool[key] = { - agentId, - file: path.join(dir, name), // absolute path to this subagent's .jsonl (for "copy path") - type: meta.agentType || meta.subagent_type || 'agent', - description: meta.description || '', - skill: skillFromRecs(recs), - count: shaped.messages.length, - totals: shaped.totals, - messages: shaped.messages, - }; - } - return byTool; -} - -// Absolute path to a session's subagents dir (`//subagents`), regardless of existence. -function subagentDir(file) { - return path.join(path.dirname(file), path.basename(file, '.jsonl'), 'subagents'); -} - -// A session's raw subagent sidecar files (`agent-*.jsonl` + `agent-*.meta.json`) as -// [{ name, data:Buffer }], sorted by name. Empty when the session has no subagents. Shared by -// bundle export/import and the replay merge (mirrors src-tauri/src/history.rs read_subagent_files). -function readSubagentFiles(file) { - const dir = subagentDir(file); - let names; - try { names = fs.readdirSync(dir); } catch (_) { return []; } - const out = []; - for (const name of names) { - if (!/^agent-.*\.jsonl$/i.test(name) && !/^agent-.*\.meta\.json$/i.test(name)) continue; - try { - const p = path.join(dir, name); - if (!fs.statSync(p).isFile()) continue; - out.push({ name, data: fs.readFileSync(p) }); - } catch (_) {} - } - out.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)); - return out; -} - -// Absolute paths of a session's subagent transcripts (`/subagents/agent-*.jsonl`), sorted. -// Empty when the session has no subagents. Powers "Claude 分析": every subagent transcript is -// attached alongside the main session in the Cowork deep link (which honors a repeated `file=` -// param), so the analysis covers subagent runs — not just the main thread. -function subagentTranscriptPaths(file) { - const dir = subagentDir(file); - let names; - try { names = fs.readdirSync(dir); } catch (_) { return []; } - const out = []; - for (const name of names) { - if (!/^agent-.*\.jsonl$/i.test(name)) continue; - const p = path.join(dir, name); - try { if (fs.statSync(p).isFile()) out.push(p); } catch (_) {} - } - out.sort(); - return out; -} - -function createHistoryWatcher(opts) { - const getDirs = (opts && opts.getDirs) || defaultDirs; - const emitter = new EventEmitter(); - const offsets = new Map(); // file -> bytes already tailed - const watchers = []; // [{ poll, w }] - const metaCache = new Map(); // file -> { mtime, size, meta } - let debounce = null; - let started = false; - - function dirs() { try { return getDirs() || []; } catch (e) { console.error('[history] getDirs() failed:', (e && e.message) || e); return []; } } - - // A dir entry's Codex data tree: sibling `sessions/` next to its `projects/` — every work dir - // is probed for BOTH layouts (Claude Code writes `/projects/…`, Codex `/sessions/…`), - // so `~/.codex` is just another configured dir rather than a special case. - function sessionsDirOf(dm) { - const base = dm && (dm.configDir || (dm.projectsDir ? path.dirname(dm.projectsDir) : null)); - return base ? path.join(base, 'sessions') : null; - } - - function eachSessionFile(cb) { - for (const dm of dirs()) { - const root = dm && dm.projectsDir; - if (!root) continue; - let entries; - try { entries = fs.readdirSync(root, { withFileTypes: true }); } catch (_) { entries = []; } - for (const d of entries) { - if (!d.isDirectory()) continue; - const pdir = path.join(root, d.name); - let files; - try { files = fs.readdirSync(pdir, { withFileTypes: true }); } catch (_) { continue; } - for (const f of files) { - if (f.isFile() && f.name.endsWith('.jsonl')) { - cb(path.join(pdir, f.name), d.name, false, dm); - } else if (f.isDirectory() && f.name === 'subagents') { - const sdir = path.join(pdir, f.name); - let sfiles; - try { sfiles = fs.readdirSync(sdir); } catch (_) { continue; } - for (const sf of sfiles) if (sf.endsWith('.jsonl')) cb(path.join(sdir, sf), d.name, true, dm); - } - } - } - // Codex rollouts live in a date-sharded sessions/ tree, so it gets its own walk. - const sdir = sessionsDirOf(dm); - if (sdir) codex.walkSessions(sdir, (file) => cb(file, '', false, dm)); - } - } - - /* ---------- browse ---------- */ - function sessionMeta(file, dirName, isSub, dm) { - let st; - try { st = fs.statSync(file); } catch (_) { return null; } - let entry = metaCache.get(file); - if (!entry || entry.mtime !== st.mtimeMs || entry.size !== st.size) { - const head = readChunk(file, st.size, 131072); - const recs = parseLines(head); - // Codex rollouts (a dir's sessions/ tree, or snapshots imported into the app store) list - // through the codex shaper — the record format shares nothing with Claude's. - if (codex.looksCodex(recs)) { - entry = { mtime: st.mtimeMs, size: st.size, meta: codex.sessionMetaFrom(file, recs, dm, st) }; - metaCache.set(file, entry); - return entry.meta; - } - const metaRec = recs.find((r) => r.cwd) || recs.find((r) => r.sessionId) || {}; - const agentRec = recs.find((r) => r.agentId) || {}; - const msgs = recs.map(lineToMessage).filter(Boolean); - const cc = readCcbud(recs); - const autoTitle = firstUserText(msgs); - let model = null; - for (const r of recs) { if (r.type === 'assistant' && r.message && r.message.model) model = r.message.model; } - const subagent = isSub || !!agentRec.agentId; - const cwd = metaRec.cwd || decodeDirName(dirName); - const baseId = metaRec.sessionId || path.basename(file, '.jsonl'); - const sessId = subagent && agentRec.agentId ? `${baseId}-${agentRec.agentId}` : baseId; - entry = { - mtime: st.mtimeMs, - size: st.size, - meta: { - id: 'disk:' + path.basename(file, '.jsonl') + (subagent ? ':sub' : ''), - file, - source: 'disk', - dirId: dm ? dm.id : 'default', - dirLabel: dm ? dm.label : null, - sessionId: sessId, - cwd, - project: baseName(cwd), - gitBranch: metaRec.gitBranch || null, - title: cc.title || autoTitle, - autoTitle, - tags: cc.tags, - model, - isSubagent: subagent, - imported: !!(dm && dm.imported), - lastActivity: st.mtimeMs, - sizeKB: Math.round(st.size / 1024), - } - }; - metaCache.set(file, entry); - } - return entry.meta; - } - - function listSessions(activeId, limit) { - const files = []; - const liveFiles = new Set(); - eachSessionFile((file, dirName, isSub, dm) => { - liveFiles.add(file); - if (activeId && activeId !== 'all' && dm && dm.id !== activeId) return; - let st; try { st = fs.statSync(file); } catch (_) { return; } - files.push({ file, dirName, isSub, dm, mtime: st.mtimeMs }); - }); - for (const f of [...metaCache.keys()]) { - if (!liveFiles.has(f)) metaCache.delete(f); - } - files.sort((a, b) => b.mtime - a.mtime || String(b.file).localeCompare(String(a.file))); - // Materialize before applying the limit: true duplicate Codex rollout paths must not consume - // slots and evict distinct threads. `session_id` is intentionally NOT part of this key because - // root + subagents share it; the canonical first SessionMeta.id is exposed as threadId. - const sessions = files.map((s) => sessionMeta(s.file, s.dirName, s.isSub, s.dm)).filter(Boolean); - return limitWithCodexAncestors(dedupeCanonicalCodexSessions(sessions), limit || 400); - } - - function dedupeCanonicalCodexSessions(sessions) { - const out = []; - const positions = new Map(); - const canonicalFile = (session) => { - const threadId = String(session.threadId || ''); - const stem = path.basename(String(session.file || ''), '.jsonl'); - return !!threadId && (stem === threadId || stem.endsWith('-' + threadId)); - }; - const prefer = (candidate, current) => { - // Keep separate configured roots/import stores independent even when they contain the same - // logical transcript. Without Codex's SQLite index in this legacy backend, UpdatedAt - // (rollout mtime) is the closest official fallback; filename/size/path only break ties. - const activityDelta = (candidate.lastActivity || 0) - (current.lastActivity || 0); - if (activityDelta) return activityDelta > 0; - const createdDelta = (candidate.createdAt || 0) - (current.createdAt || 0); - if (createdDelta) return createdDelta > 0; - const canonicalDelta = Number(canonicalFile(candidate)) - Number(canonicalFile(current)); - if (canonicalDelta) return canonicalDelta > 0; - const sizeDelta = (candidate.sizeKB || 0) - (current.sizeKB || 0); - if (sizeDelta) return sizeDelta > 0; - return String(candidate.file || '') > String(current.file || ''); - }; - (sessions || []).forEach((session) => { - if (!session || session.source !== 'codex' || !session.canonicalThreadIdValid || !session.threadId) { - out.push(session); - return; - } - const key = `${session.dirId || ''}\u0000${session.threadId}`; - const existing = positions.get(key); - if (existing == null) { - positions.set(key, out.length); - out.push(session); - } else if (prefer(session, out[existing])) { - out[existing] = session; - } - }); - return out.sort((a, b) => (b.lastActivity || 0) - (a.lastActivity || 0) - || String(b.threadId || b.id || '').localeCompare(String(a.threadId || a.id || ''))); - } - - // The limit is a soft cap for Codex trees: when a recent child makes the cut, include its parent - // chain (and root) even if those older rollout files rank below the cap. This prevents an orphan - // child from appearing without the conversation that spawned it. - function limitWithCodexAncestors(sessions, limit) { - if (sessions.length <= limit) return sessions; - const positions = new Map(); - sessions.forEach((session, index) => { - if (session && session.source === 'codex' && session.canonicalThreadIdValid && session.threadId) { - positions.set(`${session.dirId || ''}\u0000${session.threadId}`, index); - } - }); - const included = new Set(sessions.slice(0, limit).map((_, index) => index)); - const queue = [...included]; - for (let cursor = 0; cursor < queue.length; cursor++) { - const session = sessions[queue[cursor]]; - if (!session || session.source !== 'codex' || !session.canonicalThreadIdValid) continue; - const parentIds = [session.parentThreadId, session.isSubagent ? session.rootSessionId : null] - .filter((id, index, ids) => id != null && ids.indexOf(id) === index); - let parent = null; - for (const parentId of parentIds) { - const found = positions.get(`${session.dirId || ''}\u0000${parentId}`); - if (found != null) { parent = found; break; } - } - if (parent != null && !included.has(parent)) { included.add(parent); queue.push(parent); } - } - return sessions.filter((_, index) => included.has(index)); - } - - function listProjects(activeId, limit) { - const sessions = listSessions(activeId, limit || 600); - const groups = new Map(); - for (const s of sessions) { - const key = s.cwd || '(unknown)'; - if (!groups.has(key)) groups.set(key, { cwd: s.cwd, name: s.project || baseName(key) || '', sessions: [], lastActivity: 0 }); - const g = groups.get(key); - g.sessions.push(s); - if (s.lastActivity > g.lastActivity) g.lastActivity = s.lastActivity; - } - const arr = [...groups.values()]; - arr.forEach((g) => g.sessions.sort((a, b) => b.lastActivity - a.lastActivity)); - arr.sort((a, b) => b.lastActivity - a.lastActivity); - return arr; - } - - /** Per-directory session counts (for the settings list + directory switcher). */ - function dirStats() { - const counts = {}; - // Count the same canonical rows the sidebar can show, not raw rollout files. Distinct Codex - // subagents still count; only true same-thread physical duplicates are collapsed. - for (const session of listSessions('all', Number.MAX_SAFE_INTEGER)) { - if (!session || session.deleted) continue; - const id = session.dirId || 'default'; - counts[id] = (counts[id] || 0) + 1; - } - return dirs().map((dm) => { - // A dir "exists" when EITHER data tree is on disk — ~/.codex has only sessions/. - let exists = false; - try { exists = fs.statSync(dm.projectsDir).isDirectory(); } catch (_) {} - if (!exists) { - const sdir = sessionsDirOf(dm); - try { exists = !!sdir && fs.statSync(sdir).isDirectory(); } catch (_) {} - } - return { id: dm.id, label: dm.label, projectsDir: dm.projectsDir, sessions: counts[dm.id] || 0, exists, imported: !!dm.imported }; - }); - } - - function getSession(file) { - let raw; - try { raw = fs.readFileSync(file, 'utf8'); } catch (_) { return null; } - const recs = parseLines(raw); - if (codex.looksCodex(recs)) return codex.sessionFromRecs(file, recs); - const metaRec = recs.find((r) => r.cwd) || recs.find((r) => r.sessionId) || {}; - const agentRec = recs.find((r) => r.agentId) || {}; - const summaryRec = recs.find((r) => r.type === 'summary' && r.summary); - const cc = readCcbud(recs); - - const shaped = shapeMessages(recs); - const messages = shaped.messages; - const autoTitle = firstUserText(messages); - - const subagent = !!agentRec.agentId; - // Imported transcripts carry a sidecar recording where they came from (see main.importOne). - let imported = null; - try { imported = JSON.parse(fs.readFileSync(file.replace(/\.jsonl$/, '.import.json'), 'utf8')); } catch (_) {} - // Only a top-level session embeds its child subagent dialogues (a subagent file has no nested - // subagents/ dir of its own), so the renderer can nest them under their spawning Task call. - const subagents = subagent ? {} : readSubagents(file); - applySkillNames(messages, subagents); - const cwd = metaRec.cwd || null; - const baseId = metaRec.sessionId || path.basename(file, '.jsonl'); - const sessId = subagent && agentRec.agentId ? `${baseId}-${agentRec.agentId}` : baseId; - return { - meta: { - id: 'disk:' + path.basename(file, '.jsonl') + (subagent ? ':sub' : ''), - file, - source: 'disk', - title: cc.title || autoTitle, - autoTitle, - tags: cc.tags, - summary: summaryRec ? summaryRec.summary : null, - sessionId: sessId, - cwd, - project: baseName(cwd), - gitBranch: metaRec.gitBranch || null, - version: metaRec.version || null, - isSubagent: subagent, - skill: subagent ? skillFromRecs(recs) : null, // a standalone subagent transcript self-reports via the sentinel - imported: !!imported, - importedFrom: imported ? imported.originalPath : null, - importedAt: imported ? imported.importedAt : null, - model: shaped.model, - totals: shaped.totals, - messages: messages.length, - subagentCount: Object.keys(subagents).length, - firstTs: shaped.firstTs, - lastTs: shaped.lastTs, - }, - messages, - subagents, - }; - } - - // Write per-conversation customization (custom title + tags) onto the FIRST parseable line of a - // session file as a `__ccbud__` field. patch: { title?, tags? } — empty title / empty tags removes - // that key (empty __ccbud__ is dropped entirely). Atomic (tmp + rename, mirrors store.js). Guarded - // to the configured projects dirs so a renderer can never drive an arbitrary-path write. - function setCcbud(file, patch) { - patch = patch || {}; - // Renderer-driven writes are confined to the configured work dirs' data trees (projects/ - // AND sessions/) plus the imports store. - const resolved = path.resolve(file); - const within = dirs().some((dm) => { - if (!dm) return false; - const roots = [dm.projectsDir, sessionsDirOf(dm)].filter(Boolean); - return roots.some((r) => resolved.startsWith(path.resolve(r) + path.sep)); - }); - if (!within) return { ok: false, reason: 'out-of-scope' }; - let raw; - try { raw = fs.readFileSync(file, 'utf8'); } catch (_) { return { ok: false, reason: 'read' }; } - // Live Codex rollouts are another tool's files — their title/tags live in the app-owned - // sidecar instead of being written into the rollout. Imported codex COPIES sit inside our - // store (marked by .import.json) and take the normal in-file path below. - const head = []; - for (const line of raw.split('\n')) { - const s = line.trim(); if (!s) continue; - try { head.push(JSON.parse(s)); } catch (_) {} - if (head.length >= 8) break; - } - if (codex.looksCodex(head) && !codex.hasImportSidecar(file)) { - const r = codex.setMeta(file, patch); - if (r && r.ok) metaCache.delete(file); // sidecar edits don't bump the file's mtime - return r; - } - const lines = raw.split('\n'); - let idx = -1, obj = null; - for (let i = 0; i < lines.length; i++) { - const s = lines[i].trim(); if (!s) continue; - try { obj = JSON.parse(s); idx = i; break; } catch (_) {} - } - if (idx < 0 || !obj || typeof obj !== 'object') return { ok: false, reason: 'empty' }; - const next = Object.assign({}, obj.__ccbud__ || {}); - if ('title' in patch) { const t = (patch.title || '').trim(); if (t) next.title = t; else delete next.title; } - if ('tags' in patch) { - const arr = []; - for (const x of (patch.tags || [])) { const t = typeof x === 'string' ? x.trim() : ''; if (t && arr.indexOf(t) < 0) arr.push(t); } - if (arr.length) next.tagList = arr; else delete next.tagList; - } - if (Object.keys(next).length) obj.__ccbud__ = next; else delete obj.__ccbud__; - lines[idx] = JSON.stringify(obj); - const out = lines.join('\n'); - const tmp = file + '.ccbud.tmp'; - try { fs.writeFileSync(tmp, out, 'utf8'); fs.renameSync(tmp, file); } - catch (e) { try { fs.unlinkSync(tmp); } catch (_) {} return { ok: false, reason: 'write' }; } - metaCache.delete(file); // next list re-reads the new title/tags - try { offsets.set(file, Buffer.byteLength(out)); } catch (_) {} // don't let the watcher replay the rewrite as new records - return { ok: true }; - } - - /* ---------- watch / live tail ---------- */ - function tailNew() { - const changed = []; - eachSessionFile((file) => { - let st; - try { st = fs.statSync(file); } catch (_) { return; } - const prev = offsets.get(file); - if (prev === undefined) { - offsets.set(file, st.size); - if (started) changed.push(file); - return; - } - if (st.size <= prev) { offsets.set(file, st.size); if (st.size < prev) changed.push(file); return; } - let chunk = ''; - try { - const fd = fs.openSync(file, 'r'); - const len = st.size - prev; - const b = Buffer.alloc(len); - fs.readSync(fd, b, 0, len, prev); - fs.closeSync(fd); - chunk = b.toString('utf8'); - } catch (_) { offsets.set(file, st.size); return; } - offsets.set(file, st.size); - for (const rec of parseLines(chunk)) { - if (rec.type === 'assistant' && rec.message && rec.message.id) { - const sid = rec.sessionId || null; - const sessId = sid && rec.agentId ? `${sid}-${rec.agentId}` : sid; - emitter.emit('correlate', { messageId: rec.message.id, sessionId: sessId, cwd: rec.cwd, gitBranch: rec.gitBranch }); - } - emitter.emit('record', { file, rec }); - } - changed.push(file); - }); - if (changed.length) emitter.emit('changed', { files: changed }); - } - - function watchDir(root) { - try { - const w = fs.watch(root, { recursive: true }, () => { clearTimeout(debounce); debounce = setTimeout(tailNew, 250); }); - return { poll: false, w }; - } catch (_) { - const iv = setInterval(tailNew, 2000); - if (iv.unref) iv.unref(); - return { poll: true, w: iv }; - } - } - function clearWatchers() { - for (const x of watchers) { try { if (x.poll) clearInterval(x.w); else x.w.close(); } catch (_) {} } - watchers.length = 0; - } - function primeOffsets() { - const live = new Set(); - eachSessionFile((file) => { live.add(file); if (!offsets.has(file)) { try { offsets.set(file, fs.statSync(file).size); } catch (_) {} } }); - // drop offsets for files whose directory was removed, so a later re-add re-primes cleanly - for (const f of [...offsets.keys()]) if (!live.has(f)) offsets.delete(f); - } - function syncWatches() { - clearWatchers(); - for (const dm of dirs()) { - // Watch both data trees of every work dir (projects/ = Claude, sessions/ = Codex). - for (const root of [dm.projectsDir, sessionsDirOf(dm)]) { - if (!root) continue; - let exists = false; - try { exists = fs.statSync(root).isDirectory(); } catch (_) {} - if (exists) watchers.push(watchDir(root)); - } - } - primeOffsets(); - } - - function start() { - if (started) return; - started = true; - syncWatches(); - } - function stop() { - started = false; - clearTimeout(debounce); - debounce = null; - clearWatchers(); - } - /** Re-establish watches after the configured directory list changes. */ - function refresh() { if (started) syncWatches(); } - - return { - on: emitter.on.bind(emitter), - off: emitter.off.bind(emitter), - start, - stop, - refresh, - tailNew, - listSessions, - listProjects, - dirStats, - getSession, - setCcbud, - }; -} - -module.exports = { createHistoryWatcher, lineToMessage, firstUserText, readCcbud, decodeDirName, defaultDirs, subagentDir, readSubagentFiles, subagentTranscriptPaths, skillFromRecs, applySkillNames }; diff --git a/src/main/hotpaths.js b/src/main/hotpaths.js deleted file mode 100644 index 3f4c618..0000000 --- a/src/main/hotpaths.js +++ /dev/null @@ -1,65 +0,0 @@ -'use strict'; - -/* - * Shared on-disk layout + state for the hot-update system. Used by BOTH the bootstrap - * loader (src/main/bootstrap.js — runs before the real app) and the updater (updater.js). - * Keeping the schema in one tiny, dependency-free module is what lets the staged bundle's - * copy of these files agree byte-for-byte with the packaged shell's copy. - * - * Layout (under /hot): - * hot/state.json — pointer + rollback bookkeeping (see schema below) - * hot// — an extracted hot bundle; contains src/main/main.js, etc. - * - * state.json schema: - * { - * active: { version, dir } | null, // the live hot bundle (dir is relative to hot/) - * pending: { version, dir } | null, // staged, promoted to active on next launch - * previous: { version, dir } | null, // last known-good active, for rollback - * trying: string | null // version we promoted but haven't confirmed booted ok - * } - */ - -const fs = require('fs'); -const path = require('path'); - -function hotRoot(userData) { - return path.join(userData, 'hot'); -} -function stateFile(userData) { - return path.join(hotRoot(userData), 'state.json'); -} -function bundleDir(userData, dirName) { - return path.join(hotRoot(userData), dirName); -} -// The entry the bootstrap requires for a given bundle root (packaged root OR a hot bundle dir). -function mainEntry(root) { - return path.join(root, 'src', 'main', 'main.js'); -} - -function readState(userData) { - try { - const raw = fs.readFileSync(stateFile(userData), 'utf8'); - const s = JSON.parse(raw); - if (s && typeof s === 'object') { - return { - active: s.active || null, - pending: s.pending || null, - previous: s.previous || null, - trying: typeof s.trying === 'string' ? s.trying : null, - }; - } - } catch (_) {} - return { active: null, pending: null, previous: null, trying: null }; -} - -function writeState(userData, state) { - const dir = hotRoot(userData); - try { fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); } catch (_) {} - const file = stateFile(userData); - const tmp = file + '.tmp'; - fs.writeFileSync(tmp, JSON.stringify(state, null, 2), { mode: 0o600 }); - fs.renameSync(tmp, file); - return state; -} - -module.exports = { hotRoot, stateFile, bundleDir, mainEntry, readState, writeState }; diff --git a/src/main/icon.png b/src/main/icon.png deleted file mode 100644 index 93058b74886056e55cc8030918a9b66f9350684f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 879934 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
LKxLSyb?hfAo5;P84=UKf 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�L(E&yDlzP$e?kAK313f0PI^_@chpj0 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

    -~d6|naAS1B2+%u)2%^vjHqk8DB-!gW)Y<%cHq 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�zCfpF5>^kT%d%L5>c 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