From beb6ec857ba434edda63f258afa1bf750b024d20 Mon Sep 17 00:00:00 2001 From: Markus Hartung Date: Tue, 18 Aug 2026 16:32:33 +0200 Subject: [PATCH] =?UTF-8?q?feat(dashboard):=20agentic=20conversation=20tra?= =?UTF-8?q?cking=20=E2=80=94=20v4,=20decoupled=20+=20storage-architecture?= =?UTF-8?q?=20concern=20resolved=20(#10263)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(responses): virtualize previous_response_id continuation regardless of upstream support OmniRoute now exposes OpenAI-compatible previous_response_id/store continuation to clients unconditionally, even when the selected upstream provider has no native Responses-API state support. Reconstruction happens server-side in handleChatImplementation, before any downstream validation or provider translation: OmniRoute resolves the response id back to the full input/output it previously produced, prepends it to the client's delta, and forwards the full reconstructed history upstream exactly as it does today. Client<->OmniRoute traffic shrinks to the new delta only; OmniRoute<->provider traffic is unchanged. Storage reuses the existing call-log pipeline artifact (already gated by call_log_pipeline_enabled, already retained/cleaned up by the existing call-log lifecycle) instead of duplicating conversation content into a second store -- only a lightweight call_logs.response_id index is new. Every lookup is scoped by api_key_id so one client can never resolve another client's stored conversation, and any unresolvable/missing/ size-limit-omitted state fails closed with OpenAI's own previous_response_not_found contract. Stacked on feat/openai-responses-store-toggle (#10121). * feat(dashboard): agentic conversation tracking with live transcript view Every agentic chat request now gets a conversation id (X-ConversationId response header). OmniRoute detects when a follow-up request continues the same conversation via fingerprint + bounded prefix-hash matching, with a strict-growth invariant to prevent false merges between independent single-shot requests that happen to share identical opening content. Continuation detection excludes the system message from the identity anchor, since real coding-agent CLIs commonly regenerate it every request with live context (timestamp, cwd, git status) — without this, that volatility alone broke every continuation check against real traffic. - `/dashboard/logs`: new toggleable Conversation column. - `/dashboard/logs/timeline`: requests sharing a conversation id share a timeline lane, connected by an arrow, with a configurable lane-reuse window. - Request detail panel: new Full Conversation transcript above the raw SSE event stream — Markdown rendering, per-turn timestamps, turn-relative view, click-any-turn navigation, live auto-refresh building the transcript in real time from the in-flight SSE chunk buffer while a request is still streaming, auto-scroll-to-bottom as the live turn grows. - New `/dashboard/conversations` page listing conversations with 2+ turns, no-forking model (an edited/duplicated mid-history turn mints its own independent conversation instead of merging), pagination, duplicate- anchor fix. - Configurable auto-refresh intervals on both the timeline and conversations list pages. - Responses API tool-call gap fix: turnsFromOpenAiMessages only handled role-based Chat Completions messages, so bare {type:"function_call"} / {type:"function_call_output"} / {type:"reasoning"} items (real Responses API traffic) silently vanished from the Conversation Context panel. - truncateForLog now counts input[] (Responses API), not just messages[] (Chat Completions), so a truncated /v1/responses request still shows a placeholder instead of nothing. - RequestTimeline.tsx now reads the same debugEnabled/emailsVisible settings RequestLoggerV2.tsx already used, instead of hardcoding both false — the timeline view never showed SSE/stream-chunk events or respected email-masking, regardless of the actual setting. Migrations 147/148 (agentic_conversations, conversation_turn_nodes) — 135 and 136 are now taken upstream; 143-145 are documented KNOWN_GAPS, so this uses the next free slot past upstream's current highest. Test plan: - npm run typecheck:core — clean - npm run lint — clean - node --import tsx/esm scripts/check/check-migration-numbering.mjs — OK, 0 collisions - 109 unit tests across the conversation-tracking, migration-renumber, and dashboard-wiring surface — 0 failures * refactor(dashboard): reuse call-log artifacts for conversation transcript content conversation_turn_nodes no longer stores turn text/tool-call content (text_preview/block_kind/tool_name) -- it's identity-only now (id/parent/ content_hash), matching agentic_conversations' existing lightweight-index shape. Every node's originating request is already fully captured by the call-log pipeline artifact its last_correlation_id points at, so the /dashboard/conversations tree view resolves each node's actual display content on demand from there (open-sse/services/conversationTurnContent.ts), re-running the same extractCanonicalTurns/hashTurnContent the write path used and matching by content_hash, instead of duplicating conversation content into a second store under a separate retention/gating policy. This also drops the old 8000-char text_preview truncation entirely -- resolved content is always full and untruncated. The frontend contract is unchanged (tree API still returns {textPreview, blockKind, toolName} per node), so the dashboard UI itself (page.tsx, RequestLoggerDetail/RequestTimeline, sidebar, i18n) needed no changes. Renumbered the cherry-picked 147/148 migrations to 153/154 -- 147 now collides with 147_api_keys_model_access_mode.sql, which landed on release/v3.8.50 after this work was originally built. Also includes a standalone, unrelated fix carried along from this rebase: close isProviderModelHidden's missing function-body brace in modelSelectModalHelpers.ts (separately landed as #10206). Stacked on feat/responses-previous-response-id-virtualization (#3), which is itself stacked on feat/openai-responses-store-toggle (#10121). * fix(dashboard): resync conversation list on open so the live-text poll starts immediately openConversation() seeded activeConversation (and therefore activeCallLogId, which gates the live-partial-text poll effect) from whatever row snapshot the list's own fixed-interval poll last produced. A conversation opened right after a reply started streaming -- after that tick, before the next -- had activeCallLogId still null, so the live-text poll never started; only a subsequent background list-poll resync (already existed) picked it up, which is why closing and reopening the same conversation "just worked". loadConversations() is now a shared callback so openConversation can force one immediately on open instead of waiting on pollSeconds. Live-verified against omniroute-dev: opening a conversation mid-stream now shows live reasoning on the first open. * style: prettier formatting for conversationTurnContent.test.ts * fix(db): close migration numbering gap left by decoupling from #3/#10262 153/154 (originally 154/155) were chosen back when this branch stacked on top of the previous_response_id migration (153_call_logs_response_id.sql). Decoupling removed that migration from this branch's history, leaving an unused 153 slot that check-migration-numbering.test.ts correctly flags as a gap. * refactor(dashboard): split RequestTimeline/RequestLoggerDetail under the 1000-line file-size cap Both files exceeded check-file-size's new-file cap after this PR's own additions (RequestTimeline 1048, RequestLoggerDetail 1163). Extracted pure non-component logic (types, constants, allocateLanes and its helpers) out of RequestTimeline.tsx into RequestTimeline.utils.ts, and the two self-contained presentational sub-components (PayloadSection, ConversationContextSection + its private helper) out of RequestLoggerDetail.tsx into RequestLoggerDetail.sections.tsx. No behavior change; existing external imports (default exports, allocateLanes, TimelineLog, CONVERSATION_LANE_REUSE_STORAGE_KEY) still resolve from the original file paths. * fix(db): renumber agentic-conversation migrations to clear 153 collision + sync migration-count docs The refresh-merge of release/v3.8.50 exposed that the feature's three migrations collided at slot 153 with the base's radar_local_model_state (153) and its own call_logs_response_id. Migration runner enforces unique numeric prefixes -> every DB init threw, red-ing Vitest, all Unit shards and the DB-backed quality gates. Renumber the feature's pair to 155_agentic_conversations / 156_conversation_turn_nodes and move call_logs_response_id to 154 (keeps 153_radar base-owned, preserves agentic-before-turn_nodes ordering). Update SQL headers and the 154/156 references in feature code + tests. Migration count is now 151 (was 148 stale in README/AGENTS/llm.txt) — sync the doc counts to clear the docs-accuracy gate. Co-authored-by: diegosouzapw * fix(ui): drop unused CONVERSATION_LANE_REUSE_STORAGE_KEY re-export from RequestTimeline Knip 6.32 (baseline 415) flags the public re-export of CONVERSATION_LANE_REUSE_STORAGE_KEY from RequestTimeline.tsx as dead: no external consumer imports it through that re-export (it is imported and used directly from RequestTimeline.utils.ts inside the component). Removed the unused re-export; the internal import stays. DEAD_TOTAL 416 -> 415, back to the frozen baseline. Co-authored-by: diegosouzapw * fix(agentic-conversations): guard resolveConversationId, drop dead whole-chain export - Wrap resolveConversationId() in try/catch in chat.ts, matching the defensive pattern used by every other best-effort side call nearby, so a DB hiccup in conversation tracking can't turn a working chat request into a hard failure. - Remove getConversationTurnTree: knip's project scope excludes tests/**, so an export used only by tests can never register as used there. Swap its 8 test call sites to the paginated getConversationTurnPage (already the dashboard's canonical query) with a generous limit, collapsing to one query path instead of keeping a second whole-chain export alive solely for test convenience. - Regenerate i18n llm.txt mirrors from root (pre-existing drift on this branch, unrelated to the above, caught by the docs-sync pre-commit gate). Addresses PR review feedback. * fix(i18n): close requestLogger conversation-column gap, fix domain-modules count drift - fr.json, vi.json were missing requestLogger.columns.conversation (added in the conversation-tracking feature), failing i18n-vi-completeness.test.ts. - docs/i18n/*/llm.txt mirrors still said 117 domain-specific files after an earlier rebase fixed the migration count but missed this companion number, failing check-docs-sync.mjs across all 42 locales. Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * fix(docs): restore PROXY_LOG_INCLUDE_IPS env/doc entries (env-doc-sync red) .env.example and docs/reference/ENVIRONMENT.md were both missing the PROXY_LOG_INCLUDE_IPS entry that src/lib/proxyLogger.ts already reads (confirmed present at this branch's merge-base too, so this predates the conversation-tracking work and is unrelated to it) -- the entry was added on release/v3.8.50 after this branch's last sync and this branch never picked it up. That gap red-lines tests/unit/check-env-doc-sync.test.ts and tests/unit/issue-7793-env-doc-sync-repro.test.ts (Unit Tests fast-path 2/4 in CI). Restore both entries verbatim from the current release/v3.8.50 tip -- no feature-code change. Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> --------- Co-authored-by: hartmark Co-authored-by: diegosouzapw Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> --- AGENTS.md | 2 +- README.md | 2 +- config/quality/quality-baseline.json | 5 +- docs/i18n/ar/llm.txt | 4 +- docs/i18n/az/llm.txt | 4 +- docs/i18n/bg/llm.txt | 4 +- docs/i18n/bn/llm.txt | 4 +- docs/i18n/cs/llm.txt | 4 +- docs/i18n/da/llm.txt | 4 +- docs/i18n/de/llm.txt | 4 +- docs/i18n/es/llm.txt | 4 +- docs/i18n/fa/llm.txt | 4 +- docs/i18n/fi/llm.txt | 4 +- docs/i18n/fr/llm.txt | 4 +- docs/i18n/gu/llm.txt | 4 +- docs/i18n/he/llm.txt | 4 +- docs/i18n/hi/llm.txt | 4 +- docs/i18n/hu/llm.txt | 4 +- docs/i18n/id/llm.txt | 4 +- docs/i18n/in/llm.txt | 4 +- docs/i18n/it/llm.txt | 4 +- docs/i18n/ja/llm.txt | 4 +- docs/i18n/ko/llm.txt | 4 +- docs/i18n/mr/llm.txt | 4 +- docs/i18n/ms/llm.txt | 4 +- docs/i18n/nl/llm.txt | 4 +- docs/i18n/no/llm.txt | 4 +- docs/i18n/phi/llm.txt | 4 +- docs/i18n/pl/llm.txt | 4 +- docs/i18n/pt-BR/llm.txt | 4 +- docs/i18n/pt/llm.txt | 4 +- docs/i18n/ro/llm.txt | 4 +- docs/i18n/ru/llm.txt | 4 +- docs/i18n/sk/llm.txt | 4 +- docs/i18n/sv/llm.txt | 4 +- docs/i18n/sw/llm.txt | 4 +- docs/i18n/ta/llm.txt | 4 +- docs/i18n/te/llm.txt | 4 +- docs/i18n/th/llm.txt | 4 +- docs/i18n/tr/llm.txt | 4 +- docs/i18n/uk-UA/llm.txt | 4 +- docs/i18n/ur/llm.txt | 4 +- docs/i18n/vi/llm.txt | 4 +- docs/i18n/zh-CN/llm.txt | 4 +- docs/i18n/zh-TW/llm.txt | 4 +- llm.txt | 4 +- open-sse/handlers/chatCore.ts | 8 +- open-sse/services/conversationTracker.ts | 482 +++++++++ open-sse/services/conversationTurnContent.ts | 82 ++ .../dashboard/conversations/page.tsx | 954 ++++++++++++++++++ .../playground/components/MarkdownMessage.tsx | 6 +- .../components/chat/ChatBubble.tsx | 30 +- .../components/chat/MessageContent.tsx | 18 +- .../components/shared/JsonViewer.tsx | 5 +- src/app/api/conversations/[id]/tree/route.ts | 75 ++ src/app/api/conversations/route.ts | 47 + src/app/api/logs/[id]/route.ts | 57 ++ src/i18n/messages/ar.json | 7 +- src/i18n/messages/az.json | 7 +- src/i18n/messages/bg.json | 7 +- src/i18n/messages/bn.json | 7 +- src/i18n/messages/cs.json | 7 +- src/i18n/messages/da.json | 7 +- src/i18n/messages/de.json | 7 +- src/i18n/messages/en.json | 5 +- src/i18n/messages/es.json | 7 +- src/i18n/messages/fa.json | 7 +- src/i18n/messages/fi.json | 7 +- src/i18n/messages/fr.json | 5 +- src/i18n/messages/gu.json | 7 +- src/i18n/messages/he.json | 7 +- src/i18n/messages/hi.json | 7 +- src/i18n/messages/hu.json | 7 +- src/i18n/messages/id.json | 7 +- src/i18n/messages/in.json | 7 +- src/i18n/messages/it.json | 7 +- src/i18n/messages/ja.json | 7 +- src/i18n/messages/ko.json | 7 +- src/i18n/messages/mr.json | 7 +- src/i18n/messages/ms.json | 7 +- src/i18n/messages/nl.json | 7 +- src/i18n/messages/no.json | 7 +- src/i18n/messages/phi.json | 7 +- src/i18n/messages/pl.json | 7 +- src/i18n/messages/pt-BR.json | 5 +- src/i18n/messages/pt.json | 7 +- src/i18n/messages/ro.json | 7 +- src/i18n/messages/ru.json | 7 +- src/i18n/messages/sk.json | 7 +- src/i18n/messages/sv.json | 7 +- src/i18n/messages/sw.json | 7 +- src/i18n/messages/ta.json | 7 +- src/i18n/messages/te.json | 7 +- src/i18n/messages/th.json | 7 +- src/i18n/messages/tr.json | 7 +- src/i18n/messages/uk-UA.json | 7 +- src/i18n/messages/ur.json | 7 +- src/i18n/messages/vi.json | 5 +- src/i18n/messages/zh-CN.json | 7 +- src/i18n/messages/zh-TW.json | 7 +- src/lib/db/agenticConversations.ts | 432 ++++++++ .../migrations/155_agentic_conversations.sql | 26 + .../156_conversation_turn_nodes.sql | 56 + src/lib/db/responsesContinuationStore.ts | 2 +- src/lib/localDb.ts | 1 + src/lib/usage/usageHistory.ts | 47 +- src/mitm/inspector/conversationNormalizer.ts | 79 +- src/mitm/inspector/types.ts | 29 +- .../RequestLoggerDetail.sections.tsx | 242 +++++ src/shared/components/RequestLoggerDetail.tsx | 112 +- src/shared/components/RequestLoggerV2.tsx | 64 +- src/shared/components/RequestTimeline.tsx | 377 ++++--- .../components/RequestTimeline.utils.ts | 169 ++++ .../constants/sidebarVisibility/sections.ts | 7 + .../constants/sidebarVisibility/types.ts | 1 + src/sse/handlers/chat.ts | 46 +- src/sse/handlers/chatHelpers.ts | 19 + src/sse/handlers/rejectedRequestUsage.ts | 4 + tests/unit/agenticConversations.test.ts | 299 ++++++ tests/unit/chatcore-log-truncation.test.ts | 10 +- tests/unit/conversationTracker.test.ts | 640 ++++++++++++ tests/unit/conversationTurnContent.test.ts | 141 +++ .../conversations-active-call-log-id.test.ts | 81 ++ ...conversations-tree-route-seq-param.test.ts | 30 + ...nection-modal-openai-store-toggle.test.tsx | 5 +- .../inspector-conversation-normalizer.test.ts | 77 +- ...tail-partial-reasoning-chunk-split.test.ts | 101 ++ .../request-timeline-lane-allocation.test.ts | 76 ++ tests/unit/sidebar-monitoring-reorg.test.ts | 10 +- tests/unit/sidebar-visibility.test.ts | 1 + 130 files changed, 4941 insertions(+), 481 deletions(-) create mode 100644 open-sse/services/conversationTracker.ts create mode 100644 open-sse/services/conversationTurnContent.ts create mode 100644 src/app/(dashboard)/dashboard/conversations/page.tsx create mode 100644 src/app/api/conversations/[id]/tree/route.ts create mode 100644 src/app/api/conversations/route.ts create mode 100644 src/lib/db/agenticConversations.ts create mode 100644 src/lib/db/migrations/155_agentic_conversations.sql create mode 100644 src/lib/db/migrations/156_conversation_turn_nodes.sql create mode 100644 src/shared/components/RequestLoggerDetail.sections.tsx create mode 100644 src/shared/components/RequestTimeline.utils.ts create mode 100644 tests/unit/agenticConversations.test.ts create mode 100644 tests/unit/conversationTracker.test.ts create mode 100644 tests/unit/conversationTurnContent.test.ts create mode 100644 tests/unit/conversations-active-call-log-id.test.ts create mode 100644 tests/unit/conversations-tree-route-seq-param.test.ts create mode 100644 tests/unit/logs-detail-partial-reasoning-chunk-split.test.ts create mode 100644 tests/unit/request-timeline-lane-allocation.test.ts diff --git a/AGENTS.md b/AGENTS.md index 3dbde5ae31..c22f28dbd9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -56,7 +56,7 @@ Repository map and Reference Documentation sections below. | Translators | `open-sse/translator/` | Format conversion (OpenAI↔Claude↔Gemini) | | Transformer | `open-sse/transformer/` | Responses API ↔ Chat Completions | | Services | `open-sse/services/` | Combo routing, rate limits, caching, etc | -| Database | `src/lib/db/` | SQLite domain modules (151 migrations) | +| Database | `src/lib/db/` | SQLite domain modules (153 migrations) | | Domain/Policy | `src/domain/` | Policy engine, cost rules, fallback logic | | MCP Server | `open-sse/mcp-server/` | 109 tools (44 canonical + memory/skill/GitHub/pool/gamification/plugin/Notion/Obsidian/local-corpus/RTK modules), 3 transports (stdio / SSE / Streamable HTTP), 33 scopes | | A2A Server | `src/lib/a2a/` | JSON-RPC 2.0 agent protocol | diff --git a/README.md b/README.md index fae2dce697..d5f95113f6 100644 --- a/README.md +++ b/README.md @@ -1150,7 +1150,7 @@ Métricas de validação: 1002 vídeos rastreados · 7,069,190 visualizações c RuntimeNode.js 22.x / 24.x LTS — >=22.22.2 <23 || >=24.0.0 <27 LanguageTypeScript 6.0 — 100% TypeScript across src/ and open-sse/ (zero any in core since v2.0) FrameworkNext.js 16 + React 19 + Tailwind CSS 4 - Databasebetter-sqlite3 (SQLite, WAL journaling) + LowDB (JSON legacy) — 117 domain modules, 151 migrations + Databasebetter-sqlite3 (SQLite, WAL journaling) + LowDB (JSON legacy) — 120 domain modules, 153 migrations MemorySQLite FTS5 full-text + int8-quantized vector embeddings, typed decay SchemasZod 4 — MCP tool I/O validation + API contracts ProtocolsMCP (stdio / HTTP / SSE) + A2A v0.3 (JSON-RPC 2.0 + SSE) diff --git a/config/quality/quality-baseline.json b/config/quality/quality-baseline.json index a33109e099..c8cbd07d94 100644 --- a/config/quality/quality-baseline.json +++ b/config/quality/quality-baseline.json @@ -179,7 +179,7 @@ "_rebaseline_2026_07_28_ci_runner_delta": "189 -> 190 (+1). Medido 189 no devbox e 190 no runner do GitHub no MESMO commit (run 30396592013, job Quality Gates (Extended)) — mesma classe já registrada em _rebaseline_2026_07_20_aliasresolver_hook_split_7808: a versão do zizmor no runner enxerga uma finding a mais que a local, sempre da classe unpinned-uses @vN. O valor do runner é o que o gate compara, então a baseline segue o runner." }, "vulnCount": { - "value": 10, + "value": 22, "direction": "down", "dedicatedGate": true }, @@ -396,5 +396,6 @@ "_zizmor_rebaseline_2026_06_19_a11y_148_reconcile": "RECONCILIACAO CROSS-PR (release-volatil) ao mergear #4321 (a11y) APOS #4322 (R1): zizmorFindings 145 -> 148. O #4322 ja rebaselinou 139->145 (drift base 142 + 3 unpinned-uses do mutation-redundancy.yml). Este PR adiciona +3 unpinned-uses @vN do novo job 'a11y' (nightly-resilience.yml): actions/checkout@v7, actions/setup-node@v6, actions/cache@v5.0.5 — MESMA convencao @vN deliberada e INTOCADA de todos os workflows (ver _scanner_harden_workflows_2026_06_16). Total = 142 base + 3 r1 + 3 a11y = 148, MEDIDO com `node scripts/check/check-workflows.mjs --ratchet` na arvore release(com #4322)+#4321 = 148 exato. Nenhum template-injection/artipacked/cache-poisoning novo.", "_zizmor_rebaseline_2026_06_20_ci_build_artifact_reuse": "zizmorFindings 148 -> 152. Drift legitimo deste PR ao reutilizar o artefato next-build do job Build em package-artifact/electron-package-smoke e ao separar o build de compatibilidade Node 26: +4 unpinned-uses novos (2x actions/download-artifact@v8, actions/checkout@v7, actions/setup-node@v6). Mantida a convencao deliberada @vN dos workflows (sem SHA-pinning/manual update burden), conforme precedentes _scanner_harden_workflows_2026_06_16 e _zizmor_rebaseline_2026_06_19_*. Sem novos findings de template-injection/artipacked/cache-poisoning; medido localmente com zizmor 1.25.2 via `npm run check:workflows -- --ratchet` = 152.", "_cognitive_rebaseline_2026_07_27_3850_relax_v2_20pct": "cognitiveComplexity 971->1223 (+252, +26.0% over pristine 971). OWNER-APPROVED TEMPORARY relax for v3.8.50-3.8.54 PREPARE phase (docs/ROADMAP.md). v1 was +48 on 2026-07-27; v2 = v1 +20% buffer = +58 → +252 total (cycle 971 measured pristine → 1223 ceiling). Justification: same as complexity v2 — the v3.8.50 release cut coincides with high-merge activity; owner accepted enlarging the headroom to cover the entire PREPARE phase (5 minor cycles .50-.54) without per-PR rebaseline noise, given that re-tightening is mechanical at v3.8.51 via the combo.ts/chatCore.ts decomposition work scheduled in .51/.52 (ROADMAP.md). RE-TIGHTENING MANDATORY in v3.8.51: target 1009 (shrink of 214 from structural extraction during the decomposition campaigns, or via npm run quality:ratchet -- --update if natural shrink appears earlier). The 1009 floor still gives 38 units of post-tighten headroom vs the current pristine 971. Tracked via same roadmap issue as complexity v2. Window: v3.8.50 (release cut) → v3.8.54 close (RE-TIGHTEN at v3.8.51 prep merge per ROADMAP.md). Last entry unless measured regression. v1 entry retained below for audit trail.", - "_cognitive_rebaseline_2026_07_27_3850_relax": "cognitiveComplexity 971->1019 (+48). OWNER-APPROVED TEMPORARY relax for v3.8.50-3.8.54 PREPARE phase (docs/ROADMAP.md). +48 covers Train 1D (+15) + headroom for 3.8.50/.51 batches. RE-TIGHTENING MANDATORY in v3.8.51: target 1009 (from combo.ts/chatCore.ts decomposition scheduled in .51/.52 per ROADMAP.md phases). Tracked via same roadmap issue as complexity. SUPERSEDED by _cognitive_rebaseline_2026_07_27_3850_relax_v2_20pct (v1 +20% buffer) — retained for audit. Last entry unless measured regression." + "_cognitive_rebaseline_2026_07_27_3850_relax": "cognitiveComplexity 971->1019 (+48). OWNER-APPROVED TEMPORARY relax for v3.8.50-3.8.54 PREPARE phase (docs/ROADMAP.md). +48 covers Train 1D (+15) + headroom for 3.8.50/.51 batches. RE-TIGHTENING MANDATORY in v3.8.51: target 1009 (from combo.ts/chatCore.ts decomposition scheduled in .51/.52 per ROADMAP.md phases). Tracked via same roadmap issue as complexity. SUPERSEDED by _cognitive_rebaseline_2026_07_27_3850_relax_v2_20pct (v1 +20% buffer) — retained for audit. Last entry unless measured regression.", + "_vuln_rebaseline_2026_08_04_9439_cve_drift": "vulnCount 10->22 (HIGH=10, MODERATE=12, measured by osv-scanner v2.3.8 in PR #9439's own CI run). This is CVE variance, not a dependency change made by this PR: `git diff upstream/release/v3.8.50 HEAD -- package.json package-lock.json` is empty — neither file was touched anywhere in this branch's history. The osv-scanner vulnerability ratchet apparently does not run on every commit landed directly to release/v3.8.50 (same 'fast-gate PR->release skips this check' pattern already documented for check:file-size, e.g. _rebaseline_2026_07_01_v3843_release_5609), so newly-disclosed CVEs in already-present transitive dependencies accumulated on the release branch and only surfaced here because this PR's rebase onto the current release/v3.8.50 tip pulled them in. This exact scenario — 'a newly-disclosed CVE in an already-present dep can trip the gate with no dependency change on your part' — is the documented expected behavior in _osv_flip_blocking_2026_06_16_v3827 above, whose prescribed remedy is 'bump the dep, or re-baseline vulnCount with justification+issue' (docs/security/SUPPLY_CHAIN.md -> 'Variância de CVE'). osv-scanner is not available in this sandbox to enumerate the exact GHSA/CVE ids and safely bump only the affected transitive deps without a broader, separately-scoped dependency-audit pass; re-baselining here unblocks this PR without masking anything introduced by it. Tracked for follow-up: a dedicated dependency-bump PR should re-tighten vulnCount back down once the specific advisories are enumerated locally with osv-scanner installed." } diff --git a/docs/i18n/ar/llm.txt b/docs/i18n/ar/llm.txt index 286d23b64a..9485cc2edc 100644 --- a/docs/i18n/ar/llm.txt +++ b/docs/i18n/ar/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 151 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 153 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (117 domain-specific files, 151 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 153 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/az/llm.txt b/docs/i18n/az/llm.txt index 61ca9231c6..9d879fbe27 100644 --- a/docs/i18n/az/llm.txt +++ b/docs/i18n/az/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 151 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 153 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (117 domain-specific files, 151 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 153 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/bg/llm.txt b/docs/i18n/bg/llm.txt index 61ca9231c6..9d879fbe27 100644 --- a/docs/i18n/bg/llm.txt +++ b/docs/i18n/bg/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 151 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 153 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (117 domain-specific files, 151 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 153 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/bn/llm.txt b/docs/i18n/bn/llm.txt index 52e41ce7a0..57385efc73 100644 --- a/docs/i18n/bn/llm.txt +++ b/docs/i18n/bn/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 151 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 153 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (117 domain-specific files, 151 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 153 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/cs/llm.txt b/docs/i18n/cs/llm.txt index c778017118..1518e70342 100644 --- a/docs/i18n/cs/llm.txt +++ b/docs/i18n/cs/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 151 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 153 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (117 domain-specific files, 151 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 153 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/da/llm.txt b/docs/i18n/da/llm.txt index 365ef5b569..7d1f1ee0b7 100644 --- a/docs/i18n/da/llm.txt +++ b/docs/i18n/da/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 151 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 153 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (117 domain-specific files, 151 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 153 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/de/llm.txt b/docs/i18n/de/llm.txt index 7d8b3318af..db556b0cbe 100644 --- a/docs/i18n/de/llm.txt +++ b/docs/i18n/de/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 151 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 153 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (117 domain-specific files, 151 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 153 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/es/llm.txt b/docs/i18n/es/llm.txt index c6c71284c4..23fea2da1c 100644 --- a/docs/i18n/es/llm.txt +++ b/docs/i18n/es/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 151 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 153 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (117 domain-specific files, 151 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 153 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/fa/llm.txt b/docs/i18n/fa/llm.txt index ce4ae52b9b..96427aa846 100644 --- a/docs/i18n/fa/llm.txt +++ b/docs/i18n/fa/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 151 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 153 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (117 domain-specific files, 151 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 153 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/fi/llm.txt b/docs/i18n/fi/llm.txt index 51e034e14e..796379e087 100644 --- a/docs/i18n/fi/llm.txt +++ b/docs/i18n/fi/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 151 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 153 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (117 domain-specific files, 151 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 153 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/fr/llm.txt b/docs/i18n/fr/llm.txt index 35c464ae00..c0408640aa 100644 --- a/docs/i18n/fr/llm.txt +++ b/docs/i18n/fr/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 151 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 153 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (117 domain-specific files, 151 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 153 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/gu/llm.txt b/docs/i18n/gu/llm.txt index 4e7d002ea9..c33a384936 100644 --- a/docs/i18n/gu/llm.txt +++ b/docs/i18n/gu/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 151 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 153 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (117 domain-specific files, 151 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 153 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/he/llm.txt b/docs/i18n/he/llm.txt index 0d66d97a18..f49dd8c721 100644 --- a/docs/i18n/he/llm.txt +++ b/docs/i18n/he/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 151 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 153 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (117 domain-specific files, 151 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 153 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/hi/llm.txt b/docs/i18n/hi/llm.txt index 6b40b9ec19..e95d8688cc 100644 --- a/docs/i18n/hi/llm.txt +++ b/docs/i18n/hi/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 151 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 153 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (117 domain-specific files, 151 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 153 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/hu/llm.txt b/docs/i18n/hu/llm.txt index a60382620f..81b55a6aaa 100644 --- a/docs/i18n/hu/llm.txt +++ b/docs/i18n/hu/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 151 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 153 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (117 domain-specific files, 151 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 153 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/id/llm.txt b/docs/i18n/id/llm.txt index ce814c816a..c03b43615f 100644 --- a/docs/i18n/id/llm.txt +++ b/docs/i18n/id/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 151 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 153 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (117 domain-specific files, 151 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 153 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/in/llm.txt b/docs/i18n/in/llm.txt index 23d11b96e8..7d2a245d18 100644 --- a/docs/i18n/in/llm.txt +++ b/docs/i18n/in/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 151 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 153 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (117 domain-specific files, 151 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 153 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/it/llm.txt b/docs/i18n/it/llm.txt index a28c6138a9..e448146ad6 100644 --- a/docs/i18n/it/llm.txt +++ b/docs/i18n/it/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 151 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 153 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (117 domain-specific files, 151 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 153 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/ja/llm.txt b/docs/i18n/ja/llm.txt index 8972dca49c..cc29fdb105 100644 --- a/docs/i18n/ja/llm.txt +++ b/docs/i18n/ja/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 151 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 153 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (117 domain-specific files, 151 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 153 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/ko/llm.txt b/docs/i18n/ko/llm.txt index c029933a1a..84f63922bc 100644 --- a/docs/i18n/ko/llm.txt +++ b/docs/i18n/ko/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 151 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 153 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (117 domain-specific files, 151 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 153 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/mr/llm.txt b/docs/i18n/mr/llm.txt index e9939aea5c..c29dc81d45 100644 --- a/docs/i18n/mr/llm.txt +++ b/docs/i18n/mr/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 151 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 153 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (117 domain-specific files, 151 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 153 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/ms/llm.txt b/docs/i18n/ms/llm.txt index 1a4b1c9305..3ba8d25a9b 100644 --- a/docs/i18n/ms/llm.txt +++ b/docs/i18n/ms/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 151 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 153 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (117 domain-specific files, 151 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 153 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/nl/llm.txt b/docs/i18n/nl/llm.txt index d501c634fc..fb1e502db7 100644 --- a/docs/i18n/nl/llm.txt +++ b/docs/i18n/nl/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 151 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 153 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (117 domain-specific files, 151 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 153 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/no/llm.txt b/docs/i18n/no/llm.txt index 6493ece498..8f79c3e3ff 100644 --- a/docs/i18n/no/llm.txt +++ b/docs/i18n/no/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 151 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 153 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (117 domain-specific files, 151 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 153 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/phi/llm.txt b/docs/i18n/phi/llm.txt index bd94e20531..2dec4c0693 100644 --- a/docs/i18n/phi/llm.txt +++ b/docs/i18n/phi/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 151 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 153 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (117 domain-specific files, 151 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 153 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/pl/llm.txt b/docs/i18n/pl/llm.txt index 3b89cc943a..16eaa93a6f 100644 --- a/docs/i18n/pl/llm.txt +++ b/docs/i18n/pl/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 151 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 153 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (117 domain-specific files, 151 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 153 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/pt-BR/llm.txt b/docs/i18n/pt-BR/llm.txt index a9bc6922f7..bf476bb8e3 100644 --- a/docs/i18n/pt-BR/llm.txt +++ b/docs/i18n/pt-BR/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 151 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 153 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (117 domain-specific files, 151 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 153 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/pt/llm.txt b/docs/i18n/pt/llm.txt index 1672bcc004..3dc57c2f34 100644 --- a/docs/i18n/pt/llm.txt +++ b/docs/i18n/pt/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 151 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 153 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (117 domain-specific files, 151 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 153 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/ro/llm.txt b/docs/i18n/ro/llm.txt index 3a607cc7be..2339427fc4 100644 --- a/docs/i18n/ro/llm.txt +++ b/docs/i18n/ro/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 151 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 153 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (117 domain-specific files, 151 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 153 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/ru/llm.txt b/docs/i18n/ru/llm.txt index 8a70dce5aa..c256e9bb8d 100644 --- a/docs/i18n/ru/llm.txt +++ b/docs/i18n/ru/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 151 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 153 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (117 domain-specific files, 151 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 153 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/sk/llm.txt b/docs/i18n/sk/llm.txt index 05325980f2..6b4fa58433 100644 --- a/docs/i18n/sk/llm.txt +++ b/docs/i18n/sk/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 151 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 153 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (117 domain-specific files, 151 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 153 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/sv/llm.txt b/docs/i18n/sv/llm.txt index c6fbbdad6f..b57a3c7691 100644 --- a/docs/i18n/sv/llm.txt +++ b/docs/i18n/sv/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 151 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 153 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (117 domain-specific files, 151 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 153 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/sw/llm.txt b/docs/i18n/sw/llm.txt index c8462b98f4..d517a36e59 100644 --- a/docs/i18n/sw/llm.txt +++ b/docs/i18n/sw/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 151 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 153 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (117 domain-specific files, 151 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 153 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/ta/llm.txt b/docs/i18n/ta/llm.txt index 32834eb014..f53d254c53 100644 --- a/docs/i18n/ta/llm.txt +++ b/docs/i18n/ta/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 151 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 153 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (117 domain-specific files, 151 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 153 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/te/llm.txt b/docs/i18n/te/llm.txt index 6748ed97ea..5391f5a324 100644 --- a/docs/i18n/te/llm.txt +++ b/docs/i18n/te/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 151 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 153 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (117 domain-specific files, 151 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 153 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/th/llm.txt b/docs/i18n/th/llm.txt index 7558d6b1a0..144b6e0bdb 100644 --- a/docs/i18n/th/llm.txt +++ b/docs/i18n/th/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 151 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 153 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (117 domain-specific files, 151 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 153 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/tr/llm.txt b/docs/i18n/tr/llm.txt index f712207d83..10e6e61d38 100644 --- a/docs/i18n/tr/llm.txt +++ b/docs/i18n/tr/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 151 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 153 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (117 domain-specific files, 151 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 153 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/uk-UA/llm.txt b/docs/i18n/uk-UA/llm.txt index 290527a619..dd4895b928 100644 --- a/docs/i18n/uk-UA/llm.txt +++ b/docs/i18n/uk-UA/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 151 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 153 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (117 domain-specific files, 151 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 153 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/ur/llm.txt b/docs/i18n/ur/llm.txt index 31b55c781d..fa6bc3fb7a 100644 --- a/docs/i18n/ur/llm.txt +++ b/docs/i18n/ur/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 151 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 153 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (117 domain-specific files, 151 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 153 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/vi/llm.txt b/docs/i18n/vi/llm.txt index d51ae2c3f5..fe52242de5 100644 --- a/docs/i18n/vi/llm.txt +++ b/docs/i18n/vi/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 151 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 153 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (117 domain-specific files, 151 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 153 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/zh-CN/llm.txt b/docs/i18n/zh-CN/llm.txt index f5a7f9b1b4..6dd819a0e6 100644 --- a/docs/i18n/zh-CN/llm.txt +++ b/docs/i18n/zh-CN/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 151 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 153 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (117 domain-specific files, 151 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 153 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/zh-TW/llm.txt b/docs/i18n/zh-TW/llm.txt index 38a276cf70..2aef156267 100644 --- a/docs/i18n/zh-TW/llm.txt +++ b/docs/i18n/zh-TW/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 151 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 153 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (117 domain-specific files, 151 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 153 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/llm.txt b/llm.txt index 32e43bf16a..76c556e448 100644 --- a/llm.txt +++ b/llm.txt @@ -14,7 +14,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 151 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 153 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -434,7 +434,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (117 domain-specific files, 151 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 153 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index 74c9c9cc4d..6188c44352 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -466,6 +466,7 @@ export async function handleChatCore({ skipUpstreamRetry = false, createPiiTransform = null, correlationId = null, + conversationId = null, modelPinned = false, skipResourcePressureGuard = false, managedLease = null, @@ -876,6 +877,7 @@ export async function handleChatCore({ providerRequest: initialProviderRequest, stage: "registered", correlationId, + sessionTag: conversationId || null, }) || generateRequestId(); // Initialize rate limit settings from persisted DB (once, lazy) @@ -1008,7 +1010,11 @@ export async function handleChatCore({ noLogEnabled, correlationId, modelPinned, - sessionTag: explicitSessionIdHeader, + // Resolved conversationId (open-sse/services/conversationTracker.ts) wins when + // present — it's populated for every request now, not just ones where the + // client explicitly sent x-omniroute-session-id. The raw header remains a + // fallback for any caller that somehow bypassed conversationId resolution. + sessionTag: conversationId || explicitSessionIdHeader, }); // Primary path: merge client model id + alias target so config on either key applies; resolved diff --git a/open-sse/services/conversationTracker.ts b/open-sse/services/conversationTracker.ts new file mode 100644 index 0000000000..cd70593fd2 --- /dev/null +++ b/open-sse/services/conversationTracker.ts @@ -0,0 +1,482 @@ +/** + * Conversation Tracker — assigns a stable conversation id across separate + * HTTP requests that are turns of the same multi-turn agentic conversation. + * + * Clients resend the full growing message/input history on every turn (no + * server-side state dependency). Continuation is detected with a per-turn + * hash chain (each turn's id = sha256(parentId, role, sha256(text)), the + * same idea as a git commit graph): a new request's turns are walked from + * the start against the candidate conversation's existing chain, matching as + * far as they agree. Real agentic-CLI traffic (OpenClaw and similar) often + * edits or duplicates a turn mid-history to keep provider-side prompt caches + * warm — e.g. request 1 has turns `a b c … h i`, request 2 has + * `a b c′ … h i′ i j k`. A whole-history hash (the original approach) breaks + * on any such edit and never reconnects. + * + * Every OmniRoute conversation is a single straight line — it never forks. + * When a turn diverges from what's already on file (`c` became `c'`), that + * diverging history becomes its OWN independent conversation, with its own + * id, built fresh from this request's full turn list — not a branch grafted + * onto the old chain (2026-08-06 redesign; the branching model's real + * traffic accumulated dozens of edits per session, and indenting one more + * tree level per edit eventually left no room to show content at all). + * `a b c d` and `a b c' d'` end up as two distinct conversations, sharing no + * further storage after the point they diverge — simpler to store, query, + * and render than a tree, and it matches how the data is actually used: a + * "conversation" here is one continuous transcript, not a version-control + * graph. This is a new, persisted mechanism — separate from + * `sessionManager.ts`'s `generateSessionId()` (in-memory, routing/latency + * only) even though it uses the same sha256-fingerprint style. + * + * @see Issue: X-ConversationId / agentic conversation tracking + */ + +import { createHash, randomUUID } from "node:crypto"; +import { + createAgenticConversation, + findAgenticConversationsByFingerprint, + getConversationTurnIndex, + insertConversationTurnNodes, + touchOrCreateExternalConversation, + updateAgenticConversation, + type ConversationTurnIndex, +} from "../../src/lib/db/agenticConversations.ts"; + +type JsonRecord = Record; + +interface CanonicalTurn { + role: "system" | "user" | "assistant" | "tool"; + text: string; + /** 'text' | 'tool_use' | 'tool_result' — carried through to + * conversation_turn_nodes so the tree view (and any other consumer) can + * build the exact NormalizedBlock (src/mitm/inspector/types.ts) the + * request-detail panel already builds from buildRequestTurns/ + * buildResponseTurns, rendering tool calls/results through the same + * ChatBubble/MessageContent/ToolCallBlock/ToolResultBlock components + * everywhere instead of a parallel tree-only implementation. */ + blockKind: "text" | "tool_use" | "tool_result"; + /** Set only when blockKind === "tool_use". */ + toolName: string | null; +} + +export interface ResolveConversationIdInput { + body: JsonRecord | null | undefined; + model: string | null; + apiKeyId: string | null; + /** Raw `x-omniroute-session-id` header value, if the client supplied one. */ + clientSessionIdHeader: string | null; + /** + * call_logs.correlation_id for this request (109_call_logs_correlation_id) + * — generated earlier in the request lifecycle, well before this request's + * own call_logs row/id exists, so it's the only stable identifier + * available here to tag new turn nodes with. The tree API route + * (src/app/api/conversations/[id]/tree/route.ts) joins through it to + * resolve a navigable call_logs.id. + */ + correlationId: string | null; +} + +export interface ResolveConversationIdResult { + conversationId: string; + isNewConversation: boolean; +} + +// ── Canonicalization ───────────────────────────────────────────────────── + +function normalizeRole(raw: unknown): CanonicalTurn["role"] { + if (raw === "system" || raw === "user" || raw === "assistant" || raw === "tool") return raw; + if (raw === "developer") return "system"; + if (raw === "model") return "assistant"; + if (raw === "function") return "tool"; + return "user"; +} + +/** + * Extract human-readable text from an OpenAI/Anthropic/Responses-API + * `content` value. Chat Completions sends a plain string; Responses API and + * Anthropic send an array of typed blocks (`{type:"text"|"input_text"| + * "output_text", text}`, `tool_use`, `tool_result`, ...) — collapsing that + * array to its text (rather than `JSON.stringify`-ing the whole thing) is + * what feeds both the turn-hash-chain (so the same underlying text chains + * identically regardless of which block-array shape a client used to send + * it) and `text_preview`, which the /dashboard/conversations tree view + * renders directly as markdown — a raw JSON blob there was a real bug, not a + * cosmetic one. + */ +function stringifyContent(content: unknown): string { + if (typeof content === "string") return content; + if (content == null) return ""; + if (Array.isArray(content)) { + const parts: string[] = []; + for (const item of content) { + if (typeof item === "string") { + parts.push(item); + continue; + } + const block = item && typeof item === "object" ? (item as JsonRecord) : null; + if (!block) continue; + const type = block.type; + if ( + (type === "text" || type === "input_text" || type === "output_text") && + typeof block.text === "string" + ) { + parts.push(block.text); + } else if (type === "tool_use" || type === "function_call") { + const name = typeof block.name === "string" ? block.name : ""; + parts.push(`[tool_use ${name}]`); + } else if (type === "tool_result" || type === "function_call_output") { + parts.push(stringifyContent(block.content ?? block.output ?? "")); + } else if (typeof block.text === "string") { + parts.push(block.text); + } + } + return parts.join("\n"); + } + try { + return JSON.stringify(content); + } catch { + return ""; + } +} + +/** + * Flatten a Chat Completions `messages[]` array or a Responses API `input` + * (array, bare string, or single message-shaped object) into a stable, + * format-agnostic turn list. Ignores ids/tool_call_ids/metadata entirely — + * only role + a string projection of content survive, since those are the + * only fields that stay stable across a client's own re-encoding of history. + */ +export function extractCanonicalTurns(body: JsonRecord | null | undefined): CanonicalTurn[] { + if (!body || typeof body !== "object") return []; + + let raw: unknown[]; + if (Array.isArray(body.messages)) { + raw = body.messages; + } else if (Array.isArray(body.input)) { + raw = body.input; + } else if (typeof body.input === "string") { + raw = [{ role: "user", content: body.input }]; + } else if (body.input && typeof body.input === "object") { + raw = [body.input]; + } else { + raw = []; + } + + const turns: CanonicalTurn[] = []; + for (const item of raw) { + const rec = item && typeof item === "object" ? (item as JsonRecord) : {}; + // Responses API function_call/function_call_output items have no `role` + // but do carry stable identifying text — fold them in as "tool" turns so + // tool round-trips still contribute to the continuation signal. + const role = rec.role + ? normalizeRole(rec.role) + : rec.type === "function_call" || rec.type === "function_call_output" + ? "tool" + : null; + if (!role) continue; + const text = stringifyContent(rec.content ?? rec.text ?? rec.arguments ?? rec.output); + if (!text) continue; + + // Chat Completions tool-result messages (role: "tool"/"function") and + // Responses API function_call/function_call_output items are the only + // two shapes this canonicalizer sees for tool activity — everything + // else (including plain assistant/user/system text) is "text". + let blockKind: CanonicalTurn["blockKind"] = "text"; + let toolName: string | null = null; + if (rec.type === "function_call") { + blockKind = "tool_use"; + toolName = typeof rec.name === "string" ? rec.name : null; + } else if (rec.type === "function_call_output") { + blockKind = "tool_result"; + } else if (rec.role === "tool" || rec.role === "function") { + blockKind = "tool_result"; + toolName = typeof rec.name === "string" ? rec.name : null; + } + + turns.push({ role, text, blockKind, toolName }); + } + return turns; +} + +// ── Fingerprint (identity, O(1) regardless of history size) ───────────── + +function hashHex(text: string): string { + return createHash("sha256").update(text).digest("hex"); +} + +function extractToolNames(body: JsonRecord | null | undefined): string[] { + if (!body || !Array.isArray(body.tools)) return []; + const names: string[] = []; + for (const tool of body.tools as unknown[]) { + const rec = tool && typeof tool === "object" ? (tool as JsonRecord) : {}; + const fn = rec.function && typeof rec.function === "object" ? (rec.function as JsonRecord) : {}; + const name = + typeof rec.name === "string" ? rec.name : typeof fn.name === "string" ? fn.name : ""; + if (name) names.push(name); + } + return names.sort(); +} + +// Deliberately excludes any message text — both the system prompt (real +// coding-agent CLIs like Claude Code/opencode regenerate it every request +// with live context: timestamp, cwd, git status...) AND, discovered live on +// a real OmniRoute deployment running OpenClaw, the first non-system turn +// too: OpenClaw's sliding context window drops/summarizes the EARLIEST +// turns as a session grows, so `firstNonSystemText` never stays stable +// across requests either — anchoring identity to either one mints a brand +// new conversation (or, worse, finds zero fingerprint candidates at all, so +// the turn-chain match in resolveConversationId never even runs) on every +// single turn for exactly this kind of real traffic, even though the actual +// history is a genuine, unbroken continuation. The bucket only needs to be +// small enough to bound candidate lookup — apiKeyId + model + toolNames is +// stable across a whole session and still narrow in practice; actual +// identity is decided by the turn-chain walk (real content overlap), not by +// this bucket, so widening it here cannot cause a false merge on its own. +export function computeFingerprintHash(input: { + apiKeyId: string | null; + model: string | null; + toolNames: string[]; +}): string { + const parts = [input.apiKeyId ?? "", input.model ?? "", input.toolNames.join(",")]; + // NOTE: no connectionId — conversation identity must not depend on which + // upstream connection this particular turn happened to be routed to. + return hashHex(parts.join("|")); +} + +// ── Turn hash chain (continuation + branch detection) ──────────────────── +// +// Each turn gets a stable id chained to its predecessor, the same idea as a +// git commit graph: id = sha256(parentId, role, sha256(text)). A brand-new +// tree's first turn chains off the conversation root id itself (not off +// `null`) so two different, unrelated conversation trees whose first turn +// happens to be byte-identical (e.g. two sessions that both open with "hi") +// never compute the same node id — `conversation_turn_nodes.id` is a global +// primary key, not scoped per conversation_id. +// +// Nodes store identity only (id/parent/content_hash), never the turn's +// actual text/tool-call shape — the dashboard resolves that on demand from +// the call-log pipeline artifact each node's correlation id points at (see +// conversationTurnContent.ts), re-running extractCanonicalTurns over that +// artifact's full, untruncated request body and matching by contentHash. +// Exported so that resolver can compute the same hash for a lookup key. +export function hashTurnContent(turn: CanonicalTurn): string { + return hashHex(`${turn.role} ${turn.text}`); +} + +function chainNodeId(parentId: string, turn: CanonicalTurn): string { + return hashHex(`${parentId} ${hashTurnContent(turn)}`); +} + +interface NewTurnNode { + id: string; + parentId: string | null; + role: string; + contentHash: string; +} + +/** Build the new-node run for turns[fromIndex:], chained off `chainAnchor`. */ +function buildNewNodes( + turns: CanonicalTurn[], + fromIndex: number, + chainAnchor: string, + rootId: string +): NewTurnNode[] { + const nodes: NewTurnNode[] = []; + let parent = chainAnchor; + for (let i = fromIndex; i < turns.length; i++) { + const turn = turns[i]; + const nodeId = chainNodeId(parent, turn); + nodes.push({ + id: nodeId, + // The root anchor is a hashing seed, not a real node — the first turn + // of a tree has no parent turn. + parentId: parent === rootId ? null : parent, + role: turn.role, + contentHash: hashTurnContent(turn), + }); + parent = nodeId; + } + return nodes; +} + +interface ReconnectMatch { + /** Index into `chainTurns` where the reconnection was found (turns before + * this index were dropped from the chain's view — a compacted summary the + * client sent instead of resending them verbatim — and are not inserted + * as nodes). */ + startIndex: number; + /** How far the match extends past startIndex (>= startIndex + 1). */ + matchEndIndex: number; + /** Node id to chain new nodes off (the last matched node). */ + anchorNodeId: string; + /** True when `anchorNodeId` already has a recorded child in this chain — + * i.e. turns[matchEndIndex] (if any) would collide with an existing, + * DIFFERENT turn rather than simply being new. See resolveConversationId's + * doc comment for what this distinction now controls. */ + anchorHasChild: boolean; +} + +/** + * Find where `chainTurns` reconnects to an existing chain, trying the + * leftmost turn first (so a still-fully-present prefix — the common case — + * matches immediately at the start) and falling back to later turns only + * when earlier ones aren't found anywhere in the chain. This is what makes + * continuation detection survive OpenClaw's sliding context window: once + * the earliest turns are compacted away, turn 0 of a new request is some + * turn from the MIDDLE of the existing chain, not its start — a start-only + * walk (checking only whether turn 0 is the chain's own first turn) would + * find nothing. + * + * Real agentic traffic is full of byte-identical repeated turns — a tool + * polling loop's "Process still running." output, a heartbeat ack, a + * one-word "ok" — so `byContentHash.get(...)` routinely returns MANY + * candidate anchors for the same turn (one real conversation observed 28 + * duplicates of a single OpenClaw runtime-context turn). Evaluating only the + * first candidate (as this used to do) meant returning whichever occurrence + * SQLite happened to list first — in practice the OLDEST, most stale one — + * whose recorded next-turn almost never matches the current request, so the + * walk stalled a few turns in and (worse) that stale anchor already has a + * DIFFERENT recorded child, tripping `anchorHasChild` and making + * resolveConversationId treat a genuine continuation as a divergence. Live + * result: a real conversation minted a brand-new copy of its ENTIRE history + * on every single request instead of ever reconnecting (2026-08-06). Every + * candidate anchor for every prefix start is now tried, and the one that + * verifiably extends furthest into the actual request wins — the only + * reliable signal of genuine continuation when content repeats. + */ +function findReconnectMatch( + chainTurns: CanonicalTurn[], + index: ConversationTurnIndex +): ReconnectMatch | null { + let best: ReconnectMatch | null = null; + + for (let s = 0; s < chainTurns.length; s++) { + const anchors = index.byContentHash.get(hashTurnContent(chainTurns[s])); + if (!anchors) continue; + for (const anchorNodeId of anchors) { + let parent = anchorNodeId; + let matchEndIndex = s + 1; + for (let i = s + 1; i < chainTurns.length; i++) { + const nodeId = chainNodeId(parent, chainTurns[i]); + if (!index.nodeIds.has(nodeId)) break; + parent = nodeId; + matchEndIndex++; + } + const anchorHasChild = index.parentsWithChildren.has(parent); + // Longest verified run wins outright. An equal-length run breaks + // toward anchorHasChild===false: a tie means both candidate anchors' + // recorded next-turn already differs from what's being requested (the + // walk stopped for the same reason on both), so the anchor with NO + // established child is the safe, unambiguous "just append here" — the + // other, having a different recorded child already, would incorrectly + // read as a divergence purely because it happened to be tried first. + const isBetter = + !best || + matchEndIndex > best.matchEndIndex || + (matchEndIndex === best.matchEndIndex && !anchorHasChild && best.anchorHasChild); + if (isBetter) { + best = { startIndex: s, matchEndIndex, anchorNodeId: parent, anchorHasChild }; + } + // Can't do better than matching every turn through to the end. + if (matchEndIndex === chainTurns.length) return best; + } + } + return best; +} + +// ── Orchestration ───────────────────────────────────────────────────────── + +const MAX_STORED_ID_LENGTH = 128; + +export async function resolveConversationId( + input: ResolveConversationIdInput +): Promise { + // Client override wins outright — deterministic, zero heuristic risk. + // Same header feature #8249 already reads (chatCore.ts); we don't invent a + // new prefix so the existing header's contract/format stays unchanged. + if (input.clientSessionIdHeader && input.clientSessionIdHeader.trim()) { + const id = input.clientSessionIdHeader.trim().slice(0, MAX_STORED_ID_LENGTH); + touchOrCreateExternalConversation(id, { apiKeyId: input.apiKeyId }); + return { conversationId: id, isNewConversation: false }; + } + + const turns = extractCanonicalTurns(input.body); + const toolNames = extractToolNames(input.body); + const fingerprintHash = computeFingerprintHash({ + apiKeyId: input.apiKeyId, + model: input.model, + toolNames, + }); + + // The turn CHAIN excludes the system message entirely, same reasoning as + // extractFirstNonSystemText above: real coding-agent CLIs regenerate the + // system prompt (timestamp/cwd/git status...) on every single request, so + // treating it as an ordinary chained turn would make turn-0 (or wherever + // it sits) fail to match on every request — reintroducing the exact + // always-new-conversation bug this chain design exists to fix. + const chainTurns = turns.filter((t) => t.role !== "system"); + + const candidates = findAgenticConversationsByFingerprint(fingerprintHash); + for (const candidate of candidates) { + const index = getConversationTurnIndex(candidate.id); + if (index.nodeIds.size === 0) continue; + + const match = findReconnectMatch(chainTurns, index); + // No match anywhere in the chain means this candidate isn't actually + // this conversation's lineage — it only shares the coarse fingerprint + // bucket (apiKeyId/model/toolNames), which real traffic proves is not + // enough to assume overlap on its own (see computeFingerprintHash's doc + // comment) — try the next candidate rather than attaching a completely + // unrelated turn. + if (!match) continue; + + if (match.matchEndIndex === chainTurns.length) { + // Every turn from the reconnect point onward already exists on this + // chain (e.g. an exact retry, or the whole request is already fully + // recorded) — a real continuation, nothing new to insert. + updateAgenticConversation(candidate.id, { turnCount: candidate.turnCount + 1 }); + return { conversationId: candidate.id, isNewConversation: false }; + } + + if (!match.anchorHasChild) { + // Genuine tail growth: the reconnect point has no recorded child yet, + // so turns[matchEndIndex:] are simply turns this conversation hasn't + // seen before — append them to this SAME chain. Turns before + // startIndex (a compacted-away prefix, if any) are never inserted — + // they don't represent new content, just the client's own context + // management. + const newNodes = buildNewNodes( + chainTurns, + match.matchEndIndex, + match.anchorNodeId, + candidate.id + ); + insertConversationTurnNodes(candidate.id, input.correlationId, newNodes); + updateAgenticConversation(candidate.id, { turnCount: candidate.turnCount + 1 }); + return { conversationId: candidate.id, isNewConversation: false }; + } + + // The reconnect point already has a DIFFERENT recorded child — this + // request's turn at that position diverges from what's on file (a real + // OpenClaw cache-aware-context edit: turn `c` became `c'`). As of the + // 2026-08-06 redesign, an edited/duplicated turn no longer forks a + // branch inside this conversation's own chain — every OmniRoute + // conversation is now a single straight line, never a tree. The + // diverging history becomes its own independent conversation instead + // (built fresh below, from this request's full turn list) — distinct + // conversation ids for `a b c d` and `a b c' d'`, not one tree with two + // branches. This is both simpler to store/query and fixes a real UX + // problem the branching model had: real OpenClaw traffic accumulates + // dozens of edits per session, and indenting one more level per fork + // eventually left no horizontal space for content at all. Keep checking + // remaining candidates first, though — a later candidate may already BE + // that independent conversation from a previous edit at this same spot + // (e.g. a repeated retry of the edited turn), which should continue + // that one rather than minting yet another new id for it. + } + + const id = `conv_${randomUUID()}`; + createAgenticConversation({ id, apiKeyId: input.apiKeyId, fingerprintHash }); + insertConversationTurnNodes(id, input.correlationId, buildNewNodes(chainTurns, 0, id, id)); + return { conversationId: id, isNewConversation: true }; +} diff --git a/open-sse/services/conversationTurnContent.ts b/open-sse/services/conversationTurnContent.ts new file mode 100644 index 0000000000..a95a39c939 --- /dev/null +++ b/open-sse/services/conversationTurnContent.ts @@ -0,0 +1,82 @@ +/** + * conversationTurnContent.ts — resolves a conversation_turn_nodes row's + * actual display text/tool-call shape on demand, instead of storing it. + * + * conversation_turn_nodes (migration 156) is identity-only: id/parent/ + * content_hash, no turn text. Every node's originating request is already + * fully captured by the call-log pipeline artifact its `last_correlation_id` + * points at (call_logs.artifact_relpath, behind call_log_pipeline_enabled), + * so display content is re-derived from there on read instead of duplicating + * it into a second store: load the artifact's raw client request body, run + * it back through the SAME extractCanonicalTurns/hashTurnContent the write + * path used, and match by content_hash. This also gives full, untruncated + * text where the old stored text_preview was capped at 8000 chars. + */ + +import { getDbInstance } from "../../src/lib/db/core.ts"; +import { readCallArtifact } from "../../src/lib/usage/callLogArtifacts.ts"; +import { extractCanonicalTurns, hashTurnContent } from "./conversationTracker.ts"; + +export type TurnDisplayContent = { + textPreview: string; + blockKind: "text" | "tool_use" | "tool_result"; + toolName: string | null; +}; + +/** + * Resolve display content for a batch of turn nodes, keyed by content_hash. + * Content_hash is sha256(role+text) only — real traffic has plenty of + * byte-identical repeated turns (a tool-polling "still running" ack), so + * distinct nodes legitimately share one hash; since the hash is exactly the + * display text's own identity, resolving once per unique hash is correct, + * not lossy, and avoids redundant artifact reads for a request that touched + * many nodes at once. + */ +export function resolveTurnDisplayContent( + nodes: ReadonlyArray<{ lastCorrelationId: string | null }> +): Map { + const result = new Map(); + const correlationIds = [ + ...new Set(nodes.map((n) => n.lastCorrelationId).filter((v): v is string => !!v)), + ]; + if (correlationIds.length === 0) return result; + + const db = getDbInstance(); + const placeholders = correlationIds.map(() => "?").join(","); + const rows = db + .prepare( + `SELECT correlation_id, artifact_relpath FROM call_logs + WHERE correlation_id IN (${placeholders}) AND artifact_relpath IS NOT NULL + ORDER BY timestamp ASC` + ) + .all(...correlationIds) as Array<{ correlation_id: string; artifact_relpath: string }>; + + // A retry/combo-fallback attempt can share one correlation_id across a few + // call_logs rows; they all carry the same client-facing request body, so + // any one artifact is a valid content source — keep the first. + const artifactPathByCorrelationId = new Map(); + for (const row of rows) { + if (!artifactPathByCorrelationId.has(row.correlation_id)) { + artifactPathByCorrelationId.set(row.correlation_id, row.artifact_relpath); + } + } + + for (const relPath of artifactPathByCorrelationId.values()) { + const { artifact, state } = readCallArtifact(relPath); + if (state !== "ready") continue; + const clientRawRequest = artifact?.pipeline?.clientRawRequest as { body?: unknown } | undefined; + const body = clientRawRequest?.body; + if (!body || typeof body !== "object") continue; + + for (const turn of extractCanonicalTurns(body as Record)) { + const hash = hashTurnContent(turn); + if (result.has(hash)) continue; + result.set(hash, { + textPreview: turn.text, + blockKind: turn.blockKind, + toolName: turn.toolName, + }); + } + } + return result; +} diff --git a/src/app/(dashboard)/dashboard/conversations/page.tsx b/src/app/(dashboard)/dashboard/conversations/page.tsx new file mode 100644 index 0000000000..78c352c175 --- /dev/null +++ b/src/app/(dashboard)/dashboard/conversations/page.tsx @@ -0,0 +1,954 @@ +"use client"; + +import { Suspense, useCallback, useEffect, useRef, useState } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { PROVIDER_COLORS, getHttpStatusStyle } from "@/shared/constants/colors"; +import { formatTime } from "@/shared/utils/formatting"; +import { copyToClipboard } from "@/shared/utils/clipboard"; +import RequestLoggerDetail from "@/shared/components/RequestLoggerDetail"; +import useEmailPrivacyStore from "@/store/emailPrivacyStore"; +import { ChatBubble } from "@/app/(dashboard)/dashboard/tools/traffic-inspector/components/chat/ChatBubble"; +import type { NormalizedBlock, NormalizedTurn } from "@/mitm/inspector/types"; + +interface ConversationRow { + id: string; + turnCount: number; + firstSeenAt: string; + lastSeenAt: string; + lastCallLogId: string | null; + lastModel: string | null; + lastProvider: string | null; + lastStatus: number | null; + isActive: boolean; + // The in-flight request's OWN id (from usageHistory's pendingById, keyed by + // sessionTag) — distinct from lastCallLogId, which joins against call_logs + // and therefore always lags one request behind while a reply is still + // streaming (call_logs only gets its row on completion). Used to poll + // /api/logs/[id] for this conversation's live partial assistant text. + activeCallLogId: string | null; +} + +// Same spinner used for an in-flight request on /dashboard/logs +// (RequestLoggerV2) — reused here so "in progress" reads the same way in +// both places. +function ActiveSpinner() { + return ( + + + + ); +} + +interface ConversationTurn { + seq: number; + id: string; + parentId: string | null; + role: string; + textPreview: string; + blockKind: string; + toolName: string | null; + firstSeenAt: string; +} + +interface ConversationTurnsPage { + nodes: ConversationTurn[]; + hasMore: boolean; +} + +const CONVERSATION_PAGE_SIZE = 20; + +const DEFAULT_POLL_SECONDS = 5; +const POLL_STORAGE_KEY = "conversationsListPollSeconds"; +// Matches RequestLoggerDetail's CONVERSATION_ACTIVE_POLL_INTERVAL_MS — same +// live-partial-text source, same cadence, so the two views feel consistent. +const LIVE_TEXT_POLL_INTERVAL_MS = 1200; + +function ProviderBadge({ provider }: { provider: string | null }) { + if (!provider) return ; + const style = (PROVIDER_COLORS as Record)[ + provider + ]; + if (!style) { + return ( + + {provider} + + ); + } + return ( + + {style.label} + + ); +} + +function StatusBadge({ status }: { status: number | null }) { + if (status == null) return ; + const style = getHttpStatusStyle(status); + return ( + + {status} + + ); +} + +/** + * Builds the exact NormalizedBlock (src/mitm/inspector/types.ts) the + * request-detail panel already builds from buildRequestTurns/ + * buildResponseTurns, so a tool call/result renders through the very same + * ChatBubble → MessageContent → ToolCallBlock/ToolResultBlock pipeline as + * the detail view — not a parallel implementation. `textPreview` round- + * tripped through JSON for a structured tool_use/tool_result turn; parse it + * best-effort so the block gets a real object, not a JSON string. + */ +function toTurn(node: ConversationTurn): NormalizedTurn { + const role: NormalizedTurn["role"] = + node.role === "system" || node.role === "user" || node.role === "assistant" + ? node.role + : "tool"; + + let block: NormalizedBlock; + if (node.blockKind === "tool_use") { + let input: unknown = node.textPreview; + try { + input = JSON.parse(node.textPreview); + } catch { + // Arguments weren't valid JSON — show the raw string. + } + block = { type: "tool_use", id: node.id.slice(0, 12), name: node.toolName ?? "tool", input }; + } else if (node.blockKind === "tool_result") { + let content: unknown = node.textPreview; + try { + content = JSON.parse(node.textPreview); + } catch { + // Not JSON — show the raw string. + } + block = { type: "tool_result", tool_use_id: node.id.slice(0, 12), content }; + } else { + block = { type: "text", text: node.textPreview || "_(empty)_" }; + } + + return { role, blocks: [block], timestamp: node.firstSeenAt }; +} + +/** + * Renders a conversation's turns top to bottom, oldest first — always a + * flat, chronological list. Every OmniRoute conversation is a single + * straight line (an edited/duplicated turn mints its own independent + * conversation instead of branching this one — see conversationTracker.ts's + * 2026-08-06 redesign), so there is no fork/indentation logic here at all + * anymore. `onLoadOlder` renders as a button above the turns when more + * (older) history exists than the current page. + */ +function ConversationLogView({ + nodes, + hasMore, + loadingMore, + onLoadOlder, + livePartialText, +}: { + nodes: ConversationTurn[]; + hasMore: boolean; + loadingMore: boolean; + onLoadOlder: () => void; + // The reply currently streaming for this conversation, if any — not yet a + // persisted conversation_turn_nodes row (see the live-text poll effect's + // comment for why), rendered as a provisional bubble below the real turns. + livePartialText: string; +}) { + if (nodes.length === 0 && !livePartialText) { + return ( +
No turns recorded for this conversation.
+ ); + } + return ( +
+ {hasMore && ( + + )} + {nodes.map((node) => ( + + ))} + {livePartialText && ( +
+
+ + Generating… +
+ +
+ )} +
+ ); +} + +function ConversationsPageContent() { + const router = useRouter(); + const searchParams = useSearchParams(); + // Read once on mount, mirroring dashboard/logs/page.tsx (#6830/#8354): re-reading the + // live searchParams on every render re-fires the deep-link open effect right when the + // panel closes and router.replace() strips the ?id= param. + const [initialId] = useState(() => searchParams.get("id")); + // Deep link for the conversation modal — separate param from `id` (the + // request-detail panel) so either overlay can be linked independently. + const [initialConversationParam] = useState(() => searchParams.get("tree")); + + const [conversations, setConversations] = useState([]); + const [total, setTotal] = useState(0); + const [loading, setLoading] = useState(true); + + const { emailsVisible } = useEmailPrivacyStore(); + const [selectedLog, setSelectedLog] = useState(null); + const [detailData, setDetailData] = useState(null); + const [detailLoading, setDetailLoading] = useState(false); + const [detailLoggingEnabled, setDetailLoggingEnabled] = useState(false); + const [activeConversation, setActiveConversation] = useState(null); + // Extracted so effects that only care "which conversation" (not its + // summary fields) can depend on this stable primitive instead of the + // whole activeConversation object — that object gets a fresh reference + // every list-poll tick once opened (see the resync effect below), which + // would otherwise rebind timers/listeners on every poll tick. + const activeConversationId = activeConversation?.id ?? null; + // Only the identifier, not the whole activeConversation object, for the same + // reason as activeConversationId above: this changes identity every list-poll + // tick, which would otherwise tear down/restart the live-text poll effect. + const activeCallLogId = activeConversation?.activeCallLogId ?? null; + const [livePartialText, setLivePartialText] = useState(""); + const [conversationNodes, setConversationNodes] = useState([]); + const [conversationLoading, setConversationLoading] = useState(false); + const [conversationHasMore, setConversationHasMore] = useState(false); + const [loadingOlder, setLoadingOlder] = useState(false); + const [pollSeconds, setPollSeconds] = useState(() => { + try { + const saved = localStorage.getItem(POLL_STORAGE_KEY); + const parsed = saved ? Number(saved) : DEFAULT_POLL_SECONDS; + return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_POLL_SECONDS; + } catch { + return DEFAULT_POLL_SECONDS; + } + }); + const initialOpenedRef = useRef(false); + const initialConversationOpenedRef = useRef(false); + const conversationPanelRef = useRef(null); + const conversationContentRef = useRef(null); + // True right after opening a conversation (or clicking "Go to bottom"), + // cleared once the user scrolls away from the bottom themselves. A large + // conversation's last page can include multi-KB tool-output/context turns + // whose markdown takes more than one animation frame to lay out, so a + // single scrollTop=scrollHeight right after fetch can undershoot — the + // ResizeObserver below re-pins on every subsequent layout change while + // this stays true, instead of a one-shot scroll that races the render. + const pinnedToBottomRef = useRef(false); + // Set right before prepending an older page, so the effect below can + // adjust scrollTop by exactly how much content grew above the fold — + // otherwise "Load more" would visually yank the view to the top. + const prependAdjustRef = useRef<{ prevScrollHeight: number; prevScrollTop: number } | null>(null); + // Mirrors the newest loaded turn's seq without needing conversationNodes + // itself in the poll effect's dependency array (which would tear down and + // restart the interval on every single appended turn). + const newestSeqRef = useRef(null); + + // Extracted so openConversation can force an immediate refresh instead of + // waiting for the next scheduled tick — see its call site for why: a + // conversation opened right after a new reply starts streaming otherwise + // shows no live text until this poll's own interval happens to land, + // because activeCallLogId only updates via the resync effect below, which + // depends on this list actually having been refetched. + const loadConversations = useCallback(() => { + if (document.visibilityState !== "visible") return; + return fetch("/api/conversations?limit=100", { cache: "no-store" }) + .then((res) => (res.ok ? res.json() : null)) + .then((data) => { + if (!data) return; + setConversations(Array.isArray(data.conversations) ? data.conversations : []); + setTotal(typeof data.total === "number" ? data.total : 0); + }) + .catch(() => {}) + .finally(() => { + setLoading(false); + }); + }, []); + + useEffect(() => { + loadConversations(); + const interval = setInterval(loadConversations, pollSeconds * 1000); + return () => { + clearInterval(interval); + }; + }, [pollSeconds, loadConversations]); + + // activeConversation is a snapshot taken once at openConversation() time — + // it's never touched again while the modal stays open (the turns-poll + // effect below only appends conversationNodes). Without this, "Goto latest + // request" and any other displayed summary field (lastModel/lastStatus/ + // turnCount) go stale the moment a new request lands in this conversation + // while you're still reading it, even though the list poll above (which + // runs regardless of whether the modal is open) already has the fresh + // row. Re-sync from it whenever the list refreshes. + useEffect(() => { + if (!activeConversationId) return; + const fresh = conversations.find((c) => c.id === activeConversationId); + if (!fresh) return; + setActiveConversation((prev) => (prev && prev.id === fresh.id ? fresh : prev)); + }, [conversations, activeConversationId]); + + useEffect(() => { + fetch("/api/logs/detail?limit=1") + .then((res) => (res.ok ? res.json() : null)) + .then((data) => { + if (!data) return; + setDetailLoggingEnabled(data.enabled === true); + }) + .catch(() => {}); + }, []); + + // Opens a request's detail panel in-place — used for the initial row click and for + // every subsequent turn/next-message navigation, so viewing a conversation never + // navigates away from this page (matches RequestLoggerV2/RequestTimeline). + const openById = useCallback( + async (id: string) => { + try { + const url = new URL(globalThis.location.href); + url.searchParams.set("id", id); + router.replace(url.pathname + url.search); + } catch { + // ignore navigation errors + } + setDetailLoading(true); + try { + const res = await fetch(`/api/logs/${id}`, { cache: "no-store" }); + const data = res.ok ? await res.json() : null; + if (data) { + setSelectedLog({ + id: data.id ?? id, + timestamp: data.timestamp, + status: data.status ?? 0, + model: data.model ?? null, + provider: data.provider ?? null, + account: data.account ?? null, + duration: data.duration ?? 0, + tokens: data.tokens ?? { in: 0, out: 0 }, + active: data.active, + error: data.error ?? null, + path: data.path ?? null, + }); + setDetailData(data); + } + } catch { + // ignore fetch errors + } finally { + setDetailLoading(false); + } + }, + [router] + ); + + const closeDetail = useCallback(() => { + setSelectedLog(null); + setDetailData(null); + try { + const url = new URL(globalThis.location.href); + url.searchParams.delete("id"); + router.replace(url.pathname + url.search); + } catch { + // ignore navigation errors + } + }, [router]); + + useEffect(() => { + if (!initialId || initialOpenedRef.current) return; + initialOpenedRef.current = true; + openById(initialId).catch(() => {}); + }, [initialId, openById]); + + const scrollToBottom = useCallback(() => { + pinnedToBottomRef.current = true; + const el = conversationPanelRef.current; + if (!el) return; + requestAnimationFrame(() => { + try { + el.scrollTop = el.scrollHeight; + } catch {} + }); + }, []); + + // Keeps the panel pinned to its bottom while conversationContentRef's + // height keeps changing (initial render of a large page, late-settling + // markdown/tool-output layout, a new turn arriving via poll) — see + // pinnedToBottomRef's comment above for why a single scrollToBottom call + // isn't enough on its own for a heavy page. + useEffect(() => { + const content = conversationContentRef.current; + const panel = conversationPanelRef.current; + if (!content || !panel) return; + const observer = new ResizeObserver(() => { + if (!pinnedToBottomRef.current) return; + panel.scrollTop = panel.scrollHeight; + }); + observer.observe(content); + return () => observer.disconnect(); + // Keyed on the id, not the whole object: activeConversation's summary + // fields (lastCallLogId etc.) get resynced from the list poll while the + // modal stays open (see that effect's comment), which would otherwise + // tear down and recreate this observer on every poll tick. + }, [activeConversation?.id]); + + // Un-pin as soon as the user scrolls away from the bottom themselves (e.g. + // to read earlier turns or click "Load more"), so later content growth + // doesn't yank them back down against their will. Re-pins automatically if + // they scroll back down to the bottom on their own. + useEffect(() => { + const panel = conversationPanelRef.current; + if (!panel) return; + const NEAR_BOTTOM_PX = 24; + const onScroll = () => { + const distanceFromBottom = panel.scrollHeight - panel.scrollTop - panel.clientHeight; + pinnedToBottomRef.current = distanceFromBottom <= NEAR_BOTTOM_PX; + }; + panel.addEventListener("scroll", onScroll, { passive: true }); + return () => panel.removeEventListener("scroll", onScroll); + }, [activeConversation?.id]); + + const fetchConversationPage = useCallback( + (id: string, params: string): Promise => + fetch(`/api/conversations/${id}/tree?${params}`, { cache: "no-store" }) + .then((res) => (res.ok ? res.json() : null)) + .then((data) => + data && Array.isArray(data.nodes) + ? { nodes: data.nodes, hasMore: Boolean(data.hasMore) } + : null + ) + .catch(() => null), + [] + ); + + const openConversation = useCallback( + (row: ConversationRow) => { + setActiveConversation(row); + setConversationNodes([]); + setConversationHasMore(false); + setConversationLoading(true); + setLivePartialText(""); + try { + const url = new URL(globalThis.location.href); + url.searchParams.set("tree", row.id); + router.replace(url.pathname + url.search); + } catch { + // ignore navigation errors + } + // `row` is a snapshot from whenever the list last polled — if a reply + // started streaming after that tick, row.activeCallLogId is still + // null and the live-text poll effect never starts until the next + // scheduled list refresh happens to land (the exact "opened it and + // saw nothing, closed and reopened and saw it live" report). Force + // one now so activeConversation resyncs with the current isActive/ + // activeCallLogId immediately instead of waiting on pollSeconds. + loadConversations(); + fetchConversationPage(row.id, `limit=${CONVERSATION_PAGE_SIZE}`) + .then((page) => { + setConversationNodes(page?.nodes ?? []); + setConversationHasMore(page?.hasMore ?? false); + }) + .finally(() => { + setConversationLoading(false); + // A freshly-opened conversation should start scrolled to the + // latest (bottom-most) turn, not the oldest one on the page. + scrollToBottom(); + }); + }, + [router, fetchConversationPage, scrollToBottom, loadConversations] + ); + + const closeConversation = useCallback(() => { + setActiveConversation(null); + try { + const url = new URL(globalThis.location.href); + url.searchParams.delete("tree"); + router.replace(url.pathname + url.search); + } catch { + // ignore navigation errors + } + }, [router]); + + const loadOlderTurns = useCallback(() => { + const panel = conversationPanelRef.current; + const oldestSeq = conversationNodes[0]?.seq; + if (!activeConversation || !panel || oldestSeq == null || loadingOlder) return; + setLoadingOlder(true); + prependAdjustRef.current = { + prevScrollHeight: panel.scrollHeight, + prevScrollTop: panel.scrollTop, + }; + fetchConversationPage( + activeConversation.id, + `limit=${CONVERSATION_PAGE_SIZE}&beforeSeq=${oldestSeq}` + ) + .then((page) => { + if (page && page.nodes.length > 0) { + setConversationNodes((prev) => [...page.nodes, ...prev]); + } + setConversationHasMore(page?.hasMore ?? false); + }) + .finally(() => setLoadingOlder(false)); + }, [activeConversation, conversationNodes, loadingOlder, fetchConversationPage]); + + // Preserve scroll position across a "load more" prepend — otherwise + // adding older turns above the fold visually yanks the view to the top. + useEffect(() => { + const adjust = prependAdjustRef.current; + if (!adjust) return; + prependAdjustRef.current = null; + const panel = conversationPanelRef.current; + if (!panel) return; + requestAnimationFrame(() => { + panel.scrollTop = adjust.prevScrollTop + (panel.scrollHeight - adjust.prevScrollHeight); + }); + }, [conversationNodes]); + + useEffect(() => { + newestSeqRef.current = + conversationNodes.length > 0 ? conversationNodes[conversationNodes.length - 1].seq : null; + }, [conversationNodes]); + + useEffect(() => { + if (!activeConversationId) return; + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") closeConversation(); + }; + document.addEventListener("keydown", onKeyDown); + return () => document.removeEventListener("keydown", onKeyDown); + // activeConversationId, not the whole activeConversation object: it + // gets resynced (new object reference) from the list poll while the + // modal stays open (see that effect's comment) — depending on the + // object here would rebind this listener on every poll tick for no + // reason. + }, [activeConversationId, closeConversation]); + + // While the conversation is open, keep polling for turns that arrive + // later (the request that opened it may not be the last one — OpenClaw + // can send another turn while you're still reading). Reuses the same + // "Auto-refresh Xs" setting as the list, rather than a separate interval, + // so there's one poll cadence to reason about on this page. Only ever + // APPENDS newer turns (via afterSeq) — it never re-fetches or replaces + // the whole page, so a "Load more" page loaded earlier stays put, and it + // deliberately does NOT re-scroll on every refresh (only the initial open + // does that), so it doesn't yank the view mid-read. + // + // Depends on activeConversationId, NOT the whole activeConversation + // object: activeConversation gets a fresh object reference every list-poll + // tick (see the resync effect above, needed so "Goto latest request" + // doesn't go stale) — on the SAME poll cadence as this effect's own + // interval. Depending on the object would tear down and recreate this + // setInterval every single tick, resetting its countdown each time and + // starving it of ever actually firing — silently breaking the exact + // "keep filling in new turns while open" behavior this effect exists for. + useEffect(() => { + if (!activeConversationId) return; + const tick = () => { + if (document.visibilityState !== "visible") return; + if (newestSeqRef.current == null) return; + fetchConversationPage(activeConversationId, `afterSeq=${newestSeqRef.current}`).then( + (page) => { + if (page && page.nodes.length > 0) { + setConversationNodes((prev) => [...prev, ...page.nodes]); + } + } + ); + }; + const interval = setInterval(tick, pollSeconds * 1000); + return () => clearInterval(interval); + }, [activeConversationId, pollSeconds, fetchConversationPage]); + + // Live preview of the CURRENTLY streaming reply, if any: conversation_turn_nodes + // only gains a node for an assistant turn once the client resends it as + // history on its NEXT request (resolveConversationId reads only the request + // body), so the turns-poll effect above has nothing new to fetch while a + // reply is still generating — the transcript would sit frozen despite the + // request actively producing text. Same live-partial-text source + // RequestLoggerDetail's ConversationContextSection already polls + // (/api/logs/[id]'s partialAssistantText, built from in-flight streamChunks), + // rendered here as a provisional bubble that's never written to + // conversationNodes/DB. Uses a short fixed interval (not the user's + // Auto-refresh Xs list-poll setting) since a still-generating reply is worth + // refreshing faster than "is there a new conversation" — matches + // RequestLoggerDetail's CONVERSATION_ACTIVE_POLL_INTERVAL_MS. + useEffect(() => { + if (!activeCallLogId) { + setLivePartialText(""); + return; + } + let cancelled = false; + let timeoutId: ReturnType | undefined; + + const tick = () => { + if (cancelled) return; + if (document.visibilityState !== "visible") { + timeoutId = setTimeout(tick, LIVE_TEXT_POLL_INTERVAL_MS); + return; + } + fetch(`/api/logs/${activeCallLogId}`, { cache: "no-store" }) + .then((res) => (res.ok ? res.json() : null)) + .then((data) => { + if (cancelled || !data) return; + setLivePartialText( + typeof data.partialAssistantText === "string" ? data.partialAssistantText : "" + ); + if (data.active) timeoutId = setTimeout(tick, LIVE_TEXT_POLL_INTERVAL_MS); + }) + .catch(() => { + timeoutId = setTimeout(tick, LIVE_TEXT_POLL_INTERVAL_MS); + }); + }; + + timeoutId = setTimeout(tick, LIVE_TEXT_POLL_INTERVAL_MS); + return () => { + cancelled = true; + if (timeoutId) clearTimeout(timeoutId); + }; + }, [activeCallLogId]); + + // Deep link: /dashboard/conversations?tree= opens that conversation. + // Prefer the already-loaded row (has lastCallLogId for "Goto latest + // request"); fall back to a minimal row if the conversation isn't in the + // current page of the list (still fully works — the API only needs the + // id). + useEffect(() => { + if (!initialConversationParam || initialConversationOpenedRef.current || loading) return; + initialConversationOpenedRef.current = true; + const found = conversations.find((c) => c.id === initialConversationParam); + openConversation( + found ?? { + id: initialConversationParam, + turnCount: 0, + firstSeenAt: "", + lastSeenAt: "", + lastCallLogId: null, + lastModel: null, + lastProvider: null, + lastStatus: null, + isActive: false, + activeCallLogId: null, + } + ); + }, [initialConversationParam, loading, conversations, openConversation]); + + const gotoLatestRequest = () => { + const id = activeConversation?.lastCallLogId; + if (!id) return; + closeConversation(); + openById(id).catch(() => {}); + }; + + // Previous/Next navigate to the adjacent row in the currently loaded list — + // same idea as RequestLoggerDetail's onPrevious/onNext, but one level up + // (between conversations, not between requests within one). Index is + // recomputed from `conversations` on every click rather than memoized: the + // list refreshes under a poll while the modal is open (see the resync + // effect above), so a stale captured index could skip/repeat a row. + const activeConversationIndex = activeConversation + ? conversations.findIndex((c) => c.id === activeConversation.id) + : -1; + const hasPreviousConversation = activeConversationIndex > 0; + const hasNextConversation = + activeConversationIndex !== -1 && activeConversationIndex < conversations.length - 1; + + const goToPreviousConversation = useCallback(() => { + const index = conversations.findIndex((c) => c.id === activeConversation?.id); + if (index <= 0) return; + openConversation(conversations[index - 1]); + }, [conversations, activeConversation, openConversation]); + + const goToNextConversation = useCallback(() => { + const index = conversations.findIndex((c) => c.id === activeConversation?.id); + if (index === -1 || index >= conversations.length - 1) return; + openConversation(conversations[index + 1]); + }, [conversations, activeConversation, openConversation]); + + return ( +
+
+

Conversations

+
+ + {total} conversation{total === 1 ? "" : "s"} with 2+ turns + + +
+
+ + {loading && conversations.length === 0 && ( +
+ Loading conversations... +
+ )} + + {!loading && conversations.length === 0 && ( +
+ No multi-turn conversations yet. +
+ )} + + {conversations.length > 0 && ( + <> + {/* Mobile: stacked cards — avoids the horizontal-scroll table entirely on + narrow viewports instead of squeezing 6 columns into one row. */} +
+ {conversations.map((row) => ( +
openConversation(row)} + className="rounded-xl border border-border p-3 flex flex-col gap-2 active:bg-bg-subtle cursor-pointer" + > +
+ + {row.isActive && } + { + e.stopPropagation(); + copyToClipboard(row.id); + }} + className="font-mono text-[11px] text-text-main hover:underline truncate" + > + {row.id.slice(0, 16)}… + + + + {row.turnCount} turns + +
+
+
+ {row.lastModel ?? "—"} + +
+ +
+
+ {formatTime(row.lastSeenAt)} +
+
+ ))} +
+ + {/* Desktop/tablet: full table */} +
+ + + + + + + + + + + + + {conversations.map((row) => ( + openConversation(row)} + > + + + + + + + + ))} + +
ConversationTurnsLast ModelProviderStatusLast Seen
+ + {row.isActive && } + { + e.stopPropagation(); + copyToClipboard(row.id); + }} + className="hover:underline" + > + {row.id.slice(0, 16)}… + + + + {row.turnCount} + {row.lastModel ?? "—"} + + + + + {formatTime(row.lastSeenAt)} +
+
+ + )} + + {activeConversation && ( +
+
+
e.stopPropagation()} + > +
+
+

Conversation

+ + {activeConversation.id.slice(0, 24)}… + +
+
+ + + {activeConversation.lastCallLogId && ( + + )} + + +
+
+
+ {conversationLoading ? ( +
Loading…
+ ) : ( + + )} +
+
+
+ )} + + {selectedLog && ( + + )} +
+ ); +} + +export default function ConversationsPage() { + return ( + + Loading conversations... +
+ } + > + + + ); +} diff --git a/src/app/(dashboard)/dashboard/playground/components/MarkdownMessage.tsx b/src/app/(dashboard)/dashboard/playground/components/MarkdownMessage.tsx index 7dacd87837..38a74a8867 100644 --- a/src/app/(dashboard)/dashboard/playground/components/MarkdownMessage.tsx +++ b/src/app/(dashboard)/dashboard/playground/components/MarkdownMessage.tsx @@ -148,7 +148,11 @@ export default function MarkdownMessage({ content, className }: MarkdownMessageP }; return ( -
+ // break-words: long unspaced runs (raw JSON, ids, tokens) have no natural + // wrap point, so without it they overflow their container instead of + // wrapping — invisible in a wide full-page layout, glaring in a narrower + // one (e.g. the conversation tree modal). +
{content} diff --git a/src/app/(dashboard)/dashboard/tools/traffic-inspector/components/chat/ChatBubble.tsx b/src/app/(dashboard)/dashboard/tools/traffic-inspector/components/chat/ChatBubble.tsx index 496cadd38a..fbc67c8b27 100644 --- a/src/app/(dashboard)/dashboard/tools/traffic-inspector/components/chat/ChatBubble.tsx +++ b/src/app/(dashboard)/dashboard/tools/traffic-inspector/components/chat/ChatBubble.tsx @@ -4,10 +4,18 @@ import { useState } from "react"; import { useTranslations } from "next-intl"; import type { NormalizedTurn } from "@/mitm/inspector/types"; import { cn } from "@/shared/utils/cn"; +import { formatTime } from "@/shared/utils/formatting"; import { MessageContent } from "./MessageContent"; interface ChatBubbleProps { turn: NormalizedTurn; + /** Optional — makes the bubble clickable when a caller has somewhere to + * navigate to for this turn (e.g. a tree/list view linking back to the + * request that produced it). */ + onClick?: () => void; + /** True when this turn belongs to the request currently open — shown + * highlighted instead of clickable (nowhere further to navigate to). */ + isCurrent?: boolean; } const ROLE_STYLES: Record = { @@ -24,27 +32,41 @@ const ROLE_LABEL_KEY: Record = { tool: "roleTool", }; -export function ChatBubble({ turn }: ChatBubbleProps) { +export function ChatBubble({ turn, onClick, isCurrent }: ChatBubbleProps) { const t = useTranslations("trafficInspector"); const [collapsed, setCollapsed] = useState(turn.role === "system"); const isSystem = turn.role === "system"; const isUser = turn.role === "user"; + const clickable = Boolean(onClick) && !isCurrent; return (
- {t(ROLE_LABEL_KEY[turn.role])} +
+ {t(ROLE_LABEL_KEY[turn.role])} + {turn.timestamp && ( + {formatTime(turn.timestamp)} + )} +
{isSystem && ( + )} +
+ +
+ {open && ( +
+          {json}
+        
+ )} +
+ ); +} + +// ─── Conversation context section ─────────────────────────────────────────── +// Renders THIS request's own context (its request body's messages/input, plus +// its response) — a plain single-request normalization, same shape as the +// traffic-inspector's ConversationTab, no cross-request reconstruction. While +// the request is still generating (detail.active === true) the response side +// shows the partial text captured so far, refreshed on a short poll scoped to +// just this section. +const CONVERSATION_ACTIVE_POLL_INTERVAL_MS = 1200; + +function asInterceptedResponseBody(responseBody: unknown): InterceptedRequest { + return { + id: "", + source: "custom-host", + timestamp: "", + method: "POST", + host: "", + path: "", + requestHeaders: {}, + requestBody: null, + requestSize: 0, + responseHeaders: {}, + responseBody: responseBody != null ? JSON.stringify(responseBody) : null, + responseSize: 0, + status: 0, + detectedKind: "llm", + }; +} + +export function ConversationContextSection({ log, detail }) { + const [open, setOpen] = useState(true); + const [liveDetail, setLiveDetail] = useState(detail); + const [liveRefresh, setLiveRefresh] = useState(() => { + try { + const v = localStorage.getItem("pref:conversationContext:liveRefresh"); + return v == null ? true : v === "1"; + } catch { + return true; + } + }); + const turnsBoxRef = useRef(null); + + useEffect(() => { + setLiveDetail(detail); + }, [detail]); + + // Same live-poll pattern as the SSE Events section (StreamSection below), + // but gated on liveRefresh too: an active request keeps generating either + // way, this toggle only controls whether THIS panel keeps fetching/ + // redrawing while the user reads it. + useEffect(() => { + if (!liveDetail?.active || !liveRefresh) return; + let cancelled = false; + let timeoutId: ReturnType | undefined; + + const tick = () => { + if (cancelled) return; + if (document.visibilityState !== "visible") { + timeoutId = setTimeout(tick, CONVERSATION_ACTIVE_POLL_INTERVAL_MS); + return; + } + fetch(`/api/logs/${log.id}`, { cache: "no-store" }) + .then((res) => (res.ok ? res.json() : null)) + .then((data) => { + if (cancelled || !data) return; + setLiveDetail(data); + if (data.active) timeoutId = setTimeout(tick, CONVERSATION_ACTIVE_POLL_INTERVAL_MS); + }) + .catch(() => { + timeoutId = setTimeout(tick, CONVERSATION_ACTIVE_POLL_INTERVAL_MS); + }); + }; + + timeoutId = setTimeout(tick, CONVERSATION_ACTIVE_POLL_INTERVAL_MS); + return () => { + cancelled = true; + if (timeoutId) clearTimeout(timeoutId); + }; + }, [liveDetail?.active, liveRefresh, log.id]); + + const toggleLiveRefresh = () => { + const next = !liveRefresh; + setLiveRefresh(next); + try { + localStorage.setItem("pref:conversationContext:liveRefresh", next ? "1" : "0"); + } catch {} + }; + + const scrollToBottom = () => { + const el = turnsBoxRef.current; + if (!el) return; + requestAnimationFrame(() => { + try { + el.scrollTop = el.scrollHeight; + } catch {} + }); + }; + + const requestBody = + liveDetail?.requestBody ?? liveDetail?.pipelinePayloads?.clientRequest ?? null; + const requestTurns = buildRequestTurns(requestBody) ?? []; + + const responseBody = liveDetail?.responseBody ?? null; + const responseTurns: NormalizedTurn[] = + responseBody != null + ? buildResponseTurns(asInterceptedResponseBody(responseBody)) + : liveDetail?.partialAssistantText + ? [ + { + role: "assistant", + blocks: [{ type: "text", text: liveDetail.partialAssistantText }], + }, + ] + : []; + + const allTurns: NormalizedTurn[] = [...requestTurns, ...responseTurns]; + + // Follow new content as it streams in — same idea as StreamSection's + // autoscroll effect, tied to the same liveRefresh toggle. + useEffect(() => { + if (!liveRefresh || !open) return; + scrollToBottom(); + }, [allTurns.length, liveDetail?.partialAssistantText, liveRefresh, open]); + + if (allTurns.length === 0) return null; + + return ( +
+
+
+

+ Conversation Context +

+ +
+ {open && ( +
+ {liveDetail?.active && ( + + )} + +
+ )} +
+ {open && ( +
+ {allTurns.map((turn, i) => ( + + ))} +
+ )} +
+ ); +} diff --git a/src/shared/components/RequestLoggerDetail.tsx b/src/shared/components/RequestLoggerDetail.tsx index 6938e1da2f..02107bdf22 100644 --- a/src/shared/components/RequestLoggerDetail.tsx +++ b/src/shared/components/RequestLoggerDetail.tsx @@ -9,60 +9,10 @@ import { } from "@/shared/constants/colors"; import { formatDuration, formatApiKeyLabel, maskAccount } from "@/shared/utils/formatting"; import { formatErrorForDisplay } from "@/shared/utils/formatting"; - -// ─── Payload Code Block ───────────────────────────────────────────────────── - -function PayloadSection({ title, json, onCopy, collapsible = true, defaultOpen = true }) { - const t = useTranslations("requestLogger.detail"); - const [copied, setCopied] = useState(false); - const [open, setOpen] = useState(defaultOpen); - - const handleCopy = async () => { - const success = await onCopy(); - if (success !== false) { - setCopied(true); - setTimeout(() => setCopied(false), 2000); - } - }; - - return ( -
-
-
-

- {title} -

- {collapsible && ( - - )} -
- -
- {open && ( -
-          {json}
-        
- )} -
- ); -} +import { + PayloadSection, + ConversationContextSection, +} from "@/shared/components/RequestLoggerDetail.sections"; // ─── Stream section + Detail Modal ─────────────────────────────────────────────────────────── @@ -354,7 +304,7 @@ export default function RequestLoggerDetail({ const codexAccountRotation = getCodexAccountRotation(detail); return (
e.stopPropagation()} > {/* Modal Header */} -
-
+
+
{log.active ? ( @@ -414,23 +364,31 @@ export default function RequestLoggerDetail({ )}
-
- - +
+ {/* Only rendered when a caller actually wires up navigation (RequestLoggerV2's + list view) — a caller with no ordered-list context to navigate through + (conversations page, RequestTimeline) passes neither, so there's nothing + to show instead of a permanently-disabled dead button. */} + {(onPrevious || onNext) && ( + <> + + + + )}
-
+
{/* Metadata Grid */} {log.active ? (
@@ -868,6 +826,8 @@ export default function RequestLoggerDetail({
) : ( <> + + {streamChunks && streamChunks.provider && ( (null); const [visibleColumns, setVisibleColumns] = useState(() => { const defaultVisible = Object.fromEntries(columns.map((c) => [c.key, true])); @@ -750,9 +757,14 @@ const RequestLoggerV2 = forwardRef { const idx = currentLogIndex; @@ -764,10 +776,44 @@ const RequestLoggerV2 = forwardRef { console.error("Failed to open previous log id:", error_); }); + } else { + pendingBoundaryNavRef.current = "next"; + fetchLogs(false); + } + }, [currentLogIndex, sortedLogsForNav, fetchLogs]); + + // Resolves a pending boundary nav (see handlePrev/handleNext) once a + // triggered fetchLogs() resync has landed in sortedLogsForNav. Only fires + // when a boundary nav is actually pending, so this is a no-op on the + // normal (paused-while-modal-open) list-update cadence. + useEffect(() => { + const direction = pendingBoundaryNavRef.current; + if (!direction || !selectedLog) return; + pendingBoundaryNavRef.current = null; + const idx = sortedLogsForNav.findIndex((l) => l.id === selectedLog.id); + const target = + direction === "prev" + ? idx > 0 + ? sortedLogsForNav[idx - 1] + : null + : idx >= 0 && idx < sortedLogsForNav.length - 1 + ? sortedLogsForNav[idx + 1] + : null; + if (target?.id) { + openDetail(target) + .then((r) => r) + .catch((error_) => { + console.error("Failed to open adjacent log id:", error_); + }); } else { closeDetail(); } - }, [currentLogIndex, sortedLogsForNav]); + // openDetail/closeDetail are plain functions re-created every render + // (same as handlePrev/handleNext above and the rest of this file) — + // listing them would re-fire this effect on every render instead of + // only when sortedLogsForNav/selectedLog actually change. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [sortedLogsForNav, selectedLog]); const toggleDetailLogging = async () => { setDetailLoggingLoading(true); @@ -1241,6 +1287,9 @@ const RequestLoggerV2 = forwardRef )} + {visibleColumns.conversation && ( + {t("columns.conversation")} + )} @@ -1588,6 +1637,15 @@ const RequestLoggerV2 = forwardRef )} + {visibleColumns.conversation && ( + + {log.sessionTag ? ( + {log.sessionTag.slice(0, 12)}… + ) : ( + + )} + + )} ); })} diff --git a/src/shared/components/RequestTimeline.tsx b/src/shared/components/RequestTimeline.tsx index cd5acd39df..af4a061d09 100644 --- a/src/shared/components/RequestTimeline.tsx +++ b/src/shared/components/RequestTimeline.tsx @@ -3,134 +3,35 @@ import { useState, useEffect, useRef, useCallback, useMemo } from "react"; import { useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; -import { getHttpStatusStyle } from "@/shared/constants/colors"; import { copyToClipboard } from "@/shared/utils/clipboard"; import RequestLoggerDetail from "@/shared/components/RequestLoggerDetail"; +import useEmailPrivacyStore from "@/store/emailPrivacyStore"; +import { + type TimelineLog, + type ViewMode, + VISIBLE_WINDOW_MS, + BAR_HEIGHT, + LANE_GAP, + LANE_HEIGHT, + HEADER_HEIGHT, + AXIS_HEIGHT, + MIN_BAR_WIDTH, + DEFAULT_LIST_POLL_SECONDS, + TIMELINE_LIST_POLL_STORAGE_KEY, + FOLLOW_LINE_X, + LIVE_LINE_FRACTION, + computeBarRange, + MODE_META, + formatTimeAxis, + getStatusColor, + CONVERSATION_LANE_REUSE_STORAGE_KEY, + allocateLanes, + truncateModel, + formatDateLabel, +} from "@/shared/components/RequestTimeline.utils"; -interface TimelineLog { - id: string; - timestamp: string; - status: number; - model: string | null; - provider: string | null; - account: string | null; - duration: number; - tokens: { in: number; out: number }; - active?: boolean; - completed?: boolean; - error?: string | null; - path?: string | null; -} - -interface Lane { - startMs: number; - endMs: number; -} - -type ViewMode = "follow" | "live" | "pan"; - -const VISIBLE_WINDOW_MS = 5 * 60 * 1000; -const BAR_HEIGHT = 28; -const LANE_GAP = 4; -const LANE_HEIGHT = BAR_HEIGHT + LANE_GAP; -const HEADER_HEIGHT = 48; -const AXIS_HEIGHT = 32; -const MIN_BAR_WIDTH = 3; -const POLL_INTERVAL_MS = 2000; -const FOLLOW_LINE_X = 0.75; -const LIVE_LINE_FRACTION = 0.9; - -function computeBarRange(log: TimelineLog, nowMs: number): { startMs: number; endMs: number } { - const ts = new Date(log.timestamp).getTime(); - if (log.active) return { startMs: ts, endMs: nowMs }; - if (log.completed) return { startMs: ts, endMs: ts + (log.duration || 0) }; - return { startMs: ts - (log.duration || 0), endMs: ts }; -} - -const MODE_META: Record = { - follow: { - labelKey: "follow", - descriptionKey: "followDescription", - }, - live: { - labelKey: "now", - descriptionKey: "nowDescription", - }, - pan: { - labelKey: "pan", - descriptionKey: "panDescription", - }, -}; - -function formatTimeAxis(ms: number): string { - const d = new Date(ms); - const h = d.getHours().toString().padStart(2, "0"); - const m = d.getMinutes().toString().padStart(2, "0"); - const s = d.getSeconds().toString().padStart(2, "0"); - return `${h}:${m}:${s}`; -} - -function getStatusColor(status: number, active: boolean | undefined): string { - if (active) return "#6366F1"; - return getHttpStatusStyle(status).bg; -} - -function allocateLanes(items: TimelineLog[], nowMs: number): Map { - const lanes: Lane[] = []; - const laneMap = new Map(); - - const sorted = [...items].sort((a, b) => { - const aStart = new Date(a.timestamp).getTime(); - const bStart = new Date(b.timestamp).getTime(); - return aStart - bStart; - }); - - for (const item of sorted) { - const { startMs, endMs } = computeBarRange(item, nowMs); - - let placed = false; - for (let i = 0; i < lanes.length; i++) { - if (lanes[i].endMs < startMs) { - lanes[i] = { startMs, endMs }; - laneMap.set(item.id, i); - placed = true; - break; - } - } - if (!placed) { - laneMap.set(item.id, lanes.length); - lanes.push({ startMs, endMs }); - } - } - - return laneMap; -} - -function truncateModel(model: string | null): string { - if (!model) return ""; - const parts = model.split("/"); - const short = parts[parts.length - 1]; - return short.length > 16 ? short.slice(0, 15) + "\u2026" : short; -} - -function formatDateLabel(ms: number): string { - const d = new Date(ms); - const months = [ - "Jan", - "Feb", - "Mar", - "Apr", - "May", - "Jun", - "Jul", - "Aug", - "Sep", - "Oct", - "Nov", - "Dec", - ]; - return `${months[d.getMonth()]} ${d.getDate()}`; -} +export type { TimelineLog } from "@/shared/components/RequestTimeline.utils"; +export { allocateLanes } from "@/shared/components/RequestTimeline.utils"; export default function RequestTimeline({ initialSelectedId, @@ -152,9 +53,31 @@ export default function RequestTimeline({ const [isDragging, setIsDragging] = useState(false); const [dragStartX, setDragStartX] = useState(0); const [dragStartOffset, setDragStartOffset] = useState(0); + const { emailsVisible } = useEmailPrivacyStore(); const [selectedLog, setSelectedLog] = useState(null); const [detailData, setDetailData] = useState(null); const [detailLoading, setDetailLoading] = useState(false); + const [detailLoggingEnabled, setDetailLoggingEnabled] = useState(false); + const [conversationLaneReuseMinutes, setConversationLaneReuseMinutes] = useState(() => { + if (globalThis.window === undefined) return 2; + try { + const saved = localStorage.getItem(CONVERSATION_LANE_REUSE_STORAGE_KEY); + const parsed = saved ? Number(saved) : 2; + return Number.isFinite(parsed) && parsed > 0 ? parsed : 2; + } catch { + return 2; + } + }); + const [listPollSeconds, setListPollSeconds] = useState(() => { + if (globalThis.window === undefined) return DEFAULT_LIST_POLL_SECONDS; + try { + const saved = localStorage.getItem(TIMELINE_LIST_POLL_STORAGE_KEY); + const parsed = saved ? Number(saved) : DEFAULT_LIST_POLL_SECONDS; + return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_LIST_POLL_SECONDS; + } catch { + return DEFAULT_LIST_POLL_SECONDS; + } + }); const canvasRef = useRef(null); const animRef = useRef(0); // Guards the ?id= deep-link mount effect below. Also armed by any manual @@ -164,6 +87,16 @@ export default function RequestTimeline({ // reopen the modal right after the user closed it. const initialOpenedRef = useRef(false); + useEffect(() => { + fetch("/api/logs/detail?limit=1") + .then((res) => (res.ok ? res.json() : null)) + .then((data) => { + if (!data) return; + setDetailLoggingEnabled(data.enabled === true); + }) + .catch(() => {}); + }, []); + useEffect(() => { let cancelled = false; fetch("/api/usage/call-logs?limit=200") @@ -181,12 +114,12 @@ export default function RequestTimeline({ .then((res) => (res.ok ? res.json() : [])) .then((data) => setLogs(data)) .catch(() => {}); - }, POLL_INTERVAL_MS); + }, listPollSeconds * 1000); return () => { cancelled = true; clearInterval(id); }; - }, []); + }, [listPollSeconds]); useEffect(() => { if (!canvasRef.current) return undefined; @@ -252,7 +185,10 @@ export default function RequestTimeline({ }); }, [logs, timeStart, timeEnd, nowMs]); - const laneMap = useMemo(() => allocateLanes(logs, nowMs), [logs, nowMs]); + const laneMap = useMemo( + () => allocateLanes(logs, nowMs, conversationLaneReuseMinutes * 60 * 1000), + [logs, nowMs, conversationLaneReuseMinutes] + ); const maxLane = useMemo(() => (laneMap.size > 0 ? Math.max(...laneMap.values()) : 0), [laneMap]); const barElements = useMemo(() => { @@ -273,6 +209,39 @@ export default function RequestTimeline({ }); }, [visibleLogs, timeStart, timeEnd, nowMs, laneMap, canvasWidth]); + // One connector per consecutive pair of bars sharing a conversation id AND + // lane (i.e. allocateLanes actually treated them as one continuous + // conversation, not two bars that just happen to be adjacent). + const connectorElements = useMemo(() => { + const byConversation = new Map(); + for (const el of barElements) { + const cid = el.log.sessionTag; + if (!cid) continue; + const list = byConversation.get(cid); + if (list) list.push(el); + else byConversation.set(cid, [el]); + } + + const connectors: { id: string; x1: number; x2: number; y: number }[] = []; + for (const els of byConversation.values()) { + const sorted = [...els].sort( + (a, b) => new Date(a.log.timestamp).getTime() - new Date(b.log.timestamp).getTime() + ); + for (let i = 0; i < sorted.length - 1; i++) { + const a = sorted[i]; + const b = sorted[i + 1]; + if (a.topPx !== b.topPx) continue; // different lanes — reuse window lapsed + connectors.push({ + id: `${a.log.id}-${b.log.id}`, + x1: a.leftPct + a.widthPct, + x2: b.leftPct, + y: a.topPx + BAR_HEIGHT / 2, + }); + } + } + return connectors; + }, [barElements]); + const axisTicks = useMemo(() => { const totalMs = timeEnd - timeStart; if (totalMs <= 0) return []; @@ -374,33 +343,43 @@ export default function RequestTimeline({ // Deep-link support: open the request from ?id= on mount without waiting for // it to show up in the polled `logs` list (mirrors RequestLoggerV2's openDetail). - const openById = useCallback(async (id: string) => { - setDetailLoading(true); - try { - const res = await fetch(`/api/logs/${id}`, { cache: "no-store" }); - const data = res.ok ? await res.json() : null; - if (data) { - setSelectedLog({ - id: data.id ?? id, - timestamp: data.timestamp, - status: data.status ?? 0, - model: data.model ?? null, - provider: data.provider ?? null, - account: data.account ?? null, - duration: data.duration ?? 0, - tokens: data.tokens ?? { in: 0, out: 0 }, - active: data.active, - error: data.error ?? null, - path: data.path ?? null, - }); - setDetailData(data); + const openById = useCallback( + async (id: string) => { + try { + const url = new URL(globalThis.location.href); + url.searchParams.set("id", id); + router.replace(url.pathname + url.search); + } catch { + // ignore navigation errors } - } catch { - // ignore fetch errors - } finally { - setDetailLoading(false); - } - }, []); + setDetailLoading(true); + try { + const res = await fetch(`/api/logs/${id}`, { cache: "no-store" }); + const data = res.ok ? await res.json() : null; + if (data) { + setSelectedLog({ + id: data.id ?? id, + timestamp: data.timestamp, + status: data.status ?? 0, + model: data.model ?? null, + provider: data.provider ?? null, + account: data.account ?? null, + duration: data.duration ?? 0, + tokens: data.tokens ?? { in: 0, out: 0 }, + active: data.active, + error: data.error ?? null, + path: data.path ?? null, + }); + setDetailData(data); + } + } catch { + // ignore fetch errors + } finally { + setDetailLoading(false); + } + }, + [router] + ); useEffect(() => { if (!initialSelectedId || initialOpenedRef.current) return; @@ -576,6 +555,51 @@ export default function RequestTimeline({ > {t("reset")} + {/* Conversation lane-reuse window: how long a lane stays reserved + for its conversation before falling back to normal packing. */} + + {/* How often the timeline re-polls /api/usage/call-logs for new rows. */} + {/* Zoom */}
))} + + {/* Conversation connectors — one arrow per consecutive same-conversation + bar pair sharing a lane. */} + + + + + + + {connectorElements.map(({ id, x1, x2, y }) => ( + + ))} +
{/* NOW line — full height of the canvas, outside content div */} @@ -828,8 +893,8 @@ export default function RequestTimeline({ log={selectedLog as any} detail={detailData} loading={detailLoading} - debugEnabled={false} - emailsVisible={false} + debugEnabled={selectedLog?.active ? true : detailLoggingEnabled} + emailsVisible={emailsVisible} onClose={closeDetail} onCopy={copyToClipboard} onPrevious={undefined} diff --git a/src/shared/components/RequestTimeline.utils.ts b/src/shared/components/RequestTimeline.utils.ts new file mode 100644 index 0000000000..c944ea9cec --- /dev/null +++ b/src/shared/components/RequestTimeline.utils.ts @@ -0,0 +1,169 @@ +import { getHttpStatusStyle } from "@/shared/constants/colors"; + +export interface TimelineLog { + id: string; + timestamp: string; + status: number; + model: string | null; + provider: string | null; + account: string | null; + duration: number; + tokens: { in: number; out: number }; + active?: boolean; + completed?: boolean; + error?: string | null; + path?: string | null; + /** Conversation id (X-ConversationId) — same field as call_logs.session_tag. */ + sessionTag?: string | null; +} + +export interface Lane { + startMs: number; + endMs: number; +} + +export type ViewMode = "follow" | "live" | "pan"; + +export const VISIBLE_WINDOW_MS = 5 * 60 * 1000; +export const BAR_HEIGHT = 28; +export const LANE_GAP = 4; +export const LANE_HEIGHT = BAR_HEIGHT + LANE_GAP; +export const HEADER_HEIGHT = 48; +export const AXIS_HEIGHT = 32; +export const MIN_BAR_WIDTH = 3; +export const DEFAULT_LIST_POLL_SECONDS = 2; +export const TIMELINE_LIST_POLL_STORAGE_KEY = "timelineListPollSeconds"; +export const FOLLOW_LINE_X = 0.75; +export const LIVE_LINE_FRACTION = 0.9; + +export function computeBarRange( + log: TimelineLog, + nowMs: number +): { startMs: number; endMs: number } { + const ts = new Date(log.timestamp).getTime(); + if (log.active) return { startMs: ts, endMs: nowMs }; + if (log.completed) return { startMs: ts, endMs: ts + (log.duration || 0) }; + return { startMs: ts - (log.duration || 0), endMs: ts }; +} + +export const MODE_META: Record = { + follow: { + labelKey: "follow", + descriptionKey: "followDescription", + }, + live: { + labelKey: "now", + descriptionKey: "nowDescription", + }, + pan: { + labelKey: "pan", + descriptionKey: "panDescription", + }, +}; + +export function formatTimeAxis(ms: number): string { + const d = new Date(ms); + const h = d.getHours().toString().padStart(2, "0"); + const m = d.getMinutes().toString().padStart(2, "0"); + const s = d.getSeconds().toString().padStart(2, "0"); + return `${h}:${m}:${s}`; +} + +export function getStatusColor(status: number, active: boolean | undefined): string { + if (active) return "#6366F1"; + return getHttpStatusStyle(status).bg; +} + +export const DEFAULT_CONVERSATION_LANE_REUSE_WINDOW_MS = 2 * 60 * 1000; + +// Exported so other components (e.g. the "Full Conversation" transcript panel +// in RequestLoggerDetail.tsx) can decide "is this conversation still in +// progress" using the SAME setting as the timeline's lane-reuse window, +// rather than a separate, potentially-inconsistent one. +export const CONVERSATION_LANE_REUSE_STORAGE_KEY = "timelineConversationLaneReuseMinutes"; + +/** + * Assigns each item a lane (row) index. Items sharing a `sessionTag` + * (conversation id) are forced onto the same lane as long as the gap since + * that lane's last item is within `reuseWindowMs` — after that, the lane is + * free again and falls back to the normal greedy overlap-avoidance packing + * below (unrelated to any conversation). + */ +export function allocateLanes( + items: TimelineLog[], + nowMs: number, + reuseWindowMs: number = DEFAULT_CONVERSATION_LANE_REUSE_WINDOW_MS +): Map { + const lanes: Lane[] = []; + const laneConversation: (string | null)[] = []; + const laneMap = new Map(); + + const sorted = [...items].sort((a, b) => { + const aStart = new Date(a.timestamp).getTime(); + const bStart = new Date(b.timestamp).getTime(); + return aStart - bStart; + }); + + for (const item of sorted) { + const { startMs, endMs } = computeBarRange(item, nowMs); + const conversationId = item.sessionTag || null; + + let placed = false; + + if (conversationId) { + for (let i = 0; i < lanes.length; i++) { + if (laneConversation[i] === conversationId && startMs - lanes[i].endMs <= reuseWindowMs) { + lanes[i] = { startMs, endMs }; + laneMap.set(item.id, i); + placed = true; + break; + } + } + } + + if (!placed) { + for (let i = 0; i < lanes.length; i++) { + if (lanes[i].endMs < startMs) { + lanes[i] = { startMs, endMs }; + laneConversation[i] = conversationId; + laneMap.set(item.id, i); + placed = true; + break; + } + } + } + if (!placed) { + laneMap.set(item.id, lanes.length); + laneConversation.push(conversationId); + lanes.push({ startMs, endMs }); + } + } + + return laneMap; +} + +export function truncateModel(model: string | null): string { + if (!model) return ""; + const parts = model.split("/"); + const short = parts[parts.length - 1]; + return short.length > 16 ? short.slice(0, 15) + "…" : short; +} + +export function formatDateLabel(ms: number): string { + const d = new Date(ms); + const months = [ + "Jan", + "Feb", + "Mar", + "Apr", + "May", + "Jun", + "Jul", + "Aug", + "Sep", + "Oct", + "Nov", + "Dec", + ]; + return `${months[d.getMonth()]} ${d.getDate()}`; +} diff --git a/src/shared/constants/sidebarVisibility/sections.ts b/src/shared/constants/sidebarVisibility/sections.ts index 9056104dbd..967b64215f 100644 --- a/src/shared/constants/sidebarVisibility/sections.ts +++ b/src/shared/constants/sidebarVisibility/sections.ts @@ -410,6 +410,13 @@ const LOGS_GROUP: SidebarItemGroup = { subtitleKey: "logsTimelineSubtitle", icon: "view_timeline", }, + { + id: "conversations", + href: "/dashboard/conversations", + i18nKey: "conversations", + subtitleKey: "conversationsSubtitle", + icon: "forum", + }, ], }; diff --git a/src/shared/constants/sidebarVisibility/types.ts b/src/shared/constants/sidebarVisibility/types.ts index 9bacd7ba88..3bb6330ed0 100644 --- a/src/shared/constants/sidebarVisibility/types.ts +++ b/src/shared/constants/sidebarVisibility/types.ts @@ -55,6 +55,7 @@ export const HIDEABLE_SIDEBAR_ITEM_IDS = [ "logs-proxy", "logs-console", "logs-timeline", + "conversations", "logs-activity", "health", "runtime", diff --git a/src/sse/handlers/chat.ts b/src/sse/handlers/chat.ts index b32daa9f78..b8aff4cd2a 100644 --- a/src/sse/handlers/chat.ts +++ b/src/sse/handlers/chat.ts @@ -95,8 +95,10 @@ import { withSelectedConnectionHeader, withCorrelationId, withModalityBridgeHeader, + withConversationId, } from "./chatHelpers"; import { buildModalityBridgeHeader } from "@/lib/guardrails/modalityBridge/bridgeStats"; +import { resolveConversationId } from "@omniroute/open-sse/services/conversationTracker.ts"; import { isAntigravityMissingProjectError, isProviderBreakerFailureStatus, @@ -726,6 +728,30 @@ async function handleChatImplementation( const modalityBridgeHeader = buildModalityBridgeHeader(preCallGuardrails.results); telemetry.endPhase(); + // Agentic conversation tracking (X-ConversationId): resolved once per + // incoming HTTP request, before combo dispatch / credential retries, so + // every attempt for this request shares the same id and the + // agentic_conversations row is only touched once. + const clientConversationHeader = request.headers.get("x-omniroute-session-id")?.trim() || null; + let conversationId: string | null = null; + try { + ({ conversationId } = await resolveConversationId({ + body: body as Record, + model: modelStr, + apiKeyId: apiKeyInfo?.id ?? null, + clientSessionIdHeader: clientConversationHeader, + correlationId: reqId, + })); + } catch (error) { + // Best-effort tracking: a DB hiccup here must not turn an otherwise-working + // chat request into a hard failure. Downstream conversationId consumers + // already treat null/undefined as "untracked" (see withConversationId). + log.warn("CHAT", "resolveConversationId failed, continuing without conversation tracking", { + correlationId: reqId, + error: error instanceof Error ? error.message : String(error), + }); + } + // T08: per-key active session limit (0 = unlimited). if (apiKeyInfo?.id && sessionId) { const maxSessions = @@ -1050,6 +1076,7 @@ async function handleChatImplementation( cachedSettings: settings, providerId: target?.providerId ?? null, correlationId: reqId, + conversationId, modelPinned: (target as any)?.modelPinned ?? false, reasoningDecision, reasoningIntent, @@ -1123,6 +1150,7 @@ async function handleChatImplementation( sessionAffinityKey, emergencyFallbackTried: true, forceLiveComboTest: isComboLiveTest, + conversationId, managedLease, }, combo.strategy, @@ -1132,7 +1160,7 @@ async function handleChatImplementation( log.info("GLOBAL_FALLBACK", `Global fallback ${fallbackModel} succeeded`); recordTelemetry(telemetry); return withModalityBridgeHeader( - withSessionHeader(fallbackResponse, sessionId), + withConversationId(withSessionHeader(fallbackResponse, sessionId), conversationId), modalityBridgeHeader ); } @@ -1166,13 +1194,17 @@ async function handleChatImplementation( apiKeyId: apiKeyInfo?.id ?? null, apiKeyName: apiKeyInfo?.name ?? null, correlationId: reqId, + sessionTag: conversationId, startTime: telemetry?.startTime, requestBody: clientRawRequest?.body ?? null, }); } catch {} } return withModalityBridgeHeader( - withCorrelationId(withSessionHeader(response, sessionId), reqId), + withConversationId( + withCorrelationId(withSessionHeader(response, sessionId), reqId), + conversationId + ), modalityBridgeHeader ); } @@ -1207,6 +1239,7 @@ async function handleChatImplementation( forceLiveComboTest: isComboLiveTest, forcedConnectionId: requestedConnectionId, correlationId: reqId, + conversationId, routingComboId, reasoningDecision, reasoningIntent, @@ -1218,7 +1251,10 @@ async function handleChatImplementation( ); recordTelemetry(telemetry); return withModalityBridgeHeader( - withCorrelationId(withSessionHeader(response, sessionId), reqId), + withConversationId( + withCorrelationId(withSessionHeader(response, sessionId), reqId), + conversationId + ), modalityBridgeHeader ); } @@ -1249,6 +1285,7 @@ async function handleSingleModelChat( cachedSettings?: any; providerId?: string | null; correlationId?: string | null; + conversationId?: string | null; routingComboId?: string | null; modelPinned?: boolean; reasoningDecision?: ReasoningRuleDecision | null; @@ -1328,6 +1365,7 @@ async function handleSingleModelChat( allowRateLimitedConnection: resolvedTarget?.allowRateLimitedConnection === true, providerId: resolvedTarget?.providerId ?? null, correlationId: runtimeOptions?.correlationId ?? null, + conversationId: runtimeOptions?.conversationId ?? null, managedLease: runtimeOptions.managedLease ?? null, // #7360 follow-up — see the primary handleSingleModel closure above. modelAbortSignal: target?.modelAbortSignal ?? null, @@ -1431,6 +1469,7 @@ async function handleSingleModelChat( apiKeyId: apiKeyInfo?.id ?? null, apiKeyName: apiKeyInfo?.name ?? null, correlationId: runtimeOptions?.correlationId ?? null, + sessionTag: runtimeOptions?.conversationId ?? null, startTime: telemetry?.startTime, }); } catch {} @@ -1787,6 +1826,7 @@ async function handleSingleModelChat( cachedSettings: runtimeOptions.cachedSettings, skipUpstreamRetry: runtimeOptions.skipUpstreamRetry ?? false, correlationId: runtimeOptions?.correlationId ?? null, + conversationId: runtimeOptions?.conversationId ?? null, modelPinned: runtimeOptions?.modelPinned ?? false, routingComboId: runtimeOptions?.routingComboId ?? null, sessionAffinityKey: runtimeOptions.sessionAffinityKey ?? null, diff --git a/src/sse/handlers/chatHelpers.ts b/src/sse/handlers/chatHelpers.ts index 97bbea50db..c82333a78e 100644 --- a/src/sse/handlers/chatHelpers.ts +++ b/src/sse/handlers/chatHelpers.ts @@ -419,6 +419,7 @@ export async function executeChatWithBreaker({ skipUpstreamRetry = false, trafficType = "production", correlationId = null, + conversationId = null, modelPinned = false, routingComboId = null, sessionAffinityKey = null, @@ -476,6 +477,7 @@ export async function executeChatWithBreaker({ skipUpstreamRetry, trafficType: normalizedTrafficType, correlationId, + conversationId, modelPinned, routingComboId, sessionAffinityKey, @@ -955,6 +957,23 @@ export function withModalityBridgeHeader(response: Response, value: string | nul } } +export function withConversationId(response: Response, conversationId: string | null): Response { + if (!response || !conversationId) return response; + + try { + response.headers.set("X-ConversationId", conversationId); + return response; + } catch { + const cloned = new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers: response.headers, + }); + cloned.headers.set("X-ConversationId", conversationId); + return cloned; + } +} + export function withSelectedConnectionHeader( response: Response, connectionId: string | null | undefined diff --git a/src/sse/handlers/rejectedRequestUsage.ts b/src/sse/handlers/rejectedRequestUsage.ts index 46b8099014..8a817393de 100644 --- a/src/sse/handlers/rejectedRequestUsage.ts +++ b/src/sse/handlers/rejectedRequestUsage.ts @@ -30,6 +30,8 @@ export interface RejectedRequestUsageInput { comboStepId?: string | null; comboExecutionKey?: string | null; correlationId?: string | null; + /** Conversation id (X-ConversationId) — see open-sse/services/conversationTracker.ts. */ + sessionTag?: string | null; apiKeyId?: string | null; apiKeyName?: string | null; connectionId?: string | null; @@ -56,6 +58,7 @@ export async function recordRejectedRequestUsage(input: RejectedRequestUsageInpu comboStepId = null, comboExecutionKey = null, correlationId = null, + sessionTag = null, apiKeyId = null, apiKeyName = null, connectionId = undefined, @@ -86,6 +89,7 @@ export async function recordRejectedRequestUsage(input: RejectedRequestUsageInpu apiKeyId, apiKeyName, correlationId, + sessionTag, }).catch(() => {}); // 2. usage_history — so the per-api-key usage counter reflects rejected diff --git a/tests/unit/agenticConversations.test.ts b/tests/unit/agenticConversations.test.ts new file mode 100644 index 0000000000..47e5fba6e2 --- /dev/null +++ b/tests/unit/agenticConversations.test.ts @@ -0,0 +1,299 @@ +/** + * Unit tests for src/lib/db/agenticConversations.ts CRUD. + */ + +import test from "node:test"; +import assert from "node:assert/strict"; + +import { mkdtempSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +process.env.DATA_DIR = mkdtempSync(join(tmpdir(), "omniroute-agentic-conv-db-")); +process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "agentic-conversations-test-secret"; + +// Dynamic imports (not static) are required here: a static `import` of a module +// that reads process.env.DATA_DIR at its own top level (src/lib/db/core.ts's +// `export const DATA_DIR = ...`) is evaluated before this file's own top-level +// code runs — ESM instantiates the whole dependency graph, dependencies first, +// regardless of source-line order — so the override above would silently miss +// and the module would resolve the real host DATA_DIR instead of the temp dir. +const { + createAgenticConversation, + findAgenticConversationsByFingerprint, + updateAgenticConversation, + touchOrCreateExternalConversation, + listMultiTurnConversations, + getConversationTurnIndex, + insertConversationTurnNodes, + getConversationTurnPage, + resolveCallLogIdsByCorrelationIds, +} = await import("../../src/lib/db/agenticConversations.ts"); +const { getDbInstance } = await import("../../src/lib/db/core.ts"); + +test("createAgenticConversation + findAgenticConversationsByFingerprint round-trip", () => { + const row = createAgenticConversation({ + apiKeyId: "key-a", + fingerprintHash: "fp-round-trip", + }); + + assert.match(row.id, /^conv_/); + assert.equal(row.turnCount, 1); + + const found = findAgenticConversationsByFingerprint("fp-round-trip"); + assert.equal(found.length, 1); + assert.equal(found[0].id, row.id); + assert.equal(found[0].apiKeyId, "key-a"); +}); + +test("findAgenticConversationsByFingerprint returns multiple rows for a shared fingerprint", () => { + createAgenticConversation({ apiKeyId: "key-b", fingerprintHash: "fp-shared" }); + createAgenticConversation({ apiKeyId: "key-b", fingerprintHash: "fp-shared" }); + + const found = findAgenticConversationsByFingerprint("fp-shared"); + assert.equal(found.length, 2); +}); + +test("updateAgenticConversation updates turn count", () => { + const row = createAgenticConversation({ apiKeyId: "key-c", fingerprintHash: "fp-update" }); + + updateAgenticConversation(row.id, { turnCount: 3 }); + + const found = findAgenticConversationsByFingerprint("fp-update"); + assert.equal(found[0].turnCount, 3); +}); + +test("insertConversationTurnNodes + getConversationTurnIndex round-trip", () => { + const row = createAgenticConversation({ apiKeyId: "key-nodes", fingerprintHash: "fp-nodes" }); + + insertConversationTurnNodes(row.id, "corr-1", [ + { id: "node-a", parentId: null, role: "user", contentHash: "hash-a" }, + { id: "node-b", parentId: "node-a", role: "assistant", contentHash: "hash-b" }, + ]); + + const index = getConversationTurnIndex(row.id); + assert.equal(index.nodeIds.size, 2); + assert.ok(index.nodeIds.has("node-a")); + assert.ok(index.nodeIds.has("node-b")); + assert.deepEqual(index.byContentHash.get("hash-a"), ["node-a"]); + assert.deepEqual(index.byContentHash.get("hash-b"), ["node-b"]); + + // A different conversation's nodes must never leak into this index. + const other = createAgenticConversation({ + apiKeyId: "key-nodes-2", + fingerprintHash: "fp-nodes-2", + }); + insertConversationTurnNodes(other.id, "corr-2", [ + { id: "node-c", parentId: null, role: "user", contentHash: "hash-c" }, + ]); + const reReadIndex = getConversationTurnIndex(row.id); + assert.equal(reReadIndex.nodeIds.size, 2); + assert.equal(reReadIndex.byContentHash.has("hash-c"), false); +}); + +test("getConversationTurnIndex groups multiple node ids under the same content hash (duplicate turn text at different tree positions)", () => { + const row = createAgenticConversation({ apiKeyId: "key-dup-content", fingerprintHash: "fp-dup" }); + + insertConversationTurnNodes(row.id, "corr-1", [ + { id: "node-1", parentId: null, role: "user", contentHash: "hash-ok" }, + { id: "node-2", parentId: "node-1", role: "assistant", contentHash: "hash-reply" }, + // Same content ("ok") recurs later in the same tree, at a different node. + { id: "node-3", parentId: "node-2", role: "user", contentHash: "hash-ok" }, + ]); + + const index = getConversationTurnIndex(row.id); + const matches = index.byContentHash.get("hash-ok"); + assert.equal(matches?.length, 2); + assert.deepEqual([...matches!].sort(), ["node-1", "node-3"]); +}); + +test("insertConversationTurnNodes is idempotent for already-existing node ids (INSERT OR IGNORE)", () => { + const row = createAgenticConversation({ apiKeyId: "key-idem", fingerprintHash: "fp-idem" }); + + insertConversationTurnNodes(row.id, "corr-1", [ + { id: "node-dup", parentId: null, role: "user", contentHash: "hash-dup" }, + ]); + // Re-insert the same id — must not throw, must not duplicate. + insertConversationTurnNodes(row.id, "corr-2", [ + { id: "node-dup", parentId: null, role: "user", contentHash: "hash-dup" }, + ]); + + const tree = getConversationTurnPage(row.id, { limit: 500 }).nodes; + assert.equal(tree.length, 1); +}); + +test("getConversationTurnPage returns the full chain with parent/child structure and content hash", () => { + const row = createAgenticConversation({ apiKeyId: "key-tree", fingerprintHash: "fp-tree" }); + + insertConversationTurnNodes(row.id, "corr-tree", [ + { id: "root-turn", parentId: null, role: "user", contentHash: "hash-hello" }, + { id: "child-turn", parentId: "root-turn", role: "assistant", contentHash: "hash-hi" }, + ]); + // A sibling branch off the same parent. + insertConversationTurnNodes(row.id, "corr-tree-2", [ + { id: "sibling-turn", parentId: "root-turn", role: "assistant", contentHash: "hash-hey" }, + ]); + + const tree = getConversationTurnPage(row.id, { limit: 500 }).nodes; + assert.equal(tree.length, 3); + + const root = tree.find((n) => n.id === "root-turn"); + const children = tree.filter((n) => n.parentId === "root-turn"); + assert.equal(root?.parentId, null); + assert.equal(root?.contentHash, "hash-hello"); + assert.equal(children.length, 2); + assert.deepEqual(children.map((c) => c.id).sort(), ["child-turn", "sibling-turn"]); +}); + +test("getConversationTurnPage: initial load returns only the last `limit` turns, oldest-first, with hasMore", () => { + const row = createAgenticConversation({ apiKeyId: "key-page", fingerprintHash: "fp-page" }); + const nodes = Array.from({ length: 25 }, (_, i) => ({ + id: `n${i}`, + parentId: i === 0 ? null : `n${i - 1}`, + role: i % 2 === 0 ? "user" : "assistant", + contentHash: `hash-${i}`, + })); + insertConversationTurnNodes(row.id, "corr-page", nodes); + + const page = getConversationTurnPage(row.id, { limit: 20 }); + assert.equal(page.nodes.length, 20); + assert.equal(page.hasMore, true); + // Oldest-first within the page, and it's the LAST 20 (n5..n24). + assert.equal(page.nodes[0].id, "n5"); + assert.equal(page.nodes[19].id, "n24"); +}); + +test("getConversationTurnPage: beforeSeq loads the previous page (older turns), with correct hasMore", () => { + const row = createAgenticConversation({ apiKeyId: "key-page-2", fingerprintHash: "fp-page-2" }); + const nodes = Array.from({ length: 25 }, (_, i) => ({ + id: `m${i}`, + parentId: i === 0 ? null : `m${i - 1}`, + role: "user", + contentHash: `hash-m${i}`, + })); + insertConversationTurnNodes(row.id, "corr-page-2", nodes); + + const firstPage = getConversationTurnPage(row.id, { limit: 20 }); + const oldestSeqInFirstPage = firstPage.nodes[0].seq; + + const olderPage = getConversationTurnPage(row.id, { limit: 20, beforeSeq: oldestSeqInFirstPage }); + assert.equal(olderPage.nodes.length, 5, "only 5 turns (0-4) exist before the first page"); + assert.equal(olderPage.hasMore, false); + assert.equal(olderPage.nodes[0].id, "m0"); + assert.equal(olderPage.nodes[4].id, "m4"); +}); + +test("getConversationTurnPage: afterSeq returns only turns newer than the cursor (for polling), uncapped", () => { + const row = createAgenticConversation({ apiKeyId: "key-page-3", fingerprintHash: "fp-page-3" }); + insertConversationTurnNodes(row.id, "corr-page-3", [ + { id: "p0", parentId: null, role: "user", contentHash: "h0" }, + { id: "p1", parentId: "p0", role: "assistant", contentHash: "h1" }, + ]); + const firstPage = getConversationTurnPage(row.id, { limit: 20 }); + const newestSeq = firstPage.nodes[firstPage.nodes.length - 1].seq; + + // Nothing new yet. + assert.equal(getConversationTurnPage(row.id, { afterSeq: newestSeq }).nodes.length, 0); + + // A new turn arrives (e.g. a later request continuing this conversation). + insertConversationTurnNodes(row.id, "corr-page-3b", [ + { id: "p2", parentId: "p1", role: "user", contentHash: "h2" }, + ]); + const polled = getConversationTurnPage(row.id, { afterSeq: newestSeq }); + assert.equal(polled.nodes.length, 1); + assert.equal(polled.nodes[0].id, "p2"); + assert.equal(polled.hasMore, false); +}); + +test("touchOrCreateExternalConversation creates then increments turn_count on repeat calls", () => { + const id = "ext-conv-test-id"; + touchOrCreateExternalConversation(id, { apiKeyId: "key-d" }); + + const db = getDbInstance(); + const afterCreate = db + .prepare("SELECT turn_count FROM agentic_conversations WHERE id = ?") + .get(id) as { turn_count: number }; + assert.equal(afterCreate.turn_count, 1); + + touchOrCreateExternalConversation(id, { apiKeyId: "key-d" }); + const afterTouch = db + .prepare("SELECT turn_count FROM agentic_conversations WHERE id = ?") + .get(id) as { turn_count: number }; + assert.equal(afterTouch.turn_count, 2); +}); + +test("listMultiTurnConversations only returns conversations with >= 2 actual turn nodes, joined to their latest call_logs row", () => { + const db = getDbInstance(); + + createAgenticConversation({ + id: "conv-single-turn", + apiKeyId: null, + fingerprintHash: "fp-single", + }); + insertConversationTurnNodes("conv-single-turn", "corr-single", [ + { id: "single-node-1", parentId: null, role: "user", contentHash: "hash-single-1" }, + ]); + + const multi = createAgenticConversation({ + id: "conv-multi-turn", + apiKeyId: null, + fingerprintHash: "fp-multi", + }); + // turn_count deliberately left at its default of 1 here: it tracks + // requests-touched, not node count, and a freshly-minted conversation can + // already carry many turn nodes from a single insert (see the doc comment + // on listMultiTurnConversations) — the filter must key off actual node + // count, not turn_count, for this conversation to be listed at all. + insertConversationTurnNodes(multi.id, "corr-multi", [ + { id: "multi-node-1", parentId: null, role: "user", contentHash: "hash-multi-1" }, + { + id: "multi-node-2", + parentId: "multi-node-1", + role: "assistant", + contentHash: "hash-multi-2", + }, + ]); + + db.prepare( + `INSERT INTO call_logs (id, timestamp, method, path, status, model, provider, session_tag) + VALUES (?, ?, 'POST', '/v1/chat/completions', 200, 'big-pickle', 'opencode-zen', ?)` + ).run("multi-turn-1", "2026-03-01T00:00:00.000Z", "conv-multi-turn"); + db.prepare( + `INSERT INTO call_logs (id, timestamp, method, path, status, model, provider, session_tag) + VALUES (?, ?, 'POST', '/v1/chat/completions', 200, 'gemma-4', 'gemini', ?)` + ).run("multi-turn-2", "2026-03-01T00:01:00.000Z", "conv-multi-turn"); + + const { rows, total } = listMultiTurnConversations(); + const ids = rows.map((r) => r.id); + assert.ok(ids.includes("conv-multi-turn")); + assert.ok(!ids.includes("conv-single-turn")); + assert.ok(total >= 1); + + const found = rows.find((r) => r.id === "conv-multi-turn"); + assert.equal(found?.lastCallLogId, "multi-turn-2"); + assert.equal(found?.lastModel, "gemma-4"); + assert.equal(found?.lastProvider, "gemini"); +}); + +test("resolveCallLogIdsByCorrelationIds bulk-resolves correlation_id to call_logs.id", () => { + const db = getDbInstance(); + + db.prepare( + `INSERT INTO call_logs (id, timestamp, method, path, status, model, correlation_id) + VALUES (?, ?, 'POST', '/v1/chat/completions', 200, 'big-pickle', ?)` + ).run("call-corr-1", "2026-04-01T00:00:00.000Z", "corr-a"); + db.prepare( + `INSERT INTO call_logs (id, timestamp, method, path, status, model, correlation_id) + VALUES (?, ?, 'POST', '/v1/chat/completions', 200, 'big-pickle', ?)` + ).run("call-corr-2", "2026-04-01T00:01:00.000Z", "corr-b"); + + const resolved = resolveCallLogIdsByCorrelationIds(["corr-a", "corr-b", "corr-missing"]); + assert.equal(resolved.get("corr-a"), "call-corr-1"); + assert.equal(resolved.get("corr-b"), "call-corr-2"); + assert.equal(resolved.has("corr-missing"), false); +}); + +test("resolveCallLogIdsByCorrelationIds returns an empty map for an empty/all-falsy input", () => { + assert.equal(resolveCallLogIdsByCorrelationIds([]).size, 0); + assert.equal(resolveCallLogIdsByCorrelationIds(["", ""]).size, 0); +}); diff --git a/tests/unit/chatcore-log-truncation.test.ts b/tests/unit/chatcore-log-truncation.test.ts index dc257cc562..db83a81b15 100644 --- a/tests/unit/chatcore-log-truncation.test.ts +++ b/tests/unit/chatcore-log-truncation.test.ts @@ -214,14 +214,8 @@ test("truncateForLog keeps a bounded `tools` field alive when the request is sum assert.ok(summary.tools, "expected the summary to retain a `tools` field"); const clonedTools = summary.tools as Array>; assert.equal(clonedTools.length, tools.length); - assert.equal( - (clonedTools[0].function as Record).name, - "get_weather" - ); - assert.equal( - (clonedTools[1].function as Record).name, - "search_web" - ); + assert.equal((clonedTools[0].function as Record).name, "get_weather"); + assert.equal((clonedTools[1].function as Record).name, "search_web"); }); test("truncateForLog bounds an oversized `tools` array to the configured tail-item cap", () => { diff --git a/tests/unit/conversationTracker.test.ts b/tests/unit/conversationTracker.test.ts new file mode 100644 index 0000000000..fb61360445 --- /dev/null +++ b/tests/unit/conversationTracker.test.ts @@ -0,0 +1,640 @@ +/** + * Unit tests for the agentic conversation tracker + * (open-sse/services/conversationTracker.ts). + */ + +import test from "node:test"; +import assert from "node:assert/strict"; + +import { mkdtempSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +process.env.DATA_DIR = mkdtempSync(join(tmpdir(), "omniroute-conv-tracker-")); +process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "conversation-tracker-test-secret"; + +// Dynamic imports (not static) are required here: a static `import` of a module +// that reads process.env.DATA_DIR at its own top level (src/lib/db/core.ts's +// `export const DATA_DIR = ...`) is evaluated before this file's own top-level +// code runs — ESM instantiates the whole dependency graph, dependencies first, +// regardless of source-line order — so the override above would silently miss +// and the module would resolve the real host DATA_DIR instead of the temp dir. +const { extractCanonicalTurns, computeFingerprintHash, resolveConversationId, hashTurnContent } = + await import("../../open-sse/services/conversationTracker.ts"); +const { getConversationTurnPage } = await import("../../src/lib/db/agenticConversations.ts"); + +let correlationCounter = 0; +function nextCorrelationId(): string { + correlationCounter += 1; + return `corr-${correlationCounter}`; +} + +// conversation_turn_nodes stores identity only (content_hash), never display +// text — see migration 156 and conversationTurnContent.ts. Tests that need +// to assert WHICH turns ended up on a chain compare content hashes instead +// of stored text. +function hashOfPlainTextTurn(role: "user" | "assistant" | "system" | "tool", text: string): string { + return hashTurnContent({ role, text, blockKind: "text", toolName: null }); +} + +test("extractCanonicalTurns: OpenAI messages array", () => { + const turns = extractCanonicalTurns({ + messages: [ + { role: "system", content: "be helpful" }, + { role: "user", content: "hi" }, + { role: "assistant", content: "hello!" }, + ], + }); + assert.deepEqual( + turns.map((t) => t.role), + ["system", "user", "assistant"] + ); + assert.equal(turns[0].text, "be helpful"); +}); + +test("extractCanonicalTurns: Responses API input array", () => { + const turns = extractCanonicalTurns({ + input: [ + { role: "user", content: [{ type: "input_text", text: "check the file" }] }, + { type: "function_call", name: "exec", call_id: "c1", arguments: '{"command":"ls"}' }, + { type: "function_call_output", call_id: "c1", output: "ok" }, + ], + }); + assert.equal(turns.length, 3); + assert.equal(turns[0].role, "user"); + // Regression: content-block arrays (Responses API's `input_text`/ + // `output_text` shape) must extract their `.text`, not JSON.stringify the + // whole block array — a raw JSON blob here directly becomes what + // /dashboard/conversations renders as a turn's text. + assert.equal(turns[0].text, "check the file"); + assert.equal(turns[1].role, "tool"); + // `arguments` here is already a JSON string (how OpenAI/Responses API send + // tool-call arguments) — stringifyContent passes strings through as-is, + // only the content-BLOCK-ARRAY case (turns[0] above) needed the fix. + assert.equal(turns[1].text, '{"command":"ls"}'); + assert.equal(turns[2].role, "tool"); + assert.equal(turns[2].text, "ok"); + + // blockKind/toolName let a consumer (the /dashboard/conversations tree) + // build the same NormalizedBlock shape the request-detail panel already + // builds, so tool calls/results render through the same ChatBubble/ + // MessageContent/ToolCallBlock/ToolResultBlock components everywhere. + assert.equal(turns[0].blockKind, "text"); + assert.equal(turns[0].toolName, null); + assert.equal(turns[1].blockKind, "tool_use"); + assert.equal(turns[1].toolName, "exec"); + assert.equal(turns[2].blockKind, "tool_result"); + assert.equal(turns[2].toolName, null); +}); + +test("extractCanonicalTurns: Chat Completions tool-result message (role: tool) classifies as tool_result", () => { + const turns = extractCanonicalTurns({ + messages: [ + { role: "user", content: "what's the weather?" }, + { role: "tool", tool_call_id: "c1", content: '{"tempC":21}' }, + ], + }); + assert.equal(turns[0].blockKind, "text"); + assert.equal(turns[1].role, "tool"); + assert.equal(turns[1].blockKind, "tool_result"); + assert.equal(turns[1].text, '{"tempC":21}'); +}); + +test("extractCanonicalTurns: content-block arrays (Anthropic/Responses-API shape) extract text, not raw JSON", () => { + const turns = extractCanonicalTurns({ + messages: [ + { role: "user", content: [{ type: "text", text: "hello there" }] }, + { role: "assistant", content: [{ type: "output_text", text: "hi back" }] }, + ], + }); + assert.equal(turns[0].text, "hello there"); + assert.equal(turns[1].text, "hi back"); + assert.ok(!turns[0].text.includes("{"), "must not contain raw JSON"); + assert.ok(!turns[1].text.includes("{"), "must not contain raw JSON"); +}); + +test("extractCanonicalTurns: Responses API bare-string input", () => { + const turns = extractCanonicalTurns({ input: "just a string" }); + assert.equal(turns.length, 1); + assert.equal(turns[0].role, "user"); + assert.equal(turns[0].text, "just a string"); +}); + +test("computeFingerprintHash: same inputs produce the same hash", () => { + const a = computeFingerprintHash({ apiKeyId: "key1", model: "gpt-4o", toolNames: [] }); + const b = computeFingerprintHash({ apiKeyId: "key1", model: "gpt-4o", toolNames: [] }); + assert.equal(a, b); +}); + +test("computeFingerprintHash: different apiKeyId or model changes the hash", () => { + const base = computeFingerprintHash({ apiKeyId: "key1", model: "gpt-4o", toolNames: [] }); + const diffKey = computeFingerprintHash({ apiKeyId: "key2", model: "gpt-4o", toolNames: [] }); + const diffModel = computeFingerprintHash({ apiKeyId: "key1", model: "gpt-5", toolNames: [] }); + assert.notEqual(base, diffKey); + assert.notEqual(base, diffModel); +}); + +test("computeFingerprintHash: identical apiKeyId/model/toolNames produce the same hash regardless of message content", () => { + // The whole point of the fix: real OpenClaw traffic rotates its earliest + // turns out of a sliding context window, so the bucket key must not + // depend on message text at all — actual identity is decided later by the + // turn-chain walk (real content overlap), not by this coarse bucket. + const a = computeFingerprintHash({ apiKeyId: "key1", model: "gpt-4o", toolNames: ["exec"] }); + const b = computeFingerprintHash({ apiKeyId: "key1", model: "gpt-4o", toolNames: ["exec"] }); + assert.equal(a, b); +}); + +test("resolveConversationId: exact-match continuation reuses the same id", async () => { + const apiKeyId = "key-exact"; + const turn1 = await resolveConversationId({ + body: { model: "big-pickle", messages: [{ role: "user", content: "hi there" }] }, + model: "big-pickle", + apiKeyId, + clientSessionIdHeader: null, + correlationId: nextCorrelationId(), + }); + assert.equal(turn1.isNewConversation, true); + + const turn2 = await resolveConversationId({ + body: { + model: "big-pickle", + messages: [ + { role: "user", content: "hi there" }, + { role: "assistant", content: "hello!" }, + { role: "user", content: "tell me more" }, + ], + }, + model: "big-pickle", + apiKeyId, + clientSessionIdHeader: null, + correlationId: nextCorrelationId(), + }); + assert.equal(turn2.conversationId, turn1.conversationId); + assert.equal(turn2.isNewConversation, false); +}); + +test("resolveConversationId: prefix-match continuation across a longer history", async () => { + const apiKeyId = "key-prefix"; + const turn1 = await resolveConversationId({ + body: { model: "big-pickle", messages: [{ role: "user", content: "prefix test start" }] }, + model: "big-pickle", + apiKeyId, + clientSessionIdHeader: null, + correlationId: nextCorrelationId(), + }); + + // Turn 3 resends the full history including turn 2's exchange — still a + // continuation of turn 1's conversation even though it's grown further. + const turn3 = await resolveConversationId({ + body: { + model: "big-pickle", + messages: [ + { role: "user", content: "prefix test start" }, + { role: "assistant", content: "ack" }, + { role: "tool", content: "tool result" }, + { role: "assistant", content: "done" }, + { role: "user", content: "and one more thing" }, + ], + }, + model: "big-pickle", + apiKeyId, + clientSessionIdHeader: null, + correlationId: nextCorrelationId(), + }); + + assert.equal(turn3.conversationId, turn1.conversationId); +}); + +test("resolveConversationId: an edited/duplicated mid-history turn mints its own independent conversation (2026-08-06 redesign — no forking)", async () => { + // The scenario that originally motivated the hash-chain rewrite, and now + // motivates the no-forking redesign: OpenClaw-style cache-aware context + // injection edits turn `c` to `c'` and duplicates turn `i` with an + // injected variant `i'` ahead of it, between two otherwise-related + // requests: + // request 1: a b c d e f g h i + // request 2: a b c' d e f g h i' i j k + // `a`/`b` are byte-identical, but every OmniRoute conversation is a single + // straight line — it never forks. So request 2 must become its OWN + // independent conversation (not request1's), with its OWN complete chain + // (a b c' d e f g h i' i j k), and request1's chain must stay untouched. + const apiKeyId = "key-fork"; + const request1 = await resolveConversationId({ + body: { + model: "big-pickle", + messages: [ + { role: "user", content: "a" }, + { role: "assistant", content: "b" }, + { role: "user", content: "c" }, + { role: "assistant", content: "d" }, + { role: "user", content: "e" }, + { role: "assistant", content: "f" }, + { role: "user", content: "g" }, + { role: "assistant", content: "h" }, + { role: "user", content: "i" }, + ], + }, + model: "big-pickle", + apiKeyId, + clientSessionIdHeader: null, + correlationId: nextCorrelationId(), + }); + assert.equal(request1.isNewConversation, true); + + const request2 = await resolveConversationId({ + body: { + model: "big-pickle", + messages: [ + { role: "user", content: "a" }, + { role: "assistant", content: "b" }, + { role: "user", content: "c'" }, + { role: "assistant", content: "d" }, + { role: "user", content: "e" }, + { role: "assistant", content: "f" }, + { role: "user", content: "g" }, + { role: "assistant", content: "h" }, + { role: "user", content: "i'" }, + { role: "assistant", content: "i" }, + { role: "user", content: "j" }, + { role: "assistant", content: "k" }, + ], + }, + model: "big-pickle", + apiKeyId, + clientSessionIdHeader: null, + correlationId: nextCorrelationId(), + }); + + // A distinct, brand-new conversation — not request1's. + assert.notEqual(request2.conversationId, request1.conversationId); + assert.equal(request2.isNewConversation, true); + + // request1's chain is completely untouched: still exactly its own 9 turns. + const tree1 = getConversationTurnPage(request1.conversationId, { limit: 500 }).nodes; + assert.equal(tree1.length, 9); + assert.deepEqual( + tree1.map((n) => n.contentHash).sort(), + [ + hashOfPlainTextTurn("user", "a"), + hashOfPlainTextTurn("assistant", "b"), + hashOfPlainTextTurn("user", "c"), + hashOfPlainTextTurn("assistant", "d"), + hashOfPlainTextTurn("user", "e"), + hashOfPlainTextTurn("assistant", "f"), + hashOfPlainTextTurn("user", "g"), + hashOfPlainTextTurn("assistant", "h"), + hashOfPlainTextTurn("user", "i"), + ].sort() + ); + + // request2's chain is its own complete, independent 12-turn history — + // including its OWN copies of "a" and "b" (different node ids than + // request1's, since each conversation's chain hashing is scoped to its + // own conversation id), not references into request1's chain. + const tree2 = getConversationTurnPage(request2.conversationId, { limit: 500 }).nodes; + assert.equal(tree2.length, 12); + assert.deepEqual( + tree2.map((n) => n.contentHash).sort(), + [ + hashOfPlainTextTurn("user", "a"), + hashOfPlainTextTurn("assistant", "b"), + hashOfPlainTextTurn("user", "c'"), + hashOfPlainTextTurn("assistant", "d"), + hashOfPlainTextTurn("user", "e"), + hashOfPlainTextTurn("assistant", "f"), + hashOfPlainTextTurn("user", "g"), + hashOfPlainTextTurn("assistant", "h"), + hashOfPlainTextTurn("user", "i'"), + hashOfPlainTextTurn("assistant", "i"), + hashOfPlainTextTurn("user", "j"), + hashOfPlainTextTurn("assistant", "k"), + ].sort() + ); + + const ids1 = new Set(tree1.map((n) => n.id)); + const ids2 = new Set(tree2.map((n) => n.id)); + for (const id of ids2) { + assert.ok(!ids1.has(id), "the two conversations must not share any node ids"); + } + + // A repeat of request2's exact history continues request2 (not a THIRD + // conversation) — the redesign doesn't mint a new id on every retry of an + // already-diverged chain. + const request2Retry = await resolveConversationId({ + body: { + model: "big-pickle", + messages: [ + { role: "user", content: "a" }, + { role: "assistant", content: "b" }, + { role: "user", content: "c'" }, + { role: "assistant", content: "d" }, + { role: "user", content: "e" }, + { role: "assistant", content: "f" }, + { role: "user", content: "g" }, + { role: "assistant", content: "h" }, + { role: "user", content: "i'" }, + { role: "assistant", content: "i" }, + { role: "user", content: "j" }, + { role: "assistant", content: "k" }, + { role: "user", content: "l" }, + ], + }, + model: "big-pickle", + apiKeyId, + clientSessionIdHeader: null, + correlationId: nextCorrelationId(), + }); + assert.equal(request2Retry.conversationId, request2.conversationId); + assert.equal(request2Retry.isNewConversation, false); +}); + +test("resolveConversationId: continuation is detected even when the system prompt is regenerated every turn (dynamic CLI boilerplate)", async () => { + // Real coding-agent CLIs (Claude Code, opencode, etc.) commonly regenerate + // the system prompt on EVERY request with live context (timestamp, cwd, + // git status...). The chain must exclude the system message entirely, or + // that volatility alone breaks continuation detection for real traffic — + // every turn would mint a brand new conversation id, even though + // apiKeyId/model/toolNames and the actual user/assistant history are + // unchanged. Discovered live on a real deployment (#9315 follow-up): 28 + // consecutive requests from one growing session, each with turn_count=1. + const apiKeyId = "key-volatile-system"; + const dynamicSystem = (n: number) => + `You are an agent. Current time: 2026-08-04T12:0${n}:00Z. cwd: /home/user/project`; + + const turn1 = await resolveConversationId({ + body: { + model: "big-pickle", + messages: [ + { role: "system", content: dynamicSystem(0) }, + { role: "user", content: "please fix the bug in foo.ts" }, + ], + }, + model: "big-pickle", + apiKeyId, + clientSessionIdHeader: null, + correlationId: nextCorrelationId(), + }); + assert.equal(turn1.isNewConversation, true); + + const turn2 = await resolveConversationId({ + body: { + model: "big-pickle", + messages: [ + // System prompt regenerated with a DIFFERENT timestamp — everything + // else (apiKeyId, model, tool set, actual conversation content) is + // identical/growing normally. + { role: "system", content: dynamicSystem(1) }, + { role: "user", content: "please fix the bug in foo.ts" }, + { role: "assistant", content: "Sure, I'll look at it." }, + { role: "user", content: "thanks, also check bar.ts" }, + ], + }, + model: "big-pickle", + apiKeyId, + clientSessionIdHeader: null, + correlationId: nextCorrelationId(), + }); + + assert.equal( + turn2.conversationId, + turn1.conversationId, + "expected turn2 to be recognized as a continuation despite the regenerated system prompt" + ); + assert.equal(turn2.isNewConversation, false); + + // The regenerated system prompt must never appear as a chain node. + const tree = getConversationTurnPage(turn1.conversationId, { limit: 500 }).nodes; + for (const node of tree) { + assert.notEqual(node.role, "system"); + } +}); + +test("resolveConversationId: continuation is detected even when the earliest turns rotate out of a sliding context window (live OpenClaw traffic pattern)", async () => { + // Discovered live on a real deployment: OpenClaw drops/summarizes the + // EARLIEST turns as a session grows (to bound context size), so the + // request's first non-system turn is a DIFFERENT piece of text on every + // single request — not just an edited/duplicated turn somewhere in the + // middle (that's the fork scenario above), but the very first turn the + // fingerprint bucket used to anchor on. If the bucket depends on that text + // at all, findAgenticConversationsByFingerprint returns zero candidates + // and the turn-chain match never even runs — the conversation looks + // "new" forever, the exact symptom this whole test file guards against. + const apiKeyId = "key-sliding-window"; + const toolNames = ["exec"]; + + const turn1 = await resolveConversationId({ + body: { + model: "big-pickle", + tools: [{ name: "exec" }], + messages: [ + { role: "user", content: "turn-A-oldest" }, + { role: "assistant", content: "turn-B" }, + { role: "user", content: "turn-C-shared-tail" }, + ], + }, + model: "big-pickle", + apiKeyId, + clientSessionIdHeader: null, + correlationId: nextCorrelationId(), + }); + assert.equal(turn1.isNewConversation, true); + + // Turn 2: the oldest turns ("turn-A-oldest", "turn-B") are gone, replaced + // by an unrelated summary — only "turn-C-shared-tail" onward survived. + const turn2 = await resolveConversationId({ + body: { + model: "big-pickle", + tools: [{ name: "exec" }], + messages: [ + { role: "user", content: "[context summary, unrelated to turn-A/turn-B text]" }, + { role: "user", content: "turn-C-shared-tail" }, + { role: "assistant", content: "turn-D" }, + { role: "user", content: "turn-E" }, + ], + }, + model: "big-pickle", + apiKeyId, + clientSessionIdHeader: null, + correlationId: nextCorrelationId(), + }); + + assert.equal( + turn2.conversationId, + turn1.conversationId, + "expected turn2 to be recognized as a continuation despite the first turn's text changing entirely" + ); + assert.equal(turn2.isNewConversation, false); + + // Confirmed via the fingerprint itself: identical apiKeyId/model/toolNames + // (the only inputs to computeFingerprintHash now) despite completely + // different message content between the two requests. + const fp1 = computeFingerprintHash({ apiKeyId, model: "big-pickle", toolNames }); + const fp2 = computeFingerprintHash({ apiKeyId, model: "big-pickle", toolNames }); + assert.equal(fp1, fp2); +}); + +test("resolveConversationId: continuation is detected even when the reconnect turn's content is duplicated earlier in the chain (tool-polling loop)", async () => { + // Discovered live: real agentic traffic (a tool-polling loop, "ack"/"poll" + // repeated many times — one real conversation had 28 byte-identical copies + // of a single turn) leaves MANY existing nodes sharing the same content + // hash. When a sliding context window means the new request's earliest + // retained turn is one of these repeated turns, findReconnectMatch must + // not just grab whichever occurrence happens to be tried first (the + // oldest, per SQLite's insertion-order return) — that stale occurrence's + // recorded next-turn differs from the new content, so it looks like a + // divergence even though the TRUE tail occurrence (no recorded child yet) + // would extend cleanly. This is what made a real conversation mint a + // brand-new copy of its entire history on every single request instead of + // ever reconnecting (2026-08-06). + const apiKeyId = "key-dup-content"; + + const turn1 = await resolveConversationId({ + body: { + model: "big-pickle", + messages: [ + { role: "user", content: "start" }, + { role: "assistant", content: "a1" }, + { role: "user", content: "ack" }, + { role: "assistant", content: "poll" }, + { role: "user", content: "ack" }, + { role: "assistant", content: "poll" }, + { role: "user", content: "ack" }, + { role: "assistant", content: "poll" }, + ], + }, + model: "big-pickle", + apiKeyId, + clientSessionIdHeader: null, + correlationId: nextCorrelationId(), + }); + assert.equal(turn1.isNewConversation, true); + + // Sliding window: only the last "ack"/"poll" pair survived, followed by + // genuinely new content. "ack" and "poll" each match 3 existing nodes. + const turn2 = await resolveConversationId({ + body: { + model: "big-pickle", + messages: [ + { role: "user", content: "ack" }, + { role: "assistant", content: "poll" }, + { role: "user", content: "brand new turn" }, + ], + }, + model: "big-pickle", + apiKeyId, + clientSessionIdHeader: null, + correlationId: nextCorrelationId(), + }); + + assert.equal( + turn2.conversationId, + turn1.conversationId, + "expected turn2 to reconnect to turn1's conversation via the TRUE tail occurrence of the repeated ack/poll turns, not mint a new one" + ); + assert.equal(turn2.isNewConversation, false); + + const tree = getConversationTurnPage(turn1.conversationId, { limit: 500 }).nodes; + assert.equal( + tree.length, + 9, + "the new turn should be appended, not a whole new duplicate history" + ); + assert.ok(tree.some((n) => n.contentHash === hashOfPlainTextTurn("user", "brand new turn"))); +}); + +test("resolveConversationId: different api keys never merge, even with byte-identical content", async () => { + // Fingerprint isolation (apiKeyId is part of computeFingerprintHash) is + // the actual multi-tenant boundary — must hold regardless of the turn + // chain's own content-addressing. + const body = { model: "big-pickle", messages: [{ role: "user", content: "hi" }] }; + + const first = await resolveConversationId({ + body, + model: "big-pickle", + apiKeyId: "key-tenant-a", + clientSessionIdHeader: null, + correlationId: nextCorrelationId(), + }); + const second = await resolveConversationId({ + body, + model: "big-pickle", + apiKeyId: "key-tenant-b", + clientSessionIdHeader: null, + correlationId: nextCorrelationId(), + }); + + assert.notEqual(second.conversationId, first.conversationId); + + const fingerprintA = computeFingerprintHash({ + apiKeyId: "key-tenant-a", + model: "big-pickle", + toolNames: [], + }); + const fingerprintB = computeFingerprintHash({ + apiKeyId: "key-tenant-b", + model: "big-pickle", + toolNames: [], + }); + assert.notEqual(fingerprintA, fingerprintB); +}); + +test("resolveConversationId: a byte-identical repeat of a single-turn request continues the same conversation", async () => { + // Content-addressed nodes mean a byte-identical opener from the SAME + // apiKey/model (a client retry, or a genuinely separate session that also + // just says "hi") fully matches the existing 1-turn chain — nothing + // diverges (there's no turn afterward to disagree on yet), so this is a + // real continuation, not a fork candidate at all. + const apiKeyId = "key-repeated-singleshot"; + const body = { model: "big-pickle", messages: [{ role: "user", content: "hi" }] }; + + const first = await resolveConversationId({ + body, + model: "big-pickle", + apiKeyId, + clientSessionIdHeader: null, + correlationId: nextCorrelationId(), + }); + const second = await resolveConversationId({ + body, + model: "big-pickle", + apiKeyId, + clientSessionIdHeader: null, + correlationId: nextCorrelationId(), + }); + + assert.equal(first.isNewConversation, true); + assert.equal(second.conversationId, first.conversationId); + assert.equal(second.isNewConversation, false); + + const tree = getConversationTurnPage(first.conversationId, { limit: 500 }).nodes; + assert.equal(tree.length, 1); +}); + +test("resolveConversationId: client-supplied X-Omniroute-Session-Id wins outright", async () => { + const headerValue = "client-pinned-session-abc"; + const first = await resolveConversationId({ + body: { model: "big-pickle", messages: [{ role: "user", content: "conversation A" }] }, + model: "big-pickle", + apiKeyId: "key-header", + clientSessionIdHeader: headerValue, + correlationId: nextCorrelationId(), + }); + assert.equal(first.conversationId, headerValue); + + // A second, otherwise-unrelated conversation sending the SAME header value + // merges under that one id — the header is authoritative, no heuristic + // check runs at all. + const second = await resolveConversationId({ + body: { model: "gpt-4o", messages: [{ role: "user", content: "conversation B, unrelated" }] }, + model: "gpt-4o", + apiKeyId: "key-header-2", + clientSessionIdHeader: headerValue, + correlationId: nextCorrelationId(), + }); + assert.equal(second.conversationId, headerValue); +}); + +// The old 8000-char text_preview truncation (and the JSON-validity-after- +// truncation concern it required) no longer applies: conversation_turn_nodes +// stores identity only, never turn text (migration 156) — display content is +// always resolved fresh, full and untruncated, from the call-log artifact +// (see conversationTurnContent.test.ts). diff --git a/tests/unit/conversationTurnContent.test.ts b/tests/unit/conversationTurnContent.test.ts new file mode 100644 index 0000000000..adccdf3a8c --- /dev/null +++ b/tests/unit/conversationTurnContent.test.ts @@ -0,0 +1,141 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +// conversationTurnContent.ts resolves a conversation_turn_nodes row's actual +// display text/tool-call shape on demand from the call-log artifact its +// last_correlation_id points at (migration 156 dropped the old stored +// text_preview/block_kind/tool_name columns -- see conversationTracker.ts). + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-conv-turn-content-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const core = await import("../../src/lib/db/core.ts"); +const { hashTurnContent } = await import("../../open-sse/services/conversationTracker.ts"); +const { resolveTurnDisplayContent } = + await import("../../open-sse/services/conversationTurnContent.ts"); + +test.after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +function insertCallLog(row: { id: string; correlationId: string; artifactRelPath: string | null }) { + const db = core.getDbInstance(); + db.prepare( + `INSERT INTO call_logs (id, timestamp, method, path, status, model, correlation_id, artifact_relpath) + VALUES (?, ?, 'POST', '/v1/chat/completions', 200, 'big-pickle', ?, ?)` + ).run(row.id, new Date().toISOString(), row.correlationId, row.artifactRelPath); +} + +function writeArtifact(relPath: string, clientRawRequestBody: unknown) { + const absPath = path.join(TEST_DATA_DIR, "call_logs", relPath); + fs.mkdirSync(path.dirname(absPath), { recursive: true }); + fs.writeFileSync( + absPath, + JSON.stringify({ + schemaVersion: 5, + requestBody: null, + responseBody: null, + error: null, + pipeline: { + clientRawRequest: { body: clientRawRequestBody }, + }, + }) + ); +} + +test("resolveTurnDisplayContent resolves plain text turns from the artifact's raw request body", () => { + insertCallLog({ id: "log-1", correlationId: "corr-1", artifactRelPath: "2026-01-01/log-1.json" }); + writeArtifact("2026-01-01/log-1.json", { + messages: [ + { role: "user", content: "hello there" }, + { role: "assistant", content: "hi!" }, + ], + }); + + const result = resolveTurnDisplayContent([{ lastCorrelationId: "corr-1" }]); + const userHash = hashTurnContent({ + role: "user", + text: "hello there", + blockKind: "text", + toolName: null, + }); + assert.deepEqual(result.get(userHash), { + textPreview: "hello there", + blockKind: "text", + toolName: null, + }); +}); + +test("resolveTurnDisplayContent resolves tool_use/tool_result shape, full and untruncated", () => { + const bigArgs = JSON.stringify({ path: "/tmp/big.md", content: "line\n".repeat(2000) }); + insertCallLog({ id: "log-2", correlationId: "corr-2", artifactRelPath: "2026-01-01/log-2.json" }); + writeArtifact("2026-01-01/log-2.json", { + input: [{ type: "function_call", name: "write", call_id: "c1", arguments: bigArgs }], + }); + + const result = resolveTurnDisplayContent([{ lastCorrelationId: "corr-2" }]); + const hash = hashTurnContent({ + role: "tool", + text: bigArgs, + blockKind: "tool_use", + toolName: "write", + }); + const content = result.get(hash); + assert.equal(content?.blockKind, "tool_use"); + assert.equal(content?.toolName, "write"); + // No 8000-char truncation anymore -- the full raw arguments string survives. + assert.equal(content?.textPreview, bigArgs); + assert.ok(content!.textPreview.length > 8000); +}); + +test("resolveTurnDisplayContent groups nodes by correlation id, reading each artifact once", () => { + insertCallLog({ id: "log-3", correlationId: "corr-3", artifactRelPath: "2026-01-01/log-3.json" }); + writeArtifact("2026-01-01/log-3.json", { + messages: [ + { role: "user", content: "a" }, + { role: "assistant", content: "b" }, + { role: "user", content: "c" }, + ], + }); + + const result = resolveTurnDisplayContent([ + { lastCorrelationId: "corr-3" }, + { lastCorrelationId: "corr-3" }, + { lastCorrelationId: "corr-3" }, + ]); + + for (const [role, text] of [ + ["user", "a"], + ["assistant", "b"], + ["user", "c"], + ] as const) { + const hash = hashTurnContent({ role, text, blockKind: "text", toolName: null }); + assert.equal(result.get(hash)?.textPreview, text); + } +}); + +test("resolveTurnDisplayContent skips nodes with no correlation id without throwing", () => { + const result = resolveTurnDisplayContent([{ lastCorrelationId: null }]); + assert.equal(result.size, 0); +}); + +test("resolveTurnDisplayContent omits content for an unresolvable correlation id (missing call_logs row, purged artifact, or no pipeline captured)", () => { + const missingRow = resolveTurnDisplayContent([{ lastCorrelationId: "corr-does-not-exist" }]); + assert.equal(missingRow.size, 0); + + insertCallLog({ id: "log-4", correlationId: "corr-4", artifactRelPath: null }); + const noArtifact = resolveTurnDisplayContent([{ lastCorrelationId: "corr-4" }]); + assert.equal(noArtifact.size, 0); + + insertCallLog({ + id: "log-5", + correlationId: "corr-5", + artifactRelPath: "2026-01-01/does-not-exist.json", + }); + const missingFile = resolveTurnDisplayContent([{ lastCorrelationId: "corr-5" }]); + assert.equal(missingFile.size, 0); +}); diff --git a/tests/unit/conversations-active-call-log-id.test.ts b/tests/unit/conversations-active-call-log-id.test.ts new file mode 100644 index 0000000000..9e8473f582 --- /dev/null +++ b/tests/unit/conversations-active-call-log-id.test.ts @@ -0,0 +1,81 @@ +/** + * Regression test for /api/conversations's `activeCallLogId` field. + * + * `call_logs` only gets its row on completion (src/lib/usage/callLogs.ts's + * INSERT needs duration/status/tokens, none of which exist yet while a reply + * is still streaming) — so `lastCallLogId` (joined from `call_logs`) always + * lags one request behind for a conversation with an in-flight reply. The + * conversation panel needs the CURRENT pending request's own id (tracked + * separately, in-memory, via usageHistory's pendingById) to poll its live + * partial text. This test proves the route surfaces that id, keyed off the + * pending request's `sessionTag` (== the conversation's own id). + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-conv-active-call-log-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const core = await import("../../src/lib/db/core.ts"); +const agenticConversations = await import("../../src/lib/db/agenticConversations.ts"); +const usageHistory = await import("../../src/lib/usage/usageHistory.ts"); +const route = await import("../../src/app/api/conversations/route.ts"); + +test.after(() => { + core.resetDbInstance(); + usageHistory.clearPendingRequests(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test.beforeEach(() => { + usageHistory.clearPendingRequests(); +}); + +function seedTwoTurnConversation(id: string) { + agenticConversations.createAgenticConversation({ id, apiKeyId: null, fingerprintHash: "fp" }); + agenticConversations.insertConversationTurnNodes(id, null, [ + { id: `${id}-n1`, parentId: null, role: "user", contentHash: "h1" }, + { id: `${id}-n2`, parentId: `${id}-n1`, role: "assistant", contentHash: "h2" }, + ]); +} + +test("GET /api/conversations: surfaces the in-flight pending request's own id as activeCallLogId", async () => { + const conversationId = "conv_active_test_1"; + seedTwoTurnConversation(conversationId); + + const pendingId = usageHistory.trackPendingRequest("gpt-4", "openai", "conn-1", true, { + sessionTag: conversationId, + }); + assert.ok(pendingId, "trackPendingRequest should return the generated pending id"); + + const res = await route.GET(new Request("http://localhost/api/conversations?limit=50")); + assert.equal(res.status, 200); + const body = (await res.json()) as { + conversations: Array<{ id: string; isActive: boolean; activeCallLogId: string | null }>; + }; + + const row = body.conversations.find((c) => c.id === conversationId); + assert.ok(row, "seeded conversation should be present in the response"); + assert.equal(row!.isActive, true); + assert.equal(row!.activeCallLogId, pendingId); +}); + +test("GET /api/conversations: activeCallLogId is null for a conversation with no in-flight request", async () => { + const conversationId = "conv_active_test_2"; + seedTwoTurnConversation(conversationId); + + const res = await route.GET(new Request("http://localhost/api/conversations?limit=50")); + assert.equal(res.status, 200); + const body = (await res.json()) as { + conversations: Array<{ id: string; isActive: boolean; activeCallLogId: string | null }>; + }; + + const row = body.conversations.find((c) => c.id === conversationId); + assert.ok(row, "seeded conversation should be present in the response"); + assert.equal(row!.isActive, false); + assert.equal(row!.activeCallLogId, null); +}); diff --git a/tests/unit/conversations-tree-route-seq-param.test.ts b/tests/unit/conversations-tree-route-seq-param.test.ts new file mode 100644 index 0000000000..d01ff1df7f --- /dev/null +++ b/tests/unit/conversations-tree-route-seq-param.test.ts @@ -0,0 +1,30 @@ +/** + * Regression test for /api/conversations/[id]/tree's query-param parsing. + * + * Real bug: `Number(searchParams.get("beforeSeq"))` is 0 (not NaN) when the + * param is absent, since `Number(null) === 0`. That made an ABSENT + * beforeSeq/afterSeq look like "beforeSeq=0"/"afterSeq=0" was explicitly + * given, which — because the DB layer checks `opts.afterSeq != null` (true + * for 0) BEFORE checking limit — forced every single request into the + * uncapped "poll for new turns" branch, ignoring `limit` entirely and + * returning the conversation's ENTIRE history on every load. + */ + +import test from "node:test"; +import assert from "node:assert/strict"; + +import { parseSeqParam } from "../../src/app/api/conversations/[id]/tree/route.ts"; + +test("parseSeqParam: an absent query param returns undefined, not 0", () => { + assert.equal(parseSeqParam(null), undefined); + assert.equal(parseSeqParam(""), undefined); +}); + +test("parseSeqParam: a real numeric string parses to that number, including a literal '0'", () => { + assert.equal(parseSeqParam("0"), 0); + assert.equal(parseSeqParam("42"), 42); +}); + +test("parseSeqParam: a non-numeric string returns undefined rather than NaN", () => { + assert.equal(parseSeqParam("not-a-number"), undefined); +}); diff --git a/tests/unit/dashboard/edit-connection-modal-openai-store-toggle.test.tsx b/tests/unit/dashboard/edit-connection-modal-openai-store-toggle.test.tsx index ca528c302c..bfd409768a 100644 --- a/tests/unit/dashboard/edit-connection-modal-openai-store-toggle.test.tsx +++ b/tests/unit/dashboard/edit-connection-modal-openai-store-toggle.test.tsx @@ -32,9 +32,8 @@ vi.mock("@/store/emailPrivacyStore", () => ({ default: () => ({ hidden: false, toggle: vi.fn() }), })); -const { default: EditConnectionModal } = await import( - "../../../src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx" -); +const { default: EditConnectionModal } = + await import("../../../src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx"); let container: HTMLDivElement; let root: Root; diff --git a/tests/unit/inspector-conversation-normalizer.test.ts b/tests/unit/inspector-conversation-normalizer.test.ts index 51ed564a52..f8a4b1ad27 100644 --- a/tests/unit/inspector-conversation-normalizer.test.ts +++ b/tests/unit/inspector-conversation-normalizer.test.ts @@ -81,9 +81,7 @@ test("normalizes OpenAI assistant tool_calls into tool_use blocks", () => { test("normalizes OpenAI tool role into tool_result", () => { const req = makeReq({ requestBody: JSON.stringify({ - messages: [ - { role: "tool", tool_call_id: "call-1", content: "sunny" }, - ], + messages: [{ role: "tool", tool_call_id: "call-1", content: "sunny" }], }), }); const conv = normalizeConversation(req); @@ -94,6 +92,79 @@ test("normalizes OpenAI tool role into tool_result", () => { assert.equal(blk.tool_use_id, "call-1"); }); +test("normalizes Responses API function_call/function_call_output items (no `role` field) into tool_use/tool_result turns", () => { + // Real OpenClaw traffic on the Responses API sends bare + // {type:"function_call"}/{type:"function_call_output"} items with NO + // `role` field at all — previously silently dropped (2026-08-06 bug: + // request 1785975096139-6627d2 showed zero tool calls in the Conversation + // Context panel despite the artifact having real function_call/ + // function_call_output items throughout). + const req = makeReq({ + path: "/v1/responses", + requestBody: JSON.stringify({ + input: [ + { role: "user", content: [{ type: "input_text", text: "run ls" }] }, + { + type: "function_call", + call_id: "call_00_abc", + name: "exec", + arguments: '{"command":"ls"}', + }, + { + type: "function_call_output", + call_id: "call_00_abc", + output: "file1.txt\nfile2.txt", + }, + ], + }), + }); + const conv = normalizeConversation(req); + assert.ok(conv); + assert.equal(conv.request.length, 3); + + assert.equal(conv.request[1].role, "assistant"); + const toolUse = conv.request[1].blocks[0] as { + type: "tool_use"; + id: string; + name: string; + input: unknown; + }; + assert.equal(toolUse.type, "tool_use"); + assert.equal(toolUse.id, "call_00_abc"); + assert.equal(toolUse.name, "exec"); + assert.deepEqual(toolUse.input, { command: "ls" }); + + assert.equal(conv.request[2].role, "tool"); + const toolResult = conv.request[2].blocks[0] as { + type: "tool_result"; + tool_use_id: string; + content: unknown; + }; + assert.equal(toolResult.type, "tool_result"); + assert.equal(toolResult.tool_use_id, "call_00_abc"); + assert.equal(toolResult.content, "file1.txt\nfile2.txt"); +}); + +test("normalizes Responses API reasoning items (no `role` field) into an assistant text turn", () => { + const req = makeReq({ + path: "/v1/responses", + requestBody: JSON.stringify({ + input: [ + { + type: "reasoning", + summary: [{ type: "summary_text", text: "Thinking about the request." }], + }, + ], + }), + }); + const conv = normalizeConversation(req); + assert.ok(conv); + assert.equal(conv.request.length, 1); + assert.equal(conv.request[0].role, "assistant"); + assert.equal(conv.request[0].blocks[0].type, "text"); + assert.equal((conv.request[0].blocks[0] as { text: string }).text, "Thinking about the request."); +}); + test("normalizes Anthropic request with top-level system + tool_use response", () => { const req = makeReq({ host: "api.anthropic.com", diff --git a/tests/unit/logs-detail-partial-reasoning-chunk-split.test.ts b/tests/unit/logs-detail-partial-reasoning-chunk-split.test.ts new file mode 100644 index 0000000000..34513c1791 --- /dev/null +++ b/tests/unit/logs-detail-partial-reasoning-chunk-split.test.ts @@ -0,0 +1,101 @@ +/** + * Regression test — the live "Generating… / Thinking…" preview in + * /api/logs/[id] read the request's in-flight stream-chunk log by parsing + * each logged chunk-array element independently. Each element is one raw + * network read (timestamp-prefixed for the debug display), not one complete + * SSE `data:` line, so a single JSON value (e.g. a `reasoning_content` delta) + * routinely splits across two or more elements. Parsing per-element in + * isolation intermittently fails JSON.parse and silently drops that piece, + * leaving gaps in the reconstructed text that read as garbled/scrambled + * reasoning once the survivors are concatenated — reported live via a + * dashboard screenshot showing exactly this on /dashboard/conversations. + */ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { extractPartialAssistantText } = await import("../../src/app/api/logs/[id]/route.ts"); + +function chunkLine(timestamp: string, json: unknown): string { + return `[${timestamp}] data: ${JSON.stringify(json)}\n\n`; +} + +test("extractPartialAssistantText: reasoning_content split across two chunk-log entries reassembles cleanly", () => { + const fullDelta = "Let me analyze this conversation carefully to create a checkpoint."; + // Simulate the exact real-world failure: a raw network read boundary lands + // mid-JSON-string, so the JSON text for one `reasoning_content` delta value + // is split across two separately-timestamped chunk-log array elements. + const splitPoint = 30; + const firstHalfJson = JSON.stringify({ + choices: [{ delta: { reasoning_content: fullDelta.slice(0, splitPoint) } }], + }); + const secondHalfJson = JSON.stringify({ + choices: [{ delta: { reasoning_content: fullDelta.slice(splitPoint) } }], + }); + const splitAt = firstHalfJson.indexOf(fullDelta.slice(0, splitPoint)) + splitPoint; + + const chunkArr = [ + `[23:55:00.100] data: ${firstHalfJson.slice(0, splitAt)}`, + `[23:55:00.101] ${firstHalfJson.slice(splitAt)}\n\n`, + chunkLine("23:55:00.102", { choices: [{ delta: { reasoning_content: "" } }] }), + ]; + // The second delta value is itself split too, to prove multi-split survives. + const secondSplitAt = 10; + chunkArr.push(`[23:55:00.103] data: ${secondHalfJson.slice(0, secondSplitAt)}`); + chunkArr.push(`[23:55:00.104] ${secondHalfJson.slice(secondSplitAt)}\n\n`); + + const result = extractPartialAssistantText({ client: chunkArr }); + + assert.equal( + result, + `_Thinking…_\n\n${fullDelta}`, + "the reasoning text must reassemble whole, with no gaps from the split JSON values" + ); +}); + +test("extractPartialAssistantText: without concatenation-first, the split would silently drop reasoning text (documents the bug this test guards against)", () => { + // Direct demonstration of the OLD (buggy) per-element parsing behavior, so + // this test file also documents exactly what broke: parsing each element + // in isolation, a fragment split mid-JSON-string is unparseable on its own. + const fullDelta = "some reasoning text"; + const json = JSON.stringify({ choices: [{ delta: { reasoning_content: fullDelta } }] }); + const splitAt = Math.floor(json.length / 2); + const first = `[00:00:00.000] data: ${json.slice(0, splitAt)}`; + const second = `[00:00:00.001] ${json.slice(splitAt)}`; + + const oldBuggyParse = (raw: string): string | null => { + const idx = raw.indexOf("data:"); + if (idx === -1) return null; + try { + JSON.parse(raw.slice(idx + 5).trim()); + return "parsed"; + } catch { + return null; + } + }; + + assert.equal(oldBuggyParse(first), null, "first fragment alone is not valid JSON"); + assert.equal(oldBuggyParse(second), null, "second fragment alone is not valid JSON either"); + + // But the fixed function, which concatenates before parsing, recovers it fully. + const result = extractPartialAssistantText({ client: [first, second] }); + assert.equal(result, `_Thinking…_\n\n${fullDelta}`); +}); + +test("extractPartialAssistantText: content (not just reasoning) also survives a chunk-log split", () => { + const fullText = "The answer is forty-two."; + const json = JSON.stringify({ choices: [{ delta: { content: fullText } }] }); + const splitAt = Math.floor(json.length / 2); + const chunkArr = [ + `[10:00:00.000] data: ${json.slice(0, splitAt)}`, + `[10:00:00.001] ${json.slice(splitAt)}`, + ]; + + const result = extractPartialAssistantText({ provider: chunkArr }); + assert.equal(result, fullText); +}); + +test("extractPartialAssistantText: no reasoning/content anywhere returns empty string", () => { + assert.equal(extractPartialAssistantText(null), ""); + assert.equal(extractPartialAssistantText({}), ""); + assert.equal(extractPartialAssistantText({ client: [] }), ""); +}); diff --git a/tests/unit/request-timeline-lane-allocation.test.ts b/tests/unit/request-timeline-lane-allocation.test.ts new file mode 100644 index 0000000000..c079eb918e --- /dev/null +++ b/tests/unit/request-timeline-lane-allocation.test.ts @@ -0,0 +1,76 @@ +/** + * Unit tests for RequestTimeline's allocateLanes conversation-aware lane + * reuse (agentic conversation tracking / X-ConversationId). + */ + +import test from "node:test"; +import assert from "node:assert/strict"; + +import { allocateLanes, type TimelineLog } from "../../src/shared/components/RequestTimeline.tsx"; + +function log( + id: string, + timestampMs: number, + durationMs: number, + sessionTag: string | null = null +): TimelineLog { + return { + id, + timestamp: new Date(timestampMs).toISOString(), + status: 200, + model: "test-model", + provider: "test-provider", + account: null, + duration: durationMs, + tokens: { in: 0, out: 0 }, + completed: true, + sessionTag, + }; +} + +const BASE = 1_800_000_000_000; // arbitrary fixed epoch ms + +test("allocateLanes: unrelated non-overlapping bars share a lane as before (no regression)", () => { + const items = [log("a", BASE, 1000), log("b", BASE + 5000, 1000)]; + const lanes = allocateLanes(items, BASE + 10_000); + assert.equal(lanes.get("a"), lanes.get("b")); +}); + +test("allocateLanes: same conversation id reuses the same lane within the reuse window", () => { + const items = [ + log("a", BASE, 1000, "conv-1"), + // Overlapping in time with "a" would normally force a different lane — + // but sharing conv-1 within the reuse window should force it onto a's lane. + log("b", BASE + 500, 1000, "conv-1"), + ]; + const lanes = allocateLanes(items, BASE + 10_000, 2 * 60 * 1000); + assert.equal(lanes.get("a"), lanes.get("b")); +}); + +test("allocateLanes: same conversation id falls back to normal packing outside the reuse window", () => { + const reuseWindowMs = 2 * 60 * 1000; + const items = [ + log("a", BASE, 1000, "conv-2"), + // Same conversation id, but arrives long after the reuse window lapsed — + // must NOT be forced onto a's lane if that lane is still busy with + // something else (falls back to the ordinary overlap-avoidance packer). + log("b", BASE + reuseWindowMs + 60_000, 1000, "conv-2"), + // Occupies a's lane again right after "a" finishes, before "b" arrives — + // forces "b" to pack elsewhere via the normal greedy logic. + log("c", BASE + 2000, 1000, null), + ]; + const lanes = allocateLanes(items, BASE + reuseWindowMs + 65_000, reuseWindowMs); + // "a" and "c" share a's lane (c starts after a ends); "b" arrives far later + // and long after the reuse window, so it is free to reuse that same lane + // once it's genuinely free again — the key assertion is that "b" was NOT + // force-placed via conversation reuse logic (which only applies within the + // window), i.e. this is ordinary greedy packing, not identity-based. + assert.equal(lanes.get("a"), lanes.get("c")); + assert.ok(lanes.get("b") !== undefined); +}); + +test("allocateLanes: different conversation ids never share a lane just for overlapping in time", () => { + const items = [log("a", BASE, 5000, "conv-x"), log("b", BASE + 1000, 5000, "conv-y")]; + const lanes = allocateLanes(items, BASE + 10_000); + assert.notEqual(lanes.get("a"), lanes.get("b")); +}); diff --git a/tests/unit/sidebar-monitoring-reorg.test.ts b/tests/unit/sidebar-monitoring-reorg.test.ts index fbb65c1ed0..4f6d4a670c 100644 --- a/tests/unit/sidebar-monitoring-reorg.test.ts +++ b/tests/unit/sidebar-monitoring-reorg.test.ts @@ -83,7 +83,7 @@ test("monitoring section activity item has correct href and icon", () => { assert.equal(activityItem.i18nKey, "activity"); }); -test("monitoring logs group contains logs, logs-proxy, logs-console, logs-timeline", () => { +test("monitoring logs group contains logs, logs-proxy, logs-console, logs-timeline, conversations", () => { const section = findSection("monitoring"); assert.ok(section, "monitoring section must exist"); @@ -94,7 +94,13 @@ test("monitoring logs group contains logs, logs-proxy, logs-console, logs-timeli assert.ok(logsGroup, "logs group must exist in monitoring"); const itemIds = logsGroup.items.map((i) => i.id); - assert.deepEqual(itemIds, ["logs", "logs-proxy", "logs-console", "logs-timeline"]); + assert.deepEqual(itemIds, [ + "logs", + "logs-proxy", + "logs-console", + "logs-timeline", + "conversations", + ]); }); test("monitoring system group contains health, runtime, and connection resilience", () => { diff --git a/tests/unit/sidebar-visibility.test.ts b/tests/unit/sidebar-visibility.test.ts index 49332f9568..aff5ec3ca4 100644 --- a/tests/unit/sidebar-visibility.test.ts +++ b/tests/unit/sidebar-visibility.test.ts @@ -24,6 +24,7 @@ test("system sidebar items: monitoring has activity at top then logs/audit/syste "logs-proxy", "logs-console", "logs-timeline", + "conversations", "audit", "audit-mcp", "audit-a2a",