diff --git a/.env.example b/.env.example index 4d47b6b064c..eb7350a93e2 100644 --- a/.env.example +++ b/.env.example @@ -677,6 +677,9 @@ BAN_VIOLATIONS=true BAN_DURATION=1000 * 60 * 60 * 2 BAN_INTERVAL=20 +# Violation scores expire after this long (in ms) without new violations; 0 = never expire +VIOLATION_SCORE_TTL=1000 * 60 * 60 + LOGIN_VIOLATION_SCORE=1 REGISTRATION_VIOLATION_SCORE=1 CONCURRENT_VIOLATION_SCORE=1 diff --git a/.github/workflows/codegraph-e2e-votes.yml b/.github/workflows/codegraph-e2e-votes.yml index fdcbe79626c..b3783e26588 100644 --- a/.github/workflows/codegraph-e2e-votes.yml +++ b/.github/workflows/codegraph-e2e-votes.yml @@ -1,15 +1,22 @@ # Codegraph e2e VOTES — observe-only, post-merge, time-boxed. # # Playwright never runs on pushes to dev, so evidence for the e2e skip election would -# otherwise wait on rare organic PR spec failures. This workflow runs EXACTLY the skippable -# tier the merged PR's selection computed — every merge becomes a direct trial of "would -# skipping these specs have missed a failure". A green run is a confirmation vote; a failing -# spec here is a tier-miss vote counted AGAINST enabling skipping. The shadow evaluator on -# the codegraph droplet harvests these runs and attributes them back to the merged PR. +# otherwise wait on rare organic PR spec failures. This workflow runs the FULL mock suite on +# every merge: each run is one graduation trial for every spec it executes, and doubles as +# the post-merge safety net the jest workflows already have via their dev-push triggers. # -# It cannot fail the branch: the tier lookup exits 0 on every path and the test step is -# continue-on-error. The newest merge cancels older vote runs. The whole campaign switches -# off by setting repo variable CODEGRAPH_E2E_VOTES=off once the election passes. +# It previously ran only the merged PR's skippable tier, passing the tier as CLI path +# filters. playwright.config.mock.ts scopes discovery to testDir specs/mock/, so tier +# entries outside that directory matched nothing — and the covered-list log line still +# claimed them, minting graduation trials for specs that never executed (run 32701691037: +# a11y/keys/messages in the covered list, zero of their tests run). The covered list below +# is therefore derived from the run's EXECUTED results — discovery is not enough either, +# since env-gated suites self-skip under this job's default env — and the run takes no +# path filters at all. +# +# It cannot fail the branch: the test step is continue-on-error. The newest merge cancels +# older vote runs. The whole campaign switches off by setting repo variable +# CODEGRAPH_E2E_VOTES=off once the election passes. name: Codegraph E2E Votes on: @@ -20,6 +27,7 @@ on: - '**' - '!**.md' - '!.github/workflows/**' + - '.github/workflows/codegraph-e2e-votes.yml' permissions: contents: read @@ -34,10 +42,10 @@ env: jobs: vote: - name: vote (skippable tier) + name: vote (full suite) if: vars.CODEGRAPH_E2E_VOTES != 'off' runs-on: ubuntu-latest - timeout-minutes: 30 + timeout-minutes: 45 env: CI: 'true' E2E_CHROMIUM_CHANNEL: chrome @@ -45,56 +53,12 @@ jobs: steps: - uses: actions/checkout@v5 - - name: Ask codegraph for this merge's skippable tier - id: tiers - env: - URL: ${{ secrets.CODEGRAPH_URL }} - TOKEN: ${{ secrets.CODEGRAPH_TOKEN }} - GH_TOKEN: ${{ github.token }} - run: | - set +e - N=0 - if [ -n "$URL" ] && [ -n "$TOKEN" ]; then - gh api "repos/${{ github.repository }}/commits/${{ github.sha }}" \ - --jq '[.files[] | {path: .filename, status: .status}]' > files.json 2>/dev/null - if [ -s files.json ]; then - jq -c '{files: .}' files.json > body.json - RESP=$(curl -sS -m 45 -H "Authorization: Bearer $TOKEN" \ - -H 'content-type: application/json' --data-binary @body.json "$URL/v1/select") - # fail_open reflects the JEST floors (root config, lockfile, stale graph); the - # e2e tiers come from the testid bridge and are valid whenever they computed at - # all. The old fail-open skip silently excused exactly the big backend merges - # whose trials matter most (LibreChat#14957's merge produced no vote because - # api/package.json tripped the jest floor). Tiers present => vote. - if ! echo "$RESP" | jq -e '.e2e.skippable' >/dev/null 2>&1; then - echo "codegraph unavailable or no tiers; skipping" - else - echo "$RESP" | jq -r '.e2e.skippable[]' | sed 's|^e2e/||' > skippable.txt - N=$(wc -l < skippable.txt | tr -d ' ') - fi - else - echo "could not read merge commit files; skipping" - fi - else - echo "no codegraph config; skipping" - fi - echo "codegraph-votes: running $N skippable specs" - # The exact list, one log line: the shadow's per-spec graduation ledger counts a clean - # trial for every spec a green vote run covered, and until this line existed it had to - # approximate coverage from the decision event's tier (drift: the tier is recomputed - # here at the merge commit against a possibly newer graph head). - if [ "$N" != "0" ]; then echo "codegraph-votes-specs: $(tr '\n' ' ' < skippable.txt)"; fi - echo "count=$N" >> "$GITHUB_OUTPUT" - exit 0 - - name: Use Node.js 24.16.0 - if: steps.tiers.outputs.count != '0' uses: actions/setup-node@v5 with: node-version: '24.16.0' - name: Restore node_modules cache - if: steps.tiers.outputs.count != '0' id: cache-node-modules uses: actions/cache@v5 with: @@ -109,11 +73,10 @@ jobs: key: node-modules-e2e-${{ runner.os }}-24.16.0-${{ hashFiles('package-lock.json') }} - name: Install dependencies - if: steps.tiers.outputs.count != '0' && steps.cache-node-modules.outputs.cache-hit != 'true' + if: steps.cache-node-modules.outputs.cache-hit != 'true' run: npm ci - name: Restore data-provider build cache - if: steps.tiers.outputs.count != '0' id: cache-data-provider uses: actions/cache@v5 with: @@ -121,11 +84,10 @@ jobs: key: build-data-provider-${{ runner.os }}-${{ hashFiles('package.json', 'package-lock.json', 'packages/data-provider/src/**', 'packages/data-provider/tsconfig*.json', 'packages/data-provider/tsdown.config.mjs', 'packages/data-provider/package.json') }} - name: Build data-provider - if: steps.tiers.outputs.count != '0' && steps.cache-data-provider.outputs.cache-hit != 'true' + if: steps.cache-data-provider.outputs.cache-hit != 'true' run: npm run build:data-provider - name: Restore data-schemas build cache - if: steps.tiers.outputs.count != '0' id: cache-data-schemas uses: actions/cache@v5 with: @@ -133,11 +95,10 @@ jobs: key: build-data-schemas-${{ runner.os }}-${{ hashFiles('package.json', 'package-lock.json', 'packages/data-schemas/src/**', 'packages/data-schemas/tsconfig*.json', 'packages/data-schemas/tsdown.config.mjs', 'packages/data-schemas/package.json', 'packages/data-provider/src/**', 'packages/data-provider/tsconfig*.json', 'packages/data-provider/tsdown.config.mjs', 'packages/data-provider/package.json') }} - name: Build data-schemas - if: steps.tiers.outputs.count != '0' && steps.cache-data-schemas.outputs.cache-hit != 'true' + if: steps.cache-data-schemas.outputs.cache-hit != 'true' run: npm run build:data-schemas - name: Restore api build cache - if: steps.tiers.outputs.count != '0' id: cache-api uses: actions/cache@v5 with: @@ -145,11 +106,10 @@ jobs: key: build-api-${{ runner.os }}-${{ hashFiles('package.json', 'package-lock.json', 'packages/api/src/**', 'packages/api/tsconfig*.json', 'packages/api/tsdown.config.mjs', 'packages/api/package.json', 'packages/data-provider/src/**', 'packages/data-provider/tsconfig*.json', 'packages/data-provider/tsdown.config.mjs', 'packages/data-provider/package.json', 'packages/data-schemas/src/**', 'packages/data-schemas/tsconfig*.json', 'packages/data-schemas/tsdown.config.mjs', 'packages/data-schemas/package.json') }} - name: Build api - if: steps.tiers.outputs.count != '0' && steps.cache-api.outputs.cache-hit != 'true' + if: steps.cache-api.outputs.cache-hit != 'true' run: npm run build:api - name: Restore client-package build cache - if: steps.tiers.outputs.count != '0' id: cache-client-package uses: actions/cache@v5 with: @@ -157,11 +117,10 @@ jobs: key: build-client-package-${{ runner.os }}-${{ hashFiles('package.json', 'package-lock.json', 'packages/client/src/**', 'packages/client/tsconfig*.json', 'packages/client/tsdown.config.mjs', 'packages/client/package.json', 'packages/data-provider/src/**', 'packages/data-provider/tsconfig*.json', 'packages/data-provider/tsdown.config.mjs', 'packages/data-provider/package.json') }} - name: Build client-package - if: steps.tiers.outputs.count != '0' && steps.cache-client-package.outputs.cache-hit != 'true' + if: steps.cache-client-package.outputs.cache-hit != 'true' run: npm run build:client-package - name: Restore client app build cache - if: steps.tiers.outputs.count != '0' id: cache-client-app uses: actions/cache@v5 with: @@ -169,24 +128,21 @@ jobs: key: build-client-app-e2e-${{ runner.os }}-${{ hashFiles('package.json', 'package-lock.json', 'client/src/**', 'client/public/**', 'client/scripts/post-build.cjs', 'client/index.html', 'client/package.json', 'client/vite.config.*', 'client/tsconfig*.json', 'client/tailwind.config.*', 'client/postcss.config.*', 'packages/client/src/**', 'packages/client/tailwind.preset.cjs', 'packages/client/tsconfig*.json', 'packages/client/tsdown.config.mjs', 'packages/client/package.json', 'packages/data-provider/src/**', 'packages/data-provider/tsconfig*.json', 'packages/data-provider/tsdown.config.mjs', 'packages/data-provider/package.json') }} - name: Build client app - if: steps.tiers.outputs.count != '0' && steps.cache-client-app.outputs.cache-hit != 'true' + if: steps.cache-client-app.outputs.cache-hit != 'true' run: npm run build:client - name: Verify Chrome is present - if: steps.tiers.outputs.count != '0' run: google-chrome --version # ffmpeg for retry video — see the note in playwright-mock.yml. - name: Resolve Playwright version id: playwright-version - if: steps.tiers.outputs.count != '0' run: | version=$(node -p "require('./package-lock.json').packages['node_modules/playwright-core'].version") echo "version=${version}" >> "$GITHUB_OUTPUT" - name: Restore Playwright ffmpeg cache id: cache-ffmpeg - if: steps.tiers.outputs.count != '0' uses: actions/cache/restore@v5 with: path: ~/.cache/ms-playwright @@ -194,7 +150,7 @@ jobs: - name: Install Playwright ffmpeg (best effort) id: install-ffmpeg - if: steps.tiers.outputs.count != '0' && steps.cache-ffmpeg.outputs.cache-hit != 'true' + if: steps.cache-ffmpeg.outputs.cache-hit != 'true' timeout-minutes: 3 continue-on-error: true run: | @@ -202,7 +158,7 @@ jobs: .github/scripts/verify-playwright-ffmpeg.sh - name: Save Playwright ffmpeg cache - if: steps.tiers.outputs.count != '0' && steps.install-ffmpeg.outcome == 'success' + if: steps.install-ffmpeg.outcome == 'success' continue-on-error: true uses: actions/cache/save@v5 with: @@ -211,15 +167,40 @@ jobs: # Optional fonts only — see the note in playwright-mock.yml. - name: Install optional Playwright font dependencies (best effort) - if: steps.tiers.outputs.count != '0' timeout-minutes: 4 continue-on-error: true run: .github/scripts/install-playwright-fonts.sh - - name: Vote — run the skippable tier (cannot fail the branch) - if: steps.tiers.outputs.count != '0' + - name: Vote — run the full mock suite (cannot fail the branch) continue-on-error: true - run: npx playwright test --config=e2e/playwright.config.mock.ts $(tr '\n' ' ' < skippable.txt) + env: + # Absolute on purpose: Playwright resolves a relative PLAYWRIGHT_JSON_OUTPUT_NAME + # against the CONFIG directory (e2e/), not the working directory — the first live run + # wrote e2e/pw-results.json while the ledger looked in the repo root and logged zero + # trials (fail-safe, but a silent no-op). + PLAYWRIGHT_JSON_OUTPUT_NAME: ${{ github.workspace }}/pw-results.json + run: npx playwright test --config=e2e/playwright.config.mock.ts --reporter=line,json + + - name: Ledger — log the specs that actually executed + run: | + set +e + # The shadow's per-spec graduation ledger counts a clean trial for every spec a green + # run covered, so the covered list must come from EXECUTED tests, not from discovery: + # env-gated suites (mcp-tool-list-changed needs E2E_MCP_LIST_CHANGED, enforced-model- + # specs needs E2E_MODEL_SPECS_ENFORCE) are discovered by --list yet skip every test + # under this job's default env — counting them as covered would mint phantom trials, + # the exact bug this workflow revision exists to kill (Codex P1 on #15162). A spec is + # covered iff at least one of its tests reached a non-skipped outcome. + if jq -e '.suites' "$GITHUB_WORKSPACE/pw-results.json" >/dev/null 2>&1; then + jq -r '[.suites[] | recurse(.suites[]?) | .specs[]? | select([.tests[]?.status] | any(. != "skipped")) | .file] | unique | .[]' "$GITHUB_WORKSPACE/pw-results.json" \ + | sed 's|^|specs/mock/|' > covered.txt + N=$(wc -l < covered.txt | tr -d ' ') + echo "codegraph-votes: running $N specs (executed, full suite)" + if [ "$N" != "0" ]; then echo "codegraph-votes-specs: $(tr '\n' ' ' < covered.txt)"; fi + else + echo "codegraph-votes: no results json — run crashed before reporting; no trials logged" + fi + exit 0 - name: Done if: always() diff --git a/.github/workflows/playwright-mock.yml b/.github/workflows/playwright-mock.yml index 9e489d320a3..25b6f1c8f7f 100644 --- a/.github/workflows/playwright-mock.yml +++ b/.github/workflows/playwright-mock.yml @@ -49,6 +49,7 @@ jobs: decided: ${{ steps.sel.outputs.decided }} e2e_include: ${{ steps.sel.outputs.e2e_include }} mcp_run: ${{ steps.sel.outputs.mcp_run }} + e2e_skip: ${{ steps.sel.outputs.e2e_skip }} steps: - name: Select matrix lanes, fail open on any doubt id: sel @@ -61,6 +62,7 @@ jobs: BASE_SHA: ${{ github.event.pull_request.base.sha }} HEAD_SHA: ${{ github.event.pull_request.head.sha }} CHANGED: ${{ github.event.pull_request.changed_files }} + E2E_SKIP_ARMED: ${{ vars.CODEGRAPH_E2E_SKIP }} FULL_INCLUDE: '{"include":[{"name":"memory, shard 1/3","stream_store":"memory","redis_image":"","suite":"full","shard":"1/3","artifact":"memory-1-of-3"},{"name":"memory, shard 2/3","stream_store":"memory","redis_image":"","suite":"full","shard":"2/3","artifact":"memory-2-of-3"},{"name":"memory, shard 3/3","stream_store":"memory","redis_image":"","suite":"full","shard":"3/3","artifact":"memory-3-of-3"},{"name":"redis transport","stream_store":"redis","redis_image":"redis:7-alpine","suite":"transport","shard":"","artifact":"redis-transport"}]}' run: | set +e @@ -115,6 +117,25 @@ jobs: exit 0 fi echo "e2e_include=$INCLUDE" >> "$GITHUB_OUTPUT" + # Graduated per-spec skips are DARK until the operator arms repo variable + # CODEGRAPH_E2E_SKIP=on (the election switch — flipped only when the pre-registered + # resume condition holds). Even then, act only on a well-typed list from a non-fail-open + # decision: every entry must be a pool spec path, or nothing is skipped. The server + # already intersects with this PR's skippable tier and applies the streak bars + # (2x where history-coupled); see codegraph-poc service/graduate.ts. + SKIP="" + if [ "$E2E_SKIP_ARMED" = "on" ]; then + if echo "$RESP" | jq -e '(.e2e.fail_open != true) and (.e2e.graduated | type == "array" and all(.[]?; type == "string" and test("^e2e/specs/mock/[A-Za-z0-9._/-]+\\.spec\\.ts$") and (contains("..") | not)))' >/dev/null 2>&1; then + SKIP=$(echo "$RESP" | jq -r '[.e2e.graduated[] | sub("^e2e/"; "")] | join(" ")') + else + note "_graduated list absent or malformed; no specs skipped_" + fi + fi + echo "e2e_skip=$SKIP" >> "$GITHUB_OUTPUT" + if [ -n "$SKIP" ]; then + note "| graduated spec skips | $(echo "$SKIP" | wc -w | tr -d ' ') (armed) |" + echo "codegraph-e2e-graduated-skips: $SKIP" + fi echo "codegraph-select: redis_transport=$REDIS mcp_tool_list_changed=$MCP matrix_entries=$(echo "$INCLUDE" | jq '.include | length')" if [ "$MCP_SKIP" = 1 ]; then echo "mcp_run=false" >> "$GITHUB_OUTPUT" @@ -305,9 +326,33 @@ jobs: - name: Run full mock-LLM Tier-1 e2e if: matrix.suite == 'full' - run: npx playwright test --config=e2e/playwright.config.mock.ts --shard=${{ matrix.shard }} env: CI: 'true' + E2E_SKIP: ${{ needs.codegraph_select.outputs.e2e_skip }} + run: | + set +e + # Graduated-spec skipping (dark until repo var CODEGRAPH_E2E_SKIP=on upstream): subtract + # the earned skips from a run list derived from the tree itself, so an unknown or stale + # name in the skip list simply matches nothing. If subtraction would drop everything — + # or drops nothing — run the full shard exactly as before. Skipped specs still execute + # post-merge in every full-suite vote run, which is the net that catches a wrong skip. + RUN_ARGS="" + if [ -n "$E2E_SKIP" ]; then + KEEP=""; DROP=0 + for spec in $(git ls-files 'e2e/specs/mock/*.spec.ts' 'e2e/specs/mock/**/*.spec.ts' | sed 's|^e2e/||' | sort -u); do + case "$spec" in *" "*) KEEP="$KEEP $spec"; continue;; esac + case " $E2E_SKIP " in + *" $spec "*) DROP=$((DROP+1));; + *) KEEP="$KEEP $spec";; + esac + done + if [ "$DROP" -gt 0 ] && [ -n "$KEEP" ]; then + RUN_ARGS="$KEEP" + echo "codegraph-e2e-skip: dropped $DROP graduated specs from this shard's pool" + fi + fi + set -e + npx playwright test --config=e2e/playwright.config.mock.ts --shard=${{ matrix.shard }} $RUN_ARGS - name: Run Redis stream transport e2e if: matrix.suite == 'transport' diff --git a/api/package.json b/api/package.json index 396f6fe83ed..6f40e39b729 100644 --- a/api/package.json +++ b/api/package.json @@ -46,7 +46,7 @@ "@azure/storage-blob": "^12.30.0", "@google/genai": "^2.8.0", "@keyv/redis": "5.1.6", - "@librechat/agents": "^3.6.16", + "@librechat/agents": "^3.7.1", "@librechat/api": "*", "@librechat/data-schemas": "*", "@microsoft/microsoft-graph-client": "^3.0.7", diff --git a/api/server/controllers/agents/client.js b/api/server/controllers/agents/client.js index 0332bdddc12..320abe0691d 100644 --- a/api/server/controllers/agents/client.js +++ b/api/server/controllers/agents/client.js @@ -1693,13 +1693,36 @@ class AgentClient extends BaseClient { let hasFileContext = false; let promptTokenTotal = 0; const encoding = this.getEncoding(); - const formattedMessages = orderedMessages.map((message, i) => { - const formattedMessage = formatMessage({ + /** + * Rebuilds the memory-side copy of one source row: the same formatting and + * per-message merges as the prompt copy, minus the fileContext prepend. + * Only materialized when something actually consumes it — the canonical + * recount of a fileContext row, or the memory payload once any row proves + * to carry fileContext — instead of unconditionally formatting every row + * twice per turn. + */ + const buildMemoryFormattedMessage = (message) => { + const memoryFormattedMessage = formatMessage({ message, userName: this.options?.name, assistantName: this.options?.modelLabel, }); - const memoryFormattedMessage = formatMessage({ + const sourceMessageId = message.messageId ?? message.id; + if (typeof sourceMessageId === 'string' && sourceMessageId.length > 0) { + memoryFormattedMessage.messageId = sourceMessageId; + } + if (Array.isArray(message.quotes) && message.quotes.length > 0) { + prependQuotes(memoryFormattedMessage, message.quotes); + } + const turnFiles = this.message_file_map?.[message.messageId] ?? message.files; + applyAttachmentOnlyText(memoryFormattedMessage, turnFiles); + return memoryFormattedMessage; + }; + /** Memory copies built for canonical recounts, reused by the memory payload pass. */ + const memoryFormattedMessages = []; + + const formattedMessages = orderedMessages.map((message, i) => { + const formattedMessage = formatMessage({ message, userName: this.options?.name, assistantName: this.options?.modelLabel, @@ -1707,7 +1730,6 @@ class AgentClient extends BaseClient { const sourceMessageId = message.messageId ?? message.id; if (typeof sourceMessageId === 'string' && sourceMessageId.length > 0) { formattedMessage.messageId = sourceMessageId; - memoryFormattedMessage.messageId = sourceMessageId; } /** @@ -1732,7 +1754,6 @@ class AgentClient extends BaseClient { */ if (Array.isArray(message.quotes) && message.quotes.length > 0) { prependQuotes(formattedMessage, message.quotes); - prependQuotes(memoryFormattedMessage, message.quotes); } /** @@ -1746,9 +1767,6 @@ class AgentClient extends BaseClient { */ const turnFiles = this.message_file_map?.[message.messageId] ?? message.files; applyAttachmentOnlyText(formattedMessage, turnFiles); - applyAttachmentOnlyText(memoryFormattedMessage, turnFiles); - - memoryPayload.push(memoryFormattedMessage); const dbTokenCount = Number(orderedMessages[i].tokenCount); const hasDbTokenCount = Number.isFinite(dbTokenCount) && dbTokenCount > 0; @@ -1766,7 +1784,15 @@ class AgentClient extends BaseClient { let canonicalTokenCount = hasDbTokenCount ? dbTokenCount : 0; if (needsCanonicalTokenCount) { - canonicalTokenCount = countFormattedMessageTokens(memoryFormattedMessage, encoding); + /** Without fileContext the memory copy is content-identical to the + * prompt copy, so the prompt copy is the counting surface; with it, + * the canonical count must exclude the prepended context. */ + let countSurface = formattedMessage; + if (message.fileContext) { + memoryFormattedMessages[i] = buildMemoryFormattedMessage(message); + countSurface = memoryFormattedMessages[i]; + } + canonicalTokenCount = countFormattedMessageTokens(countSurface, encoding); } const promptMessageTokenCount = message.fileContext @@ -1917,6 +1943,13 @@ class AgentClient extends BaseClient { } } } + if (hasFileContext) { + for (let i = 0; i < orderedMessages.length; i++) { + memoryPayload.push( + memoryFormattedMessages[i] ?? buildMemoryFormattedMessage(orderedMessages[i]), + ); + } + } this.memoryPayload = hasFileContext ? memoryPayload : null; messages = orderedMessages; promptTokens = promptTokenTotal; @@ -3288,13 +3321,15 @@ class AgentClient extends BaseClient { manualSkillPrimes, alwaysApplySkillPrimes, }); + const useLegacyContent = this.options.agent?.useLegacyContent === true; const formatOptions = - needsReasoningContentFormat || freshSkillPrimeNames.size > 0 + needsReasoningContentFormat || freshSkillPrimeNames.size > 0 || useLegacyContent ? { ...(needsReasoningContentFormat ? { preserveReasoningContent: true } : {}), ...(freshSkillPrimeNames.size > 0 ? { skipSkillBodyNames: freshSkillPrimeNames } : {}), + ...(useLegacyContent ? { legacyContent: true } : {}), } : undefined; let { diff --git a/api/server/controllers/agents/client.test.js b/api/server/controllers/agents/client.test.js index 027feffe129..073daddbf0c 100644 --- a/api/server/controllers/agents/client.test.js +++ b/api/server/controllers/agents/client.test.js @@ -3783,6 +3783,44 @@ describe('AgentClient - titleConvo', () => { expect(client.memoryPayload[0].content).toBe('What is written here?'); }); + it('recounts a quote-bearing history row from quote-merged content and keeps the memory payload unbuilt without file context', async () => { + const { countFormattedMessageTokens } = require('@librechat/api'); + countFormattedMessageTokens.mockImplementation(({ content }) => { + const text = Array.isArray(content) + ? content.map((part) => part.text ?? part[ContentTypes.TEXT] ?? '').join('\n') + : String(content ?? ''); + return text.includes('quoted excerpt') ? 77 : 11; + }); + + const result = await client.buildMessages( + [ + { + messageId: 'msg-1', + parentMessageId: null, + sender: 'User', + text: 'Discuss this.', + isCreatedByUser: true, + tokenCount: 5, + quotes: ['quoted excerpt'], + }, + { + messageId: 'msg-2', + parentMessageId: 'msg-1', + sender: 'Assistant', + text: 'Sure.', + isCreatedByUser: false, + tokenCount: 3, + }, + ], + 'msg-2', + {}, + ); + + expect(result.tokenCountMap['msg-1']).toBe(77); + expect(result.tokenCountMap['msg-2']).toBe(3); + expect(client.memoryPayload).toBeNull(); + }); + it('does not duplicate a file that is both request context and scoped context', async () => { const sharedFile = makeTextFile('shared-file', 'shared.txt', 'Shared duplicate context'); diff --git a/api/server/routes/__test-utils__/convos-route-mocks.js b/api/server/routes/__test-utils__/convos-route-mocks.js index 0cef2cbfee6..5ae8b2b636a 100644 --- a/api/server/routes/__test-utils__/convos-route-mocks.js +++ b/api/server/routes/__test-utils__/convos-route-mocks.js @@ -4,11 +4,22 @@ const generationJobManager = { abortJob: jest.fn().mockResolvedValue({ success: true }), }; const subagentActivityHandlerInputs = []; +const moderatedTexts = []; +const moderateText = jest.fn((req, _res, next) => { + moderatedTexts.push(req.body?.text); + next(); +}); +const messageIpLimiter = jest.fn((_req, _res, next) => next()); +const messageUserLimiter = jest.fn((_req, _res, next) => next()); module.exports = { archiveAllHandler, generationJobManager, subagentActivityHandlerInputs, + moderateText, + moderatedTexts, + messageIpLimiter, + messageUserLimiter, agents: () => ({ sleep: jest.fn() }), @@ -40,6 +51,36 @@ module.exports = { return archiveAllHandler; }), createSubagentThreadViewHandler: jest.fn(() => (_req, res) => res.status(200).json({})), + createSubagentControlHandler: jest.fn(() => (_req, res) => res.status(200).json({})), + isValidSubagentControlRequest: jest.fn((body) => { + if (body == null || typeof body !== 'object') return false; + const commonKeys = ['taskId', 'invocationId', 'action']; + let allowedKeys = [...commonKeys, 'message']; + if (body.action === 'cancel_message') allowedKeys = [...commonKeys, 'controlId']; + if (body.action === 'cancel') allowedKeys = commonKeys; + if (Object.keys(body).some((key) => !allowedKeys.includes(key))) return false; + if (typeof body.taskId !== 'string' || body.taskId.length === 0 || body.taskId.length > 256) { + return false; + } + if ( + typeof body.invocationId !== 'string' || + body.invocationId.length === 0 || + body.invocationId.length > 128 + ) { + return false; + } + if (body.action === 'cancel') return true; + if (body.action === 'cancel_message') { + return typeof body.controlId === 'string' && body.controlId.length > 0; + } + return ( + ['steer', 'queue', 'interrupt'].includes(body.action) && + typeof body.message === 'string' && + body.message.trim() !== '' && + body.message.length <= 4 * 1024 + ); + }), + exemptAgentTriggerFromIpLimiter: jest.fn(() => false), createParentSubagentIndexHandler: jest.fn( () => (_req, res) => res.status(200).json({ threads: [] }), ), @@ -116,6 +157,9 @@ module.exports = { forkUserLimiter: (req, res, next) => next(), })), configMiddleware: (req, res, next) => next(), + moderateText, + messageIpLimiter, + messageUserLimiter, validateConvoAccess: (req, res, next) => next(), }), diff --git a/api/server/routes/__tests__/convos.spec.js b/api/server/routes/__tests__/convos.spec.js index 635503a8ea2..45c7f4a4dc0 100644 --- a/api/server/routes/__tests__/convos.spec.js +++ b/api/server/routes/__tests__/convos.spec.js @@ -2,12 +2,32 @@ const express = require('express'); const request = require('supertest'); const MOCKS = '../__test-utils__/convos-route-mocks'; -const { archiveAllHandler, generationJobManager, subagentActivityHandlerInputs } = require(MOCKS); +const { + archiveAllHandler, + generationJobManager, + moderateText, + moderatedTexts, + messageIpLimiter, + messageUserLimiter, + subagentActivityHandlerInputs, +} = require(MOCKS); + +const priorLimitMessageIp = process.env.LIMIT_MESSAGE_IP; +const priorLimitMessageUser = process.env.LIMIT_MESSAGE_USER; +process.env.LIMIT_MESSAGE_IP = 'true'; +process.env.LIMIT_MESSAGE_USER = 'true'; jest.mock('@librechat/agents', () => require(MOCKS).agents()); jest.mock('@librechat/api', () => require(MOCKS).api({ - createContentFilter: jest.fn(() => (req, res, next) => next()), + createContentFilter: jest.fn((options) => (req, res, next) => { + const extracted = [...options.extract(req)]; + if (JSON.stringify(extracted).includes('BLOCK-CONTROL')) { + return res.status(400).json({ error: 'content_filter_block' }); + } + next(); + }), + extractStoredMessageContent: jest.fn((input) => [input]), inspectContent: jest.fn(() => null), extractConversationTitleContent: jest.fn(() => []), contentFilterBlockResponse: jest.fn(), @@ -77,8 +97,16 @@ describe('Convos Routes', () => { app.use('/api/convos', convosRouter); }); + afterAll(() => { + if (priorLimitMessageIp == null) delete process.env.LIMIT_MESSAGE_IP; + else process.env.LIMIT_MESSAGE_IP = priorLimitMessageIp; + if (priorLimitMessageUser == null) delete process.env.LIMIT_MESSAGE_USER; + else process.env.LIMIT_MESSAGE_USER = priorLimitMessageUser; + }); + beforeEach(() => { jest.clearAllMocks(); + moderatedTexts.length = 0; generationJobManager.getJob.mockResolvedValue(null); generationJobManager.abortJob.mockResolvedValue({ success: true }); }); @@ -96,6 +124,71 @@ describe('Convos Routes', () => { ); }); + it('filters and moderates subagent guidance as ordinary user text before control handling', async () => { + const response = await request(app).post('/api/convos/parent/subagents/child/control').send({ + taskId: 'task-1', + invocationId: 'invocation-1', + action: 'queue', + message: 'Guide the child.', + }); + + expect(response.status).toBe(200); + expect(messageIpLimiter).toHaveBeenCalledTimes(1); + expect(messageUserLimiter).toHaveBeenCalledTimes(1); + expect(moderateText).toHaveBeenCalledTimes(1); + expect(moderatedTexts).toEqual(['Guide the child.']); + + moderateText.mockClear(); + moderatedTexts.length = 0; + const blocked = await request(app).post('/api/convos/parent/subagents/child/control').send({ + taskId: 'task-1', + invocationId: 'invocation-2', + action: 'interrupt', + message: 'BLOCK-CONTROL', + }); + + expect(blocked.status).toBe(400); + expect(blocked.body).toEqual({ error: 'content_filter_block' }); + expect(moderateText).not.toHaveBeenCalled(); + + moderateText.mockClear(); + const oversized = await request(app) + .post('/api/convos/parent/subagents/child/control') + .send({ + taskId: 'task-1', + invocationId: 'invocation-3', + action: 'queue', + message: 'x'.repeat(4 * 1024 + 1), + }); + + expect(oversized.status).toBe(400); + expect(oversized.body).toEqual({ error: 'Invalid subagent control request' }); + expect(moderateText).not.toHaveBeenCalled(); + + const cancelled = await request(app).post('/api/convos/parent/subagents/child/control').send({ + taskId: 'task-1', + invocationId: 'invocation-4', + action: 'cancel', + }); + + expect(cancelled.status).toBe(200); + expect(moderateText).not.toHaveBeenCalled(); + + const crafted = await request(app) + .post('/api/convos/parent/subagents/child/control') + .send({ + taskId: 'task-1', + invocationId: 'invocation-5', + action: 'queue', + message: 'Use only this bounded guidance.', + answers: ['This unrelated field must not reach moderation.'], + }); + + expect(crafted.status).toBe(400); + expect(crafted.body).toEqual({ error: 'Invalid subagent control request' }); + expect(moderateText).not.toHaveBeenCalled(); + }); + describe('GET /:conversationId', () => { it('returns an ordinary owned conversation', async () => { getConvo.mockResolvedValue({ conversationId: 'ordinary', title: 'Ordinary' }); diff --git a/api/server/routes/convos.js b/api/server/routes/convos.js index 0750e4044a4..8e30299e2c3 100644 --- a/api/server/routes/convos.js +++ b/api/server/routes/convos.js @@ -6,6 +6,9 @@ const { deleteAgentCheckpoints, createArchiveAllHandler, createSubagentActivityStreamHandler, + createSubagentControlHandler, + isValidSubagentControlRequest, + exemptAgentTriggerFromIpLimiter, createParentSubagentIndexHandler, createSubagentThreadViewHandler, resolveImportMaxFileSize, @@ -17,6 +20,7 @@ const { isContentFilterError, contentFilterBlockResponse, extractConversationTitleContent, + extractStoredMessageContent, GenerationJobManager, isStopConfirmed, } = require('@librechat/api'); @@ -27,6 +31,9 @@ const { validateConvoAccess, createForkLimiters, configMiddleware, + messageIpLimiter, + messageUserLimiter, + moderateText, } = require('~/server/middleware'); const { forkConversation, duplicateConversation } = require('~/server/utils/import/fork'); const { storage, importFileFilter } = require('~/server/routes/files/multer'); @@ -57,6 +64,60 @@ const filterConversationTitle = createContentFilter({ getFilters: (req) => req.config?.filters, extract: (req) => extractConversationTitleContent(req.body), }); +const filterSubagentControlMessage = createContentFilter({ + getFilters: (req) => req.config?.filters, + getLegacyPii: (req) => req.config?.messageFilter?.pii, + extract: (req) => + ['steer', 'queue', 'interrupt'].includes(req.body?.action) + ? extractStoredMessageContent({ text: req.body?.message }) + : [], +}); +const unless = (isExempt, middleware) => (req, res, next) => + isExempt(req) ? next() : middleware(req, res, next); +const subagentControlLimiters = []; +if (isEnabled(process.env.LIMIT_MESSAGE_IP)) { + subagentControlLimiters.push(unless(exemptAgentTriggerFromIpLimiter, messageIpLimiter)); +} +if (isEnabled(process.env.LIMIT_MESSAGE_USER)) { + subagentControlLimiters.push(messageUserLimiter); +} + +function validateSubagentControlRequest(req, res, next) { + if (!isValidSubagentControlRequest(req.body)) { + return res.status(400).json({ error: 'Invalid subagent control request' }); + } + next(); +} + +/** Present guidance to the existing moderation middleware as ordinary user text. + * The controller continues to consume `message`; `text` is restored before it runs. */ +async function moderateSubagentControlMessage(req, res, next) { + const body = (req.body ??= {}); + if (!['steer', 'queue', 'interrupt'].includes(body.action)) { + next(); + return; + } + const hadText = Object.prototype.hasOwnProperty.call(body, 'text'); + const originalText = body.text; + if (typeof body.message === 'string') { + body.text = body.message; + } + const restore = () => { + if (hadText) { + body.text = originalText; + } else { + delete body.text; + } + }; + try { + await moderateText(req, res, (error) => { + restore(); + next(error); + }); + } finally { + restore(); + } +} const subagentActivityStreamHandler = createSubagentActivityStreamHandler( { getConvoOwnership: db.getConvoOwnership, @@ -67,6 +128,13 @@ const subagentActivityStreamHandler = createSubagentActivityStreamHandler( subscribe: subagentThreadTaskStore.subscribeActivity.bind(subagentThreadTaskStore), }, ); +const subagentControlHandler = createSubagentControlHandler({ + getConvoOwnership: db.getConvoOwnership, + getSubagentThreadForParent: db.getSubagentThreadForParent, + getMessages: db.getMessages, + getSubagentTaskControlReceipt: db.getSubagentTaskControlReceipt, + store: subagentThreadTaskStore, +}); router.use(requireJwtAuth); const isValidProjectFilter = (projectId) => @@ -117,6 +185,15 @@ router.get( '/:parentConversationId/subagents/:threadId/tasks/:taskId/activity', subagentActivityStreamHandler, ); +router.post( + '/:parentConversationId/subagents/:threadId/control', + configMiddleware, + ...subagentControlLimiters, + validateSubagentControlRequest, + filterSubagentControlMessage, + moderateSubagentControlMessage, + subagentControlHandler, +); router.get('/:parentConversationId/subagents', parentSubagentIndexHandler); router.get('/:parentConversationId/subagents/:threadId', subagentThreadViewHandler); diff --git a/api/server/services/Endpoints/agents/subagentThreadStore.js b/api/server/services/Endpoints/agents/subagentThreadStore.js index fd1f4061785..6ab0398f615 100644 --- a/api/server/services/Endpoints/agents/subagentThreadStore.js +++ b/api/server/services/Endpoints/agents/subagentThreadStore.js @@ -62,8 +62,10 @@ const subagentThreadTaskStore = createSubagentThreadTaskStore( deleteConvos: db.deleteConvos, deleteMessages: db.deleteMessages, getConvo: db.getConvo, + getSubagentTaskControlReplay: db.getSubagentTaskControlReplay, getMessages: db.getMessages, listActiveSubagentThreadLeases: db.listActiveSubagentThreadLeases, + recordSubagentTaskControlReceipt: db.recordSubagentTaskControlReceipt, releaseSubagentThreadLease: db.releaseSubagentThreadLease, reserveSubagentThread: db.reserveSubagentThread, renewSubagentThreadLease: db.renewSubagentThreadLease, @@ -92,6 +94,20 @@ registerShutdownTask( ); let taskRoutingConfigured = false; +let disconnectTaskRouting = () => {}; + +/** Store quiescence is required even without Redis. Optional transport cleanup + * is attached after configuration, but local child cancellation and the final + * durable receipt flush always participate in graceful shutdown. */ +registerShutdownTask( + 'subagent task store', + async () => { + await subagentThreadTaskStore.destroyTaskControlTransport(); + subagentThreadTaskStore.destroyActivityStream(); + disconnectTaskRouting(); + }, + { priority: 90 }, +); /** Starts the optional Redis owner directory before HTTP admission opens. */ async function configureSubagentTaskRouting() { @@ -125,17 +141,11 @@ async function configureSubagentTaskRouting() { throw error; } taskRoutingConfigured = true; - registerShutdownTask( - 'subagent task control transport', - async () => { - await subagentThreadTaskStore.destroyTaskControlTransport(); - subagentThreadTaskStore.destroyActivityStream(); - publisher.disconnect(); - activitySubscriber.disconnect(); - activityPublisher.disconnect(); - }, - { priority: 90 }, - ); + disconnectTaskRouting = () => { + publisher.disconnect(); + activitySubscriber.disconnect(); + activityPublisher.disconnect(); + }; } module.exports = subagentThreadTaskStore; diff --git a/api/server/services/Endpoints/agents/subagentThreadStore.spec.js b/api/server/services/Endpoints/agents/subagentThreadStore.spec.js index 615661dd52c..f829c2092c3 100644 --- a/api/server/services/Endpoints/agents/subagentThreadStore.spec.js +++ b/api/server/services/Endpoints/agents/subagentThreadStore.spec.js @@ -28,8 +28,10 @@ jest.mock('~/models', () => ({ deleteConvos: jest.fn(), deleteMessages: jest.fn(), getConvo: jest.fn(), + getSubagentTaskControlReplay: jest.fn(), getMessages: jest.fn(), listActiveSubagentThreadLeases: jest.fn(), + recordSubagentTaskControlReceipt: jest.fn(), releaseSubagentThreadLease: jest.fn(), reserveSubagentThread: jest.fn(), renewSubagentThreadLease: jest.fn(), @@ -55,11 +57,23 @@ const { const subagentThreadTaskStore = require('./subagentThreadStore'); const { configureSubagentTaskRouting } = subagentThreadTaskStore; const taskStoreOptions = createSubagentThreadTaskStore.mock.calls[0][1]; +const taskStoreMethods = createSubagentThreadTaskStore.mock.calls[0][0]; +const db = require('~/models'); const activityPrepareRegistration = registerShutdownTask.mock.calls.find( ([name]) => name === 'subagent activity streams prepare', ); +const taskStoreShutdownRegistration = registerShutdownTask.mock.calls.find( + ([name]) => name === 'subagent task store', +); describe('subagent thread Redis lifecycle', () => { + it('wires durable control receipt persistence into the host store', () => { + expect(taskStoreMethods.recordSubagentTaskControlReceipt).toBe( + db.recordSubagentTaskControlReceipt, + ); + expect(taskStoreMethods.getSubagentTaskControlReplay).toBe(db.getSubagentTaskControlReplay); + }); + it('reads completion wakeup rollout state at task preparation time', async () => { isEnabled.mockReturnValueOnce(false); @@ -74,6 +88,14 @@ describe('subagent thread Redis lifecycle', () => { expect(subagentThreadTaskStore.completionWakeupsEnabled).toBe(false); }); + it('registers local task-store quiescence independently of optional Redis setup', () => { + expect(taskStoreShutdownRegistration).toEqual([ + 'subagent task store', + expect.any(Function), + { priority: 90 }, + ]); + }); + it('closes activity SSE before drain and disconnects its subscriber after drain', async () => { const taskSubscriber = { disconnect: jest.fn() }; const activitySubscriber = { disconnect: jest.fn() }; @@ -93,18 +115,16 @@ describe('subagent thread Redis lifecycle', () => { expect.any(Function), { phase: 'pre-drain', priority: 100 }, ]); - expect(registerShutdownTask).toHaveBeenCalledWith( - 'subagent task control transport', + expect(taskStoreShutdownRegistration).toEqual([ + 'subagent task store', expect.any(Function), { priority: 90 }, - ); + ]); const prepare = activityPrepareRegistration[1]; prepare(); expect(mockTaskStore.prepareActivityForShutdown).toHaveBeenCalledTimes(1); - const shutdown = registerShutdownTask.mock.calls.find( - ([name]) => name === 'subagent task control transport', - )[1]; + const shutdown = taskStoreShutdownRegistration[1]; await shutdown(); expect(mockTaskStore.destroyTaskControlTransport).toHaveBeenCalledTimes(1); diff --git a/api/server/services/twoFactorService.js b/api/server/services/twoFactorService.js index 313c5571339..081fa611bdf 100644 --- a/api/server/services/twoFactorService.js +++ b/api/server/services/twoFactorService.js @@ -1,4 +1,4 @@ -const { webcrypto } = require('node:crypto'); +const { webcrypto, timingSafeEqual } = require('node:crypto'); const { hashBackupCode, decryptV3, decryptV2 } = require('@librechat/data-schemas'); const { updateUser } = require('~/models'); @@ -102,6 +102,31 @@ const generateTOTP = async (secret, forTime = Date.now()) => { return code; }; +/** + * Constant-time comparison of a candidate 2FA code against the expected value. + * A plain `===` comparison short-circuits at the first differing character, so + * an attacker submitting codes to the 2FA verification endpoint could, in + * principle, learn how many leading digits are correct from the response time. + * Codes are of a fixed, public length, so returning early on a length mismatch + * (or a non-string input) leaks nothing secret while keeping the match path + * timing-independent. Mirrors the `crypto.timingSafeEqual(Buffer.from(...))` + * pattern already used for CSRF token checks in `packages/api`. + * @param {string} expected + * @param {string} candidate + * @returns {boolean} + */ +const constantTimeEqual = (expected, candidate) => { + if (typeof expected !== 'string' || typeof candidate !== 'string') { + return false; + } + const expectedBuffer = Buffer.from(expected, 'utf8'); + const candidateBuffer = Buffer.from(candidate, 'utf8'); + if (expectedBuffer.length !== candidateBuffer.length) { + return false; + } + return timingSafeEqual(expectedBuffer, candidateBuffer); +}; + /** * Verifies a TOTP token by checking a ±1 time step window. * @param {string} secret @@ -113,7 +138,7 @@ const verifyTOTP = async (secret, token) => { const currentTime = Date.now(); for (let offset = -1; offset <= 1; offset++) { const expected = await generateTOTP(secret, currentTime + offset * timeStepMS); - if (expected === token) { + if (constantTimeEqual(expected, token)) { return true; } } diff --git a/api/server/services/twoFactorService.spec.js b/api/server/services/twoFactorService.spec.js new file mode 100644 index 00000000000..81cd03d152b --- /dev/null +++ b/api/server/services/twoFactorService.spec.js @@ -0,0 +1,42 @@ +const crypto = require('node:crypto'); + +jest.mock('node:crypto', () => { + const actual = jest.requireActual('node:crypto'); + return { + ...actual, + timingSafeEqual: jest.fn((a, b) => actual.timingSafeEqual(a, b)), + }; +}); + +jest.mock('@librechat/data-schemas', () => ({ + hashBackupCode: jest.fn(), + decryptV3: jest.fn(), + decryptV2: jest.fn(), +})); + +jest.mock('~/models', () => ({ updateUser: jest.fn() })); + +const { generateTOTP, verifyTOTP, generateTOTPSecret } = require('./twoFactorService'); + +describe('verifyTOTP', () => { + it('accepts a valid current TOTP code', async () => { + const secret = generateTOTPSecret(); + const code = await generateTOTP(secret); + await expect(verifyTOTP(secret, code)).resolves.toBe(true); + }); + + it('rejects an invalid code of the same length', async () => { + const secret = generateTOTPSecret(); + const code = await generateTOTP(secret); + const wrong = code === '000000' ? '111111' : '000000'; + await expect(verifyTOTP(secret, wrong)).resolves.toBe(false); + }); + + it('compares codes in constant time via crypto.timingSafeEqual', async () => { + const secret = generateTOTPSecret(); + const code = await generateTOTP(secret); + crypto.timingSafeEqual.mockClear(); + await verifyTOTP(secret, code); + expect(crypto.timingSafeEqual).toHaveBeenCalled(); + }); +}); diff --git a/client/src/common/types.ts b/client/src/common/types.ts index 28d2872217c..768386c50be 100644 --- a/client/src/common/types.ts +++ b/client/src/common/types.ts @@ -103,27 +103,6 @@ export enum IconContext { message = 'message', } -export type IconMapProps = { - className?: string; - iconURL?: string; - context?: 'landing' | 'menu-item' | 'nav' | 'message'; - endpoint?: string | null; - endpointType?: string; - assistantName?: string; - agentName?: string; - avatar?: string; - size?: number; -}; - -export type IconComponent = React.ComponentType; -export type AgentIconComponent = React.ComponentType; -export type IconComponentTypes = IconComponent | AgentIconComponent; -export type IconsRecord = { - [key in t.EModelEndpoint | 'unknown' | string]: IconComponentTypes | null | undefined; -}; - -export type AgentIconMapProps = IconMapProps & { agentName?: string }; - export type NavLink = { title: TranslationKeys; label?: string; @@ -537,6 +516,7 @@ export type IconProps = Pick & iconClassName?: string; endpoint?: t.EModelEndpoint | string | null; endpointType?: t.EModelEndpoint | null; + endpointsConfig?: t.TEndpointsConfig | null; assistantName?: string; agentName?: string; error?: boolean; diff --git a/client/src/components/Agents/AgentGrid.tsx b/client/src/components/Agents/AgentGrid.tsx index cb11012f15d..58517c60da6 100644 --- a/client/src/components/Agents/AgentGrid.tsx +++ b/client/src/components/Agents/AgentGrid.tsx @@ -225,7 +225,7 @@ const AgentGrid: React.FC = ({ ); - if (isLoading || (isFetching && !isFetchingNextPage)) { + if ((isLoading || (isFetching && !isFetchingNextPage)) && !hasData) { return loadingSpinner; } return mainContent; diff --git a/client/src/components/Agents/SmartLoader.tsx b/client/src/components/Agents/SmartLoader.tsx index 58e741b9367..b857dd1978c 100644 --- a/client/src/components/Agents/SmartLoader.tsx +++ b/client/src/components/Agents/SmartLoader.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect } from 'react'; -import { AgentListResponse } from 'librechat-data-provider'; +import type { AgentListResponse } from 'librechat-data-provider'; interface SmartLoaderProps { /** Whether the content is currently loading */ @@ -73,6 +73,12 @@ export const useHasData = (data: AgentListResponse | undefined): boolean => { // Type guard for object data if (typeof data === 'object' && data !== null) { + // Check for agent list data (AgentListResponse shape, e.g. marketplace pages) + const agents = data.data; + if (Array.isArray(agents)) { + return agents.length > 0; + } + // Check for agent list data if ('agents' in data) { const agents = (data as any).agents; diff --git a/client/src/components/Agents/VirtualizedAgentGrid.tsx b/client/src/components/Agents/VirtualizedAgentGrid.tsx index d5f026cb132..165c6e9d0e4 100644 --- a/client/src/components/Agents/VirtualizedAgentGrid.tsx +++ b/client/src/components/Agents/VirtualizedAgentGrid.tsx @@ -225,7 +225,7 @@ const VirtualizedAgentGrid: React.FC = ({ } // Handle loading state - if (isLoading || (isFetching && !isFetchingNextPage)) { + if ((isLoading || (isFetching && !isFetchingNextPage)) && !hasData) { return loadingSpinner; } diff --git a/client/src/components/Agents/tests/AgentGrid.integration.spec.tsx b/client/src/components/Agents/tests/AgentGrid.integration.spec.tsx index a4d6282aa74..03043535a80 100644 --- a/client/src/components/Agents/tests/AgentGrid.integration.spec.tsx +++ b/client/src/components/Agents/tests/AgentGrid.integration.spec.tsx @@ -18,11 +18,6 @@ jest.mock('~/hooks/Agents', () => ({ })), })); -// Mock SmartLoader -jest.mock('../SmartLoader', () => ({ - useHasData: jest.fn(() => true), -})); - // Mock useLocalize hook jest.mock('~/hooks/useLocalize', () => () => (key: string, options?: any) => { const mockTranslations: Record = { @@ -362,6 +357,23 @@ describe('AgentGrid Integration with useGetMarketplaceAgentsQuery', () => { expect(spinner).toBeInTheDocument(); }); + it('should retain cached agents while refetching', () => { + mockUseMarketplaceAgentsInfiniteQuery.mockReturnValue({ + ...defaultMockQueryResult, + isFetching: true, + }); + + const Wrapper = createWrapper(); + render( + + + , + ); + + expect(screen.getByTestId('agent-card-1')).toBeInTheDocument(); + expect(screen.getByTestId('agent-card-2')).toBeInTheDocument(); + }); + it('should show empty state when no agents are available', () => { mockUseMarketplaceAgentsInfiniteQuery.mockReturnValue({ ...defaultMockQueryResult, diff --git a/client/src/components/Agents/tests/SmartLoader.spec.tsx b/client/src/components/Agents/tests/SmartLoader.spec.tsx index 766d5a27072..3d2609c94b6 100644 --- a/client/src/components/Agents/tests/SmartLoader.spec.tsx +++ b/client/src/components/Agents/tests/SmartLoader.spec.tsx @@ -313,6 +313,35 @@ describe('useHasData', () => { expect(screen.getByTestId('result')).toHaveTextContent('no-data'); }); + it('detects empty data array (AgentListResponse) as no data', () => { + render( + , + ); + expect(screen.getByTestId('result')).toHaveTextContent('no-data'); + }); + + it('detects non-empty data array (AgentListResponse) as has data', () => { + render( + , + ); + expect(screen.getByTestId('result')).toHaveTextContent('has-data'); + }); + + it('detects invalid data property as no data', () => { + render(); + expect(screen.getByTestId('result')).toHaveTextContent('no-data'); + }); + it('detects empty agents array as no data', () => { render(); expect(screen.getByTestId('result')).toHaveTextContent('no-data'); diff --git a/client/src/components/Agents/tests/VirtualizedAgentGrid.test.tsx b/client/src/components/Agents/tests/VirtualizedAgentGrid.test.tsx index b756fb9add3..04425af54dc 100644 --- a/client/src/components/Agents/tests/VirtualizedAgentGrid.test.tsx +++ b/client/src/components/Agents/tests/VirtualizedAgentGrid.test.tsx @@ -160,10 +160,6 @@ jest.mock('~/hooks', () => ({ }, })); -jest.mock('../SmartLoader', () => ({ - useHasData: () => true, -})); - jest.mock('../AgentCard', () => { return function MockAgentCard({ agent, @@ -266,6 +262,21 @@ describe('VirtualizedAgentGrid', () => { expect(spinner).toHaveClass('h-8 w-8 text-text-primary'); }); + it('retains cached agents while refetching', () => { + const useMarketplaceAgentsInfiniteQuery = ( + jest.requireMock('~/data-provider/Agents') as MarketplaceAgentsMock + ).useMarketplaceAgentsInfiniteQuery; + useMarketplaceAgentsInfiniteQuery.mockImplementation(() => + createMockInfiniteQuery({ isFetching: true }), + ); + + renderComponent(); + + expect(screen.getByTestId('virtual-list')).toBeInTheDocument(); + expect(screen.getByTestId('agent-card-1')).toBeInTheDocument(); + expect(screen.getByTestId('agent-card-2')).toBeInTheDocument(); + }); + it('has proper accessibility attributes', () => { renderComponent({ category: 'productivity' }); diff --git a/client/src/components/Chat/Menus/Endpoints/components/GroupIcon.tsx b/client/src/components/Chat/Menus/Endpoints/components/GroupIcon.tsx index 67cc3d6bf20..41d393c3870 100644 --- a/client/src/components/Chat/Menus/Endpoints/components/GroupIcon.tsx +++ b/client/src/components/Chat/Menus/Endpoints/components/GroupIcon.tsx @@ -1,37 +1,36 @@ import React, { memo, useState } from 'react'; import { AlertCircle } from 'lucide-react'; -import type { IconMapProps } from '~/common'; -import { getKnownEndpointAsset, hasKnownEndpointIcon } from '~/hooks/Endpoint/UnknownIcon'; -import { icons } from '~/hooks/Endpoint/Icons'; +import { ProviderIcon } from '@librechat/client'; +import { resolveProviderId } from 'librechat-data-provider'; +import { EntityEndpointMark, isEntityEndpoint } from '~/components/Endpoints/EntityEndpointMark'; +import { isImageURL } from '~/utils/icons'; interface GroupIconProps { iconURL: string; groupName: string; } -type IconType = (props: IconMapProps) => React.JSX.Element; - const GroupIcon: React.FC = ({ iconURL, groupName }) => { const [imageError, setImageError] = useState(false); + const provider = resolveProviderId(iconURL); const handleImageError = () => { setImageError(true); }; - // Check if the iconURL is a built-in icon key - if (iconURL in icons) { - const Icon: IconType = (icons[iconURL] ?? icons.unknown) as IconType; - return ; + if (isEntityEndpoint(iconURL)) { + return ( +
+ +
+ ); } - if (imageError) { - const DefaultIcon: IconType = icons.unknown as IconType; + if (provider || !isImageURL(iconURL) || imageError) { return (
-
- -
- {imageError && iconURL && ( + + {imageError && (
= ({ iconURL, groupName }) => { ); } - const resolvedIconURL = getKnownEndpointAsset(iconURL); - - if (!resolvedIconURL && hasKnownEndpointIcon(iconURL)) { - const Icon: IconType = icons.unknown as IconType; - return ( - - ); - } - return (
{groupName} React.JSX.Element; - const SpecIcon: React.FC = ({ currentSpec, endpointsConfig, agentAvatarURL }) => { const iconURL = getModelSpecIconURL(currentSpec, agentAvatarURL); const endpoint = currentSpec.preset?.endpoint; - const endpointIconURL = getEndpointField(endpointsConfig, endpoint, 'iconURL'); - const iconKey = getIconKey({ endpoint, endpointsConfig, endpointIconURL }); - const shouldRenderURLIcon = isImageURL(iconURL); - let Icon: IconType; + const { provider, imageURL } = useProviderIcon({ endpoint, endpointsConfig, iconURL }); + const { provider: fallbackProvider } = useProviderIcon({ endpoint, endpointsConfig }); - if (!shouldRenderURLIcon) { - Icon = (icons[iconURL] ?? icons[iconKey] ?? icons.unknown) as IconType; - } else { + if (imageURL) { return ( ); } + if (isEntityEndpoint(iconURL || endpoint)) { + return ; + } + return ( - ); }; diff --git a/client/src/components/Chat/Menus/Endpoints/components/__tests__/GroupIcon.test.tsx b/client/src/components/Chat/Menus/Endpoints/components/__tests__/GroupIcon.test.tsx index ee8ba0f5f06..83f3b8163b2 100644 --- a/client/src/components/Chat/Menus/Endpoints/components/__tests__/GroupIcon.test.tsx +++ b/client/src/components/Chat/Menus/Endpoints/components/__tests__/GroupIcon.test.tsx @@ -1,56 +1,42 @@ import { render, screen } from '@testing-library/react'; +import { EModelEndpoint } from 'librechat-data-provider'; import GroupIcon from '../GroupIcon'; -jest.mock('~/hooks/Endpoint/Icons', () => { - const React = jest.requireActual('react'); - const createIcon = - (iconKey: string) => - ({ className, endpoint }: { className?: string; endpoint?: string | null }) => - React.createElement('span', { - className, - 'data-testid': 'endpoint-icon', - 'data-icon-key': iconKey, - 'data-endpoint': endpoint ?? '', - }); - - return { - icons: { - openAI: createIcon('openAI'), - unknown: createIcon('unknown'), - }, - }; -}); - describe('GroupIcon', () => { it('renders built-in endpoint icon keys', () => { render(); - expect(screen.getByTestId('endpoint-icon')).toHaveAttribute('data-icon-key', 'openAI'); + expect(screen.getByRole('img', { name: 'OpenAI' })).toBeInTheDocument(); + }); + + it('keeps the agents mark for an agents group icon', () => { + const { container } = render( + , + ); + + expect(screen.queryByRole('img', { name: 'Custom' })).not.toBeInTheDocument(); + expect(container.querySelector('svg')).toBeInTheDocument(); + expect(screen.getByTitle('My Agents')).toBeInTheDocument(); }); it('resolves known endpoint asset aliases case-insensitively', () => { render(); - expect(screen.getByRole('img', { name: 'OpenRouter' })).toHaveAttribute( - 'src', - 'assets/openrouter.png', - ); + const src = screen.getByRole('img', { name: 'OpenRouter' }).getAttribute('src'); + expect(src).toBeTruthy(); + expect(src).not.toBe(''); }); it('resolves known endpoint asset aliases to shipped file paths', () => { render(); - expect(screen.getByRole('img', { name: 'Helicone' })).toHaveAttribute( - 'src', - 'assets/helicone.svg', - ); + expect(screen.getByRole('img', { name: 'Helicone' })).toHaveAttribute('alt', 'Helicone'); }); it('renders known endpoint aliases backed by components', () => { render(); - expect(screen.getByTestId('endpoint-icon')).toHaveAttribute('data-icon-key', 'unknown'); - expect(screen.getByTestId('endpoint-icon')).toHaveAttribute('data-endpoint', 'Moonshot'); + expect(screen.getByRole('img', { name: 'Moonshot' })).toBeInTheDocument(); }); it('renders configured image URLs directly', () => { diff --git a/client/src/components/Chat/Menus/Endpoints/components/__tests__/SpecIcon.test.tsx b/client/src/components/Chat/Menus/Endpoints/components/__tests__/SpecIcon.test.tsx index 45b0f3f001e..0de9af574dd 100644 --- a/client/src/components/Chat/Menus/Endpoints/components/__tests__/SpecIcon.test.tsx +++ b/client/src/components/Chat/Menus/Endpoints/components/__tests__/SpecIcon.test.tsx @@ -1,37 +1,16 @@ import { render, screen } from '@testing-library/react'; -import { EModelEndpoint } from 'librechat-data-provider'; +import { EModelEndpoint, ProviderId } from 'librechat-data-provider'; import type { TModelSpec, TEndpointsConfig } from 'librechat-data-provider'; import SpecIcon from '../SpecIcon'; -jest.mock('~/hooks/Endpoint/Icons', () => { - const React = jest.requireActual('react'); - const createIcon = - (iconKey: string) => - ({ endpoint, iconURL }: { endpoint?: string | null; iconURL?: string }) => - React.createElement('span', { - 'data-testid': 'endpoint-icon', - 'data-icon-key': iconKey, - 'data-endpoint': endpoint ?? '', - 'data-icon-url': iconURL ?? '', - }); - - return { - icons: { - google: createIcon('google'), - openAI: createIcon('openAI'), - unknown: createIcon('unknown'), - }, - }; -}); - jest.mock('~/components/Endpoints/URLIcon', () => { const React = jest.requireActual('react'); return { - URLIcon: ({ iconURL, endpoint }: { iconURL: string; endpoint?: string }) => + URLIcon: ({ iconURL, provider }: { iconURL: string; provider?: string | null }) => React.createElement('span', { 'data-testid': 'url-icon', 'data-icon-url': iconURL, - 'data-endpoint': endpoint ?? '', + 'data-provider': provider ?? '', }), }; }); @@ -48,11 +27,7 @@ describe('SpecIcon', () => { render(); - expect(screen.getByTestId('endpoint-icon')).toHaveAttribute( - 'data-icon-key', - EModelEndpoint.google, - ); - expect(screen.getByTestId('endpoint-icon')).toHaveAttribute('data-endpoint', ''); + expect(screen.getByRole('img', { name: 'Google' })).toBeInTheDocument(); }); it('renders same-origin absolute spec icon URLs as images', () => { @@ -71,13 +46,10 @@ describe('SpecIcon', () => { 'data-icon-url', '/assets/clickhouse-logo.svg', ); - expect(screen.getByTestId('url-icon')).toHaveAttribute( - 'data-endpoint', - EModelEndpoint.anthropic, - ); + expect(screen.getByTestId('url-icon')).toHaveAttribute('data-provider', ProviderId.anthropic); }); - it('falls back to the unknown icon when runtime spec data has no icon or preset', () => { + it('falls back to the generic icon when runtime spec data has no icon or preset', () => { const currentSpec = { name: 'gemini-test', label: 'Gemini Test', @@ -85,7 +57,7 @@ describe('SpecIcon', () => { render(); - expect(screen.getByTestId('endpoint-icon')).toHaveAttribute('data-icon-key', 'unknown'); + expect(screen.getByRole('img', { name: 'Custom' })).toBeInTheDocument(); }); it("renders the agent's avatar when the spec defines no icon of its own", () => { diff --git a/client/src/components/Chat/Menus/Presets/PresetItems.tsx b/client/src/components/Chat/Menus/Presets/PresetItems.tsx index b7f426e1a0e..31362c9d911 100644 --- a/client/src/components/Chat/Menus/Presets/PresetItems.tsx +++ b/client/src/components/Chat/Menus/Presets/PresetItems.tsx @@ -3,7 +3,6 @@ import { useRecoilValue } from 'recoil'; import * as Ariakit from '@ariakit/react'; import { Close } from '@radix-ui/react-popover'; import { Flipper, Flipped } from 'react-flip-toolkit'; -import { getEndpointField } from 'librechat-data-provider'; import { BookCopy, FileUp, FileX2, Ellipsis } from 'lucide-react'; import { Button, @@ -25,9 +24,10 @@ import { import type { MenuItemProps } from '@librechat/client'; import type { TPreset } from 'librechat-data-provider'; import type { ChangeEvent, FC } from 'react'; +import { ResolvedProviderIcon } from '~/components/Endpoints/ResolvedProviderIcon'; +import { resolveProviderIcon } from '~/hooks/Endpoint'; import { useGetEndpointsQuery } from '~/data-provider'; -import { getPresetTitle, getIconKey } from '~/utils'; -import { icons } from '~/hooks/Endpoint/Icons'; +import { getPresetTitle } from '~/utils'; import { MenuSeparator } from '../UI'; import { useLocalize } from '~/hooks'; import { cn } from '~/utils'; @@ -226,8 +226,10 @@ const PresetItems: FC<{ return null; } - const iconKey = getIconKey({ endpoint: preset.endpoint, endpointsConfig }); - const Icon = icons[iconKey]; + const { provider, imageURL } = resolveProviderIcon({ + endpoint: preset.endpoint, + endpointsConfig, + }); const presetTitle = getPresetTitle(preset); return ( @@ -243,14 +245,12 @@ const PresetItems: FC<{ aria-label={presetTitle} data-testid={`preset-item-${presetId}`} > - {Icon != null && ( - - )} + {presetTitle}
diff --git a/client/src/components/Chat/Menus/Presets/__tests__/PresetItems.spec.tsx b/client/src/components/Chat/Menus/Presets/__tests__/PresetItems.spec.tsx index 4f08c342c5e..0d548d2c5a6 100644 --- a/client/src/components/Chat/Menus/Presets/__tests__/PresetItems.spec.tsx +++ b/client/src/components/Chat/Menus/Presets/__tests__/PresetItems.spec.tsx @@ -11,11 +11,15 @@ jest.mock('~/hooks', () => ({ })); jest.mock('~/data-provider', () => ({ - useGetEndpointsQuery: () => ({ data: {} }), -})); - -jest.mock('~/hooks/Endpoint/Icons', () => ({ - icons: {}, + useGetEndpointsQuery: () => ({ + data: { + Branded: { + type: 'custom', + iconURL: 'https://cdn.example.com/x.png', + order: 0, + }, + }, + }), })); const preset = { @@ -116,3 +120,25 @@ describe('PresetItems clear-all dialog', () => { }); }); }); + +describe('PresetItems icons', () => { + it('renders a configured endpoint image instead of the generic mark', () => { + render( + + + + + , + ); + + expect(screen.getByRole('img')).toHaveAttribute('src', 'https://cdn.example.com/x.png'); + }); +}); diff --git a/client/src/components/Chat/Messages/Content/ContentParts.tsx b/client/src/components/Chat/Messages/Content/ContentParts.tsx index 6c4a45b5332..2324486d00e 100644 --- a/client/src/components/Chat/Messages/Content/ContentParts.tsx +++ b/client/src/components/Chat/Messages/Content/ContentParts.tsx @@ -10,7 +10,7 @@ import type { ReactNode, ReactElement } from 'react'; import type { ToolCallGroupExpansionState } from './ToolCallGroup'; import { mapAttachments, filterAttachmentsForPart, groupSequentialToolCalls } from '~/utils'; import WorkspaceChanges, { partitionWorkspaceChanges } from './Parts/WorkspaceChanges'; -import { groupActivityPhases, lastVisibleContentIdx } from '~/utils/activityLabels'; +import { groupActivityPhases, lastCursorContentIdx } from '~/utils/activityLabels'; import { ParallelContentRenderer, type PartWithIndex } from './ParallelContent'; import MemoryArtifacts, { hasMemoryArtifacts } from './MemoryArtifacts'; import { MessageContext, SearchContext } from '~/Providers'; @@ -497,7 +497,7 @@ const ContentPartsBody = memo(function ContentPartsBody({ } if (phaseSegments != null) { - const relativeGlobalLastContentIdx = lastVisibleContentIdx(content ?? []); + const relativeGlobalLastContentIdx = lastCursorContentIdx(content ?? []); const globalLastContentIdx = relativeGlobalLastContentIdx < 0 ? -1 : absoluteIndexAt(relativeGlobalLastContentIdx); const renderSegment = ( @@ -586,10 +586,9 @@ const ContentPartsBody = memo(function ContentPartsBody({ * empty TEXT after real parts keeps its flush in-flow cursor. */ const solitaryEmptyText = safeContent.length === 1 && isEmptyTextPart(safeContent[0]); const showEmptyCursor = (safeContent.length === 0 || solitaryEmptyText) && effectiveIsSubmitting; - /** Skips trailing BLANK label reservations — they render nothing, and - * counting one as last would strip the streaming cursor from the last - * VISIBLE part until the next delta. */ - const relativeLastContentIdx = lastVisibleContentIdx(safeContent); + /** Skips trailing blank label reservations and empty provider placeholders, + * keeping the cursor attached to the last visible output. */ + const relativeLastContentIdx = lastCursorContentIdx(safeContent); const lastContentIdx = relativeLastContentIdx < 0 ? -1 : absoluteIndexAt(relativeLastContentIdx); // Parallel content: use dedicated renderer with columns (TMessageContentParts includes ContentMetadata) diff --git a/client/src/components/Chat/Messages/Content/ParallelContent.tsx b/client/src/components/Chat/Messages/Content/ParallelContent.tsx index 6b3dbbbbf57..6348e258704 100644 --- a/client/src/components/Chat/Messages/Content/ParallelContent.tsx +++ b/client/src/components/Chat/Messages/Content/ParallelContent.tsx @@ -4,7 +4,7 @@ import type { TMessageContentParts, SearchResultData, TAttachment } from 'librec import { getActivityLabelPart, getActivityLabelText, - lastVisibleContentIdx, + lastCursorContentIdx, } from '~/utils/activityLabels'; import MemoryArtifacts from './MemoryArtifacts'; import Sources from '~/components/Web/Sources'; @@ -179,6 +179,7 @@ export const ParallelColumns = memo(function ParallelColumns({ part?.type !== ContentTypes.ACTIVITY_LABEL || getActivityLabelText(getActivityLabelPart(part)).length > 0, ); + const lastColumnCursorIdx = lastParallelColumnCursorIdx(columnParts); // Show loading cursor if column has no content parts yet (empty array from placeholder) const showLoadingCursor = isSubmitting && columnParts.length === 0; @@ -200,7 +201,7 @@ export const ParallelColumns = memo(function ParallelColumns({ ) : ( columnParts.map(({ part, idx }) => { - const isLastInColumn = idx === columnParts[columnParts.length - 1]?.idx; + const isLastInColumn = idx === lastColumnCursorIdx; const isLastContent = idx === lastContentIdx; return renderPart(part, idx, isLastInColumn && isLastContent); }) @@ -212,6 +213,13 @@ export const ParallelColumns = memo(function ParallelColumns({ ); }); +export function lastParallelColumnCursorIdx( + parts: ReadonlyArray<{ part: TMessageContentParts; idx: number }>, +): number { + const relativeIdx = lastCursorContentIdx(parts.map(({ part }) => part)); + return relativeIdx < 0 ? -1 : (parts[relativeIdx]?.idx ?? -1); +} + type ParallelContentRendererProps = { content?: Array; messageId: string; @@ -261,7 +269,7 @@ export const ParallelContentRenderer = memo(function ParallelContentRenderer({ /** Same walk-back as `ContentParts`: a trailing BLANK label reservation is * filtered out of every lane, so counting it as last would leave NO * rendered part with the last-part cursor until the label fills. */ - const relativeLastContentIdx = lastVisibleContentIdx(content); + const relativeLastContentIdx = lastCursorContentIdx(content); const lastContentIdx = relativeLastContentIdx < 0 ? -1 diff --git a/client/src/components/Chat/Messages/Content/__tests__/ContentParts.test.tsx b/client/src/components/Chat/Messages/Content/__tests__/ContentParts.test.tsx index f4e1fbc3bc1..33191b8ff99 100644 --- a/client/src/components/Chat/Messages/Content/__tests__/ContentParts.test.tsx +++ b/client/src/components/Chat/Messages/Content/__tests__/ContentParts.test.tsx @@ -115,8 +115,20 @@ jest.mock('../Container', () => ({ jest.mock('../Part', () => ({ __esModule: true, - default: ({ part, idx }: { part: TMessageContentParts; idx: number }) => ( -
+ default: ({ + part, + idx, + showCursor, + }: { + part: TMessageContentParts; + idx: number; + showCursor?: boolean; + }) => ( +
), })); @@ -453,6 +465,25 @@ describe('ContentParts — post-steer author re-attribution', () => { }); describe('ContentParts — activity phase state', () => { + it('keeps a streaming cursor on visible text when a provider appends an empty placeholder', () => { + render( + , + ); + + const textParts = screen.getAllByTestId(`real-part-${ContentTypes.TEXT}`); + expect(textParts[0]).toHaveAttribute('data-show-cursor', 'true'); + expect(textParts[1]).toHaveAttribute('data-show-cursor', 'false'); + }); + it('renders a completion-appended parent before the final root text', () => { const tool = { type: ContentTypes.TOOL_CALL, diff --git a/client/src/components/Chat/Messages/Content/__tests__/ParallelContent.test.ts b/client/src/components/Chat/Messages/Content/__tests__/ParallelContent.test.ts index 1633d7a55d1..ec1b821bfb7 100644 --- a/client/src/components/Chat/Messages/Content/__tests__/ParallelContent.test.ts +++ b/client/src/components/Chat/Messages/Content/__tests__/ParallelContent.test.ts @@ -1,6 +1,6 @@ import { ContentTypes } from 'librechat-data-provider'; import type { TMessageContentParts } from 'librechat-data-provider'; -import { groupParallelContent } from '../ParallelContent'; +import { groupParallelContent, lastParallelColumnCursorIdx } from '../ParallelContent'; describe('groupParallelContent', () => { test('reports absolute indices for a dense phase segment', () => { @@ -41,3 +41,27 @@ describe('groupParallelContent', () => { ]); }); }); + +describe('lastParallelColumnCursorIdx', () => { + test('keeps the lane cursor on visible output before an empty placeholder', () => { + const visible = { + type: ContentTypes.TEXT, + text: 'Visible answer', + groupId: 1, + agentId: 'agent-1', + } as unknown as TMessageContentParts; + const empty = { + type: ContentTypes.TEXT, + text: '', + groupId: 1, + agentId: 'agent-1', + } as unknown as TMessageContentParts; + + expect( + lastParallelColumnCursorIdx([ + { part: visible, idx: 7 }, + { part: empty, idx: 8 }, + ]), + ).toBe(7); + }); +}); diff --git a/client/src/components/Chat/Messages/Elapsed.tsx b/client/src/components/Chat/Messages/Elapsed.tsx new file mode 100644 index 00000000000..913f75b81b0 --- /dev/null +++ b/client/src/components/Chat/Messages/Elapsed.tsx @@ -0,0 +1,72 @@ +import { memo, useEffect, useState } from 'react'; +import { useRecoilValue } from 'recoil'; +import { useTranslation } from 'react-i18next'; +import { getElapsedDurationLabels } from '~/utils'; +import { useLocalize } from '~/hooks'; +import store from '~/store'; + +const elapsedSeconds = (start: number): number => + Math.max(0, Math.floor((Date.now() - start) / 1000)); + +type ElapsedVisibility = { + isSubmitting: boolean; + isLatestMessage: boolean; + isCreatedByUser?: boolean; + siblingIdx?: number; + siblingCount?: number; +}; + +/** + * Whether the elapsed indicator belongs under a row: the latest assistant row + * while its generation streams — but only at the newest sibling position. + * `latestMessageId` follows the SELECTED branch, so during a regeneration a + * settled older sibling the reader paged to mid-stream would otherwise satisfy + * the same latest+submitting gate the withheld hover actions use, and a + * counting timer under settled content misleads in a way hidden buttons don't. + */ +export const shouldShowElapsed = ({ + isSubmitting, + isLatestMessage, + isCreatedByUser, + siblingIdx, + siblingCount, +}: ElapsedVisibility): boolean => + isSubmitting && + isLatestMessage && + isCreatedByUser !== true && + (siblingIdx ?? 0) === (siblingCount ?? 1) - 1; + +/** + * Elapsed generation time under the actively streaming response, in the footer + * slot the hover actions occupy once the answer lands. The once-per-second tick + * is component-local state, so parents that re-render per streaming token never + * re-render on its account. The compact reading is hidden from assistive + * technology in favor of a spoken equivalent; neither is an `aria-live` region, + * so the tick never announces. + */ +const Elapsed = memo(function Elapsed({ index }: { index: number }) { + const localize = useLocalize(); + const { i18n } = useTranslation(); + const submissionStart = useRecoilValue(store.submissionStartFamily(index)); + const [mountTime] = useState(() => Date.now()); + const start = submissionStart ?? mountTime; + const [seconds, setSeconds] = useState(() => elapsedSeconds(start)); + + useEffect(() => { + setSeconds(elapsedSeconds(start)); + const intervalId = setInterval(() => setSeconds(elapsedSeconds(start)), 1000); + return () => clearInterval(intervalId); + }, [start]); + + const labels = getElapsedDurationLabels(seconds * 1000, i18n.language); + return ( + + + {localize(labels.announcedKey, labels.announcedValues)} + + ); +}); + +export default Elapsed; diff --git a/client/src/components/Chat/Messages/MessageIcon.tsx b/client/src/components/Chat/Messages/MessageIcon.tsx index 7eabab3ebb0..a5a395473f6 100644 --- a/client/src/components/Chat/Messages/MessageIcon.tsx +++ b/client/src/components/Chat/Messages/MessageIcon.tsx @@ -73,7 +73,6 @@ const MessageIcon = memo(({ iconData, assistant, agent }: MessageIconProps) => { context="message" assistantAvatar={assistantAvatar} agentAvatar={agentAvatar} - endpointIconURL={endpointIconURL} assistantName={assistantName} agentName={agentName} /> @@ -85,6 +84,7 @@ const MessageIcon = memo(({ iconData, assistant, agent }: MessageIconProps) => { isCreatedByUser={iconData?.isCreatedByUser ?? false} endpoint={endpoint} iconURL={avatarURL || endpointIconURL} + endpointsConfig={endpointsConfig} model={iconData?.model} assistantName={assistantName} agentName={agentName} diff --git a/client/src/components/Chat/Messages/MessageParts.tsx b/client/src/components/Chat/Messages/MessageParts.tsx index 620e642ebae..1afd543e44f 100644 --- a/client/src/components/Chat/Messages/MessageParts.tsx +++ b/client/src/components/Chat/Messages/MessageParts.tsx @@ -14,6 +14,7 @@ import { getHeaderModelName } from '~/components/Chat/Messages/ui/HeaderLabel'; import { revealOnRowHoverClasses, messageFooterClasses } from './styles'; import MessageRow from '~/components/Chat/Messages/ui/MessageRow'; import MessageIcon from '~/components/Chat/Messages/MessageIcon'; +import Elapsed, { shouldShowElapsed } from './Elapsed'; import ContentParts from './Content/ContentParts'; import SiblingSwitch from './SiblingSwitch'; import HoverButtons from './HoverButtons'; @@ -134,6 +135,13 @@ function MessageParts(props: TMessageProps) { isSubmitting && messageId === latestMessageId && revealOnRowHoverClasses, )} /> + {shouldShowElapsed({ + isSubmitting, + isLatestMessage: messageId === latestMessageId, + isCreatedByUser, + siblingIdx, + siblingCount, + }) && } ; } + /** Event children may be persisted against the user request that launched + * the Director. Once its assistant response exists, present that activity + * after the response instead of interrupting the turn between user and + * assistant rows. Exact assistant-owned children remain in the same group. */ + let activityParentMessageIds: string[] = []; + if (message.isCreatedByUser) { + if (!message.children?.length) activityParentMessageIds = [message.messageId]; + } else { + activityParentMessageIds = [message.messageId, message.parentMessageId].filter( + (id): id is string => typeof id === 'string' && id.length > 0, + ); + } + const isEditingActivityAnchor = + typeof currentEditId === 'string' && activityParentMessageIds.includes(currentEditId); + const hasParallelContent = + !message.isCreatedByUser && message.content?.some((part) => part?.groupId != null) === true; /** * The child recursion is a sibling of the row (not rendered inside it), so a @@ -196,14 +212,13 @@ function MultiMessage({ return ( <> {row} - {rowMounted && currentEditId !== message.messageId ? ( + {rowMounted && !isEditingActivityAnchor && activityParentMessageIds.length > 0 ? (
-
- -
+
) : null} void) { + return render( + + + , + ); +} + +function advance(ms: number) { + act(() => { + jest.advanceTimersByTime(ms); + }); +} + +describe('Elapsed', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('renders seconds from the submission start anchor and rolls into minutes', () => { + const start = Date.now() - 5_000; + renderElapsed(({ set }) => set(store.submissionStartFamily(0), start)); + + expect(screen.getByTestId('stream-elapsed')).toHaveTextContent(/^5s$/); + expect(screen.getByTestId('stream-elapsed')).toHaveAttribute('aria-hidden', 'true'); + expect(screen.getByText('5 seconds elapsed')).toHaveClass('sr-only'); + + advance(54_000); + expect(screen.getByTestId('stream-elapsed')).toHaveTextContent(/^59s$/); + + advance(1_000); + expect(screen.getByTestId('stream-elapsed')).toHaveTextContent(/^1m 0s$/); + expect(screen.getByText('1 minute elapsed')).toHaveClass('sr-only'); + + advance(59_000); + expect(screen.getByTestId('stream-elapsed')).toHaveTextContent(/^1m 59s$/); + + advance(1_000); + expect(screen.getByTestId('stream-elapsed')).toHaveTextContent(/^2m 0s$/); + }); + + it('counts from mount when no submission start is recorded', () => { + renderElapsed(); + + expect(screen.getByTestId('stream-elapsed')).toHaveTextContent(/^0s$/); + + advance(3_000); + expect(screen.getByTestId('stream-elapsed')).toHaveTextContent(/^3s$/); + }); + + it('clamps a future anchor to zero instead of going negative', () => { + const start = Date.now() + 60_000; + renderElapsed(({ set }) => set(store.submissionStartFamily(0), start)); + + expect(screen.getByTestId('stream-elapsed')).toHaveTextContent(/^0s$/); + + advance(61_000); + expect(screen.getByTestId('stream-elapsed')).toHaveTextContent(/^1s$/); + }); + + it('continues from the anchored start across an unmount and remount', () => { + const start = Date.now() - 30_000; + const view = render( + set(store.submissionStartFamily(0), start)}> + + , + ); + + expect(screen.getByTestId('stream-elapsed')).toHaveTextContent(/^30s$/); + + view.rerender( + set(store.submissionStartFamily(0), start)}> + {null} + , + ); + expect(screen.queryByTestId('stream-elapsed')).toBeNull(); + + advance(5_000); + view.rerender( + set(store.submissionStartFamily(0), start)}> + + , + ); + expect(screen.getByTestId('stream-elapsed')).toHaveTextContent(/^35s$/); + }); + + it('clears its interval on unmount', () => { + const view = renderElapsed(); + const timersWhileMounted = jest.getTimerCount(); + expect(timersWhileMounted).toBeGreaterThanOrEqual(1); + + view.rerender({null}); + expect(jest.getTimerCount()).toBe(timersWhileMounted - 1); + }); +}); + +describe('shouldShowElapsed', () => { + const streamingRow = { + isSubmitting: true, + isLatestMessage: true, + isCreatedByUser: false, + siblingIdx: 1, + siblingCount: 2, + }; + + it('shows under the newest sibling of the streaming latest assistant row', () => { + expect(shouldShowElapsed(streamingRow)).toBe(true); + }); + + it('shows when sibling metadata is absent (a lone response)', () => { + expect( + shouldShowElapsed({ isSubmitting: true, isLatestMessage: true, isCreatedByUser: false }), + ).toBe(true); + }); + + it('hides under an older sibling the reader paged to mid-stream', () => { + expect(shouldShowElapsed({ ...streamingRow, siblingIdx: 0 })).toBe(false); + }); + + it('hides for user rows, settled rows, and non-latest rows', () => { + expect(shouldShowElapsed({ ...streamingRow, isCreatedByUser: true })).toBe(false); + expect(shouldShowElapsed({ ...streamingRow, isSubmitting: false })).toBe(false); + expect(shouldShowElapsed({ ...streamingRow, isLatestMessage: false })).toBe(false); + }); +}); diff --git a/client/src/components/Chat/Messages/__tests__/HoverActions.streaming.spec.tsx b/client/src/components/Chat/Messages/__tests__/HoverActions.streaming.spec.tsx index ed0bb121666..e0918057668 100644 --- a/client/src/components/Chat/Messages/__tests__/HoverActions.streaming.spec.tsx +++ b/client/src/components/Chat/Messages/__tests__/HoverActions.streaming.spec.tsx @@ -12,6 +12,7 @@ import Message from '~/components/Chat/Messages/Message'; import store from '~/store'; let mockHoverButtonsRenderCount = 0; +let mockContentRenderCount = 0; jest.mock('~/components/Chat/Messages/HoverButtons', () => ({ __esModule: true, @@ -23,14 +24,18 @@ jest.mock('~/components/Chat/Messages/HoverButtons', () => ({ jest.mock('~/components/Chat/Messages/Content/MessageContent', () => ({ __esModule: true, - default: ({ text }: { text: string }) =>
{text}
, + default: ({ text }: { text: string }) => { + mockContentRenderCount += 1; + return
{text}
; + }, })); jest.mock('~/components/Chat/Messages/Content/ContentParts', () => ({ __esModule: true, - default: ({ content }: { content?: TMessage['content'] }) => ( -
{JSON.stringify(content ?? [])}
- ), + default: ({ content }: { content?: TMessage['content'] }) => { + mockContentRenderCount += 1; + return
{JSON.stringify(content ?? [])}
; + }, })); jest.mock('~/components/Chat/Messages/Content/Parts/AuthorHeader', () => ({ @@ -135,7 +140,15 @@ function createQueryClient() { }); } -function DerivedStreamingRow({ structured = false }: { structured?: boolean }) { +function DerivedStreamingRow({ + structured = false, + submitting = true, + siblingIdx = 1, +}: { + structured?: boolean; + submitting?: boolean; + siblingIdx?: number; +}) { const queryClient = useQueryClient(); const latestMessage = useLatestMessage(0); const latestMessageId = useLatestMessageId(0); @@ -151,7 +164,7 @@ function DerivedStreamingRow({ structured = false }: { structured?: boolean }) { latestMessageId: latestMessageId ?? undefined, latestMessageDepth, handleContinue: jest.fn(), - isSubmitting: true, + isSubmitting: submitting, abortScroll: false, setAbortScroll: jest.fn(), getMessages: () => @@ -163,7 +176,7 @@ function DerivedStreamingRow({ structured = false }: { structured?: boolean }) { ); }, }) as unknown as ReturnType, - [latestMessageDepth, latestMessageId, queryClient], + [latestMessageDepth, latestMessageId, queryClient, submitting], ); if (!latestMessage) { @@ -178,7 +191,7 @@ function DerivedStreamingRow({ structured = false }: { structured?: boolean }) { message={latestMessage} currentEditId={null} setCurrentEditId={jest.fn()} - siblingIdx={0} + siblingIdx={siblingIdx} siblingCount={2} setSiblingIdx={jest.fn()} /> @@ -187,7 +200,7 @@ function DerivedStreamingRow({ structured = false }: { structured?: boolean }) { ); } -function renderStreamingRow(structured = false) { +function renderStreamingRow(structured = false, submitting = true, siblingIdx = 1) { const queryClient = createQueryClient(); queryClient.setQueryData( [QueryKeys.messages, conversation.conversationId], @@ -196,14 +209,18 @@ function renderStreamingRow(structured = false) { const initializeState = ({ set }: MutableSnapshot) => { set(store.conversationByIndex(0), conversation); - set(store.isSubmittingFamily(0), true); + set(store.isSubmittingFamily(0), submitting); }; render( - + , @@ -215,6 +232,7 @@ function renderStreamingRow(structured = false) { describe('streaming hover actions', () => { beforeEach(() => { mockHoverButtonsRenderCount = 0; + mockContentRenderCount = 0; }); it('keeps actions mounted while an optimistic assistant row is replaced', async () => { @@ -284,4 +302,61 @@ describe('streaming hover actions', () => { expect(screen.getByTestId('hover-buttons').parentElement).toHaveClass('min-h-[31px]'); }); + + /** + * The elapsed-time indicator fills the footer slot the withheld actions leave + * empty, but only under the response that is actively generating. + */ + it.each([ + ['a plain text', false], + ['a structured', true], + ])('shows the elapsed timer under %s streaming response', (_label, structured) => { + renderStreamingRow(structured); + + expect(screen.getByTestId('stream-elapsed')).toBeInTheDocument(); + }); + + it('renders no elapsed timer once the row is not submitting', () => { + renderStreamingRow(false, false); + + expect(screen.queryByTestId('stream-elapsed')).toBeNull(); + }); + + /** + * `latestMessageId` follows the SELECTED branch, so a settled older sibling + * the reader paged to mid-regeneration satisfies the latest+submitting gate. + * The timer additionally requires the newest sibling position — a counting + * timer under settled content misleads in a way withheld buttons don't. + */ + it('renders no elapsed timer under an older sibling selected mid-stream', () => { + renderStreamingRow(false, true, 0); + + expect(screen.queryByTestId('stream-elapsed')).toBeNull(); + expect(screen.getByTestId('hover-buttons')).toBeInTheDocument(); + }); + + /** + * The timer's once-per-second tick is component-local state: advancing the + * clock must re-render nothing beyond the timer itself, or the indicator + * would tax every streaming frame's neighbors. + */ + it('ticks the elapsed timer without re-rendering content or actions', () => { + jest.useFakeTimers(); + try { + renderStreamingRow(); + + const hoverRenders = mockHoverButtonsRenderCount; + const contentRenders = mockContentRenderCount; + + act(() => { + jest.advanceTimersByTime(5_000); + }); + + expect(screen.getByTestId('stream-elapsed')).toBeInTheDocument(); + expect(mockHoverButtonsRenderCount).toBe(hoverRenders); + expect(mockContentRenderCount).toBe(contentRenders); + } finally { + jest.useRealTimers(); + } + }); }); diff --git a/client/src/components/Chat/Messages/__tests__/MultiMessage.spec.tsx b/client/src/components/Chat/Messages/__tests__/MultiMessage.spec.tsx index 7a2146e1c96..7933482fd86 100644 --- a/client/src/components/Chat/Messages/__tests__/MultiMessage.spec.tsx +++ b/client/src/components/Chat/Messages/__tests__/MultiMessage.spec.tsx @@ -34,8 +34,18 @@ jest.mock('../MessageParts', () => ({ __esModule: true, default: createRowStub() jest.mock('../Message', () => ({ __esModule: true, default: createRowStub() })); jest.mock('~/components/Chat/Subagents/EventSubagentActivityGroup', () => ({ __esModule: true, - default: ({ parentMessageId }: { parentMessageId: string }) => ( -
+ default: ({ + parentMessageIds, + hasParallelContent, + }: { + parentMessageIds: string[]; + hasParallelContent?: boolean; + }) => ( +
), })); @@ -81,8 +91,8 @@ describe('MultiMessage sibling selection', () => { ); expect(screen.getByTestId('event-subagent-activity')).toHaveAttribute( - 'data-parent-message-id', - 'structured', + 'data-parent-message-ids', + 'structured,parent-1', ); view.rerender( @@ -96,8 +106,111 @@ describe('MultiMessage sibling selection', () => { , ); expect(screen.getByTestId('event-subagent-activity')).toHaveAttribute( - 'data-parent-message-id', - 'legacy', + 'data-parent-message-ids', + 'legacy,parent-1', + ); + }); + + it('places a user-anchored event group after the assistant response', () => { + const assistant = msg('assistant'); + const user = { + ...msg('user'), + isCreatedByUser: true, + parentMessageId: 'root', + children: [assistant], + } as TMessage; + assistant.parentMessageId = 'user'; + + render( + + + , + ); + + expect(screen.getAllByTestId('event-subagent-activity')).toHaveLength(1); + expect(screen.getByTestId('event-subagent-activity')).toHaveAttribute( + 'data-parent-message-ids', + 'assistant,user', + ); + expect( + screen + .getByText('assistant') + .compareDocumentPosition(screen.getByTestId('event-subagent-activity')), + ).toBe(Node.DOCUMENT_POSITION_FOLLOWING); + }); + + it('hides merged event activity while its user anchor is being edited', () => { + const assistant = { ...msg('assistant'), parentMessageId: 'user' } as TMessage; + const user = { + ...msg('user'), + isCreatedByUser: true, + parentMessageId: 'root', + children: [assistant], + } as TMessage; + + render( + + + , + ); + + expect(screen.queryByTestId('event-subagent-activity')).not.toBeInTheDocument(); + }); + + it('matches the wider layout of a parallel assistant response', () => { + const assistant = { + ...msg('assistant'), + content: [{ type: 'text', text: 'answer', groupId: 'parallel-group' }], + } as unknown as TMessage; + + render( + + + , + ); + + expect(screen.getByTestId('event-subagent-activity')).toHaveAttribute( + 'data-has-parallel-content', + 'true', + ); + }); + + it('renders assistant content containing an undefined streaming placeholder', () => { + const assistant = { + ...msg('assistant'), + content: [undefined, { type: 'text', text: 'answer' }], + } as unknown as TMessage; + + render( + + + , + ); + + expect(screen.getByTestId('row')).toHaveTextContent('assistant'); + expect(screen.getByTestId('event-subagent-activity')).toHaveAttribute( + 'data-has-parallel-content', + 'false', ); }); diff --git a/client/src/components/Chat/Messages/ui/MessageRender.tsx b/client/src/components/Chat/Messages/ui/MessageRender.tsx index 110dea397b7..355ae59c2c5 100644 --- a/client/src/components/Chat/Messages/ui/MessageRender.tsx +++ b/client/src/components/Chat/Messages/ui/MessageRender.tsx @@ -9,6 +9,7 @@ import { getMessageAriaLabel, } from '~/utils'; import { revealOnRowHoverClasses, messageFooterClasses } from '~/components/Chat/Messages/styles'; +import Elapsed, { shouldShowElapsed } from '~/components/Chat/Messages/Elapsed'; import MessageContent from '~/components/Chat/Messages/Content/MessageContent'; import { getHeaderModelName } from '~/components/Chat/Messages/ui/HeaderLabel'; import { useLocalize, useMessageActions, useContentMetadata } from '~/hooks'; @@ -180,6 +181,13 @@ const MessageRender = memo(function MessageRender({ isSubmitting && isLatestMessage && revealOnRowHoverClasses, )} /> + {shouldShowElapsed({ + isSubmitting, + isLatestMessage, + isCreatedByUser: msg.isCreatedByUser, + siblingIdx, + siblingCount, + }) && } (); jest.mock('./ParentSubagentsProvider', () => ({ useParentSubagents: () => ({ - byMessageId: new Map([['parent-message', [mockChild]]]), + byMessageId: mockChildrenByMessage, byThreadId: new Map([['event-thread', mockChild]]), refresh: mockRefresh, }), })); jest.mock('~/Providers', () => ({ - useAgentsMapContext: () => ({ 'agent-1': { id: 'agent-1', name: 'Visible Agent' } }), + useAgentsMapContext: () => ({ + 'agent-1': { id: 'agent-1', name: 'Visible Agent' }, + 'agent-2': { id: 'agent-2', name: 'Completed Agent' }, + }), })); jest.mock('~/hooks', () => ({ useLocalize: () => (key: string) => key })); @@ -40,11 +54,15 @@ jest.mock('~/utils', () => ({ renderAgentAvatar: () => , })); jest.mock('@librechat/client', () => ({ + Button: ({ children, ...props }: React.ComponentProps<'button'>) => ( + + ), cn: (...values: Array) => values.filter(Boolean).join(' '), })); jest.mock('lucide-react', () => ({ AlertCircle: () => null, Bot: () => null, + ChevronDown: () => null, Check: () => null, CheckCircle2: () => null, CircleAlert: () => null, @@ -57,6 +75,7 @@ jest.mock('lucide-react', () => ({ describe('EventSubagentActivityGroup', () => { beforeEach(() => { mockRefresh.mockReset().mockResolvedValue(undefined); + mockChildrenByMessage = new Map([['parent-message', [mockChild]]]); }); it('opens the durable event child under its owning parent message', () => { @@ -70,11 +89,16 @@ describe('EventSubagentActivityGroup', () => { , ); + expect( + screen.getByRole('region', { name: 'com_ui_subagent_activity' }).parentElement, + ).toHaveClass('px-4', 'sm:px-0', 'md:max-w-3xl', 'xl:max-w-4xl'); + expect(screen.queryByRole('button', { name: /Visible Agent/ })).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: /com_ui_subagent_activity/ })); fireEvent.click(screen.getByRole('button', { name: /Visible Agent/ })); expect(mockRefresh).toHaveBeenCalledTimes(1); @@ -88,11 +112,99 @@ describe('EventSubagentActivityGroup', () => { event: { actorId: 'actor-a', progressKey: 'event-task:event-thread:task-1', + siblingParentMessageIds: ['parent-message'], }, }), ); }); + it('matches the width of a parallel assistant response', () => { + render( + + + , + ); + + expect( + screen.getByRole('region', { name: 'com_ui_subagent_activity' }).parentElement, + ).toHaveClass('md:max-w-[58rem]', 'xl:max-w-[70rem]'); + }); + + it('retains a merged anchor that has no children yet', () => { + let selection: ActiveSubagentPanel | null = null; + const Observer = () => { + selection = useRecoilValue(activeSubagentPanel); + return null; + }; + + render( + + + + , + ); + + fireEvent.click(screen.getByRole('button', { name: /com_ui_subagent_activity/ })); + fireEvent.click(screen.getByRole('button', { name: /Visible Agent/ })); + + expect((selection as ActiveSubagentPanel | null)?.event?.siblingParentMessageIds).toEqual([ + 'parent-message', + 'empty-assistant-message', + ]); + }); + + it('preserves every merged message anchor and uses explicit plural status labels', () => { + mockChildrenByMessage = new Map([ + ['parent-message', [mockChild]], + [ + 'assistant-message', + [ + mockCompletedChild, + { + ...mockCompletedChild, + threadId: 'event-thread-3', + actorId: 'actor-c', + agentId: undefined, + title: 'Third actor', + }, + ], + ], + ]); + let selection: ActiveSubagentPanel | null = null; + const Observer = () => { + selection = useRecoilValue(activeSubagentPanel); + return null; + }; + + render( + + + + , + ); + + const summary = screen.getByRole('button', { name: /com_ui_subagent_activity/ }); + expect(summary).toHaveAccessibleName(/com_ui_subagent_count_running_one/); + expect(summary).toHaveAccessibleName(/com_ui_subagent_count_completed_other/); + fireEvent.click(summary); + fireEvent.click(screen.getByRole('button', { name: /Completed Agent/ })); + + expect((selection as ActiveSubagentPanel | null)?.event?.siblingParentMessageIds).toEqual([ + 'parent-message', + 'assistant-message', + ]); + }); + it('does not reopen a child after the user closes it while refresh is pending', async () => { let selection: ActiveSubagentPanel | null = null; let resolveRefresh!: (value: unknown) => void; @@ -115,11 +227,12 @@ describe('EventSubagentActivityGroup', () => { , ); + fireEvent.click(screen.getByRole('button', { name: /com_ui_subagent_activity/ })); fireEvent.click(screen.getByRole('button', { name: /Visible Agent/ })); expect(selection).toEqual( expect.objectContaining({ durable: expect.objectContaining({ taskId: 'task-1' }) }), diff --git a/client/src/components/Chat/Subagents/EventSubagentActivityGroup.tsx b/client/src/components/Chat/Subagents/EventSubagentActivityGroup.tsx index 40b1ded56e6..62e82344bbb 100644 --- a/client/src/components/Chat/Subagents/EventSubagentActivityGroup.tsx +++ b/client/src/components/Chat/Subagents/EventSubagentActivityGroup.tsx @@ -1,8 +1,9 @@ -import { useCallback } from 'react'; -import { Bot } from 'lucide-react'; -import { cn } from '@librechat/client'; -import { useResetRecoilState, useSetRecoilState } from 'recoil'; +import { useCallback, useId, useMemo, useState } from 'react'; +import { Button, cn } from '@librechat/client'; +import { Bot, ChevronDown } from 'lucide-react'; +import { useRecoilValue, useResetRecoilState, useSetRecoilState } from 'recoil'; import type { ParentSubagentSummary } from 'librechat-data-provider'; +import { getMessageRowWidthClass } from '~/components/Chat/Messages/ui/MessageRow'; import { subagentStatusIcon, subagentStatusLabelKey } from './status'; import { useParentSubagents } from './ParentSubagentsProvider'; import { eventSubagentSelection } from './eventSelection'; @@ -12,33 +13,83 @@ import { renderAgentAvatar } from '~/utils'; import { useLocalize } from '~/hooks'; import store from '~/store'; +const STATUS_COUNT_LABEL_KEYS = { + dispatched: { + one: 'com_ui_subagent_count_dispatched_one', + other: 'com_ui_subagent_count_dispatched_other', + }, + running: { + one: 'com_ui_subagent_count_running_one', + other: 'com_ui_subagent_count_running_other', + }, + completed: { + one: 'com_ui_subagent_count_completed_one', + other: 'com_ui_subagent_count_completed_other', + }, + failed: { + one: 'com_ui_subagent_count_failed_one', + other: 'com_ui_subagent_count_failed_other', + }, + interrupted: { + one: 'com_ui_subagent_count_interrupted_one', + other: 'com_ui_subagent_count_interrupted_other', + }, + cancelled: { + one: 'com_ui_subagent_count_cancelled_one', + other: 'com_ui_subagent_count_cancelled_other', + }, +} as const; + export default function EventSubagentActivityGroup({ conversationId, - parentMessageId, + parentMessageIds, + hasParallelContent = false, }: { conversationId: string; - parentMessageId: string; + parentMessageIds: string[]; + hasParallelContent?: boolean; }) { const { byMessageId } = useParentSubagents(); - const children = byMessageId.get(parentMessageId) ?? []; + const children = useMemo(() => { + const seen = new Set(); + return parentMessageIds + .flatMap((messageId) => byMessageId.get(messageId) ?? []) + .filter((child) => { + if (seen.has(child.threadId)) return false; + seen.add(child.threadId); + return true; + }); + }, [byMessageId, parentMessageIds]); + const fullWidth = useRecoilValue(store.maximizeChatSpace); + const siblingParentMessageIds = useMemo( + () => Array.from(new Set(parentMessageIds)), + [parentMessageIds], + ); if (children.length === 0) return null; return ( - +
+ +
); } function EventSubagentRows({ conversationId, - parentMessageId, eventChildren, + siblingParentMessageIds, }: { conversationId: string; - parentMessageId: string; eventChildren: ParentSubagentSummary[]; + siblingParentMessageIds: string[]; }) { const localize = useLocalize(); const agentsMap = useAgentsMapContext(); @@ -46,9 +97,27 @@ function EventSubagentRows({ const setSelected = useSetRecoilState(activeSubagentPanel); const setArtifactsVisible = useSetRecoilState(store.artifactsVisibility); const resetCurrentArtifactId = useResetRecoilState(store.currentArtifactId); + const [expanded, setExpanded] = useState(false); + const panelId = useId(); + const counts = useMemo(() => { + const result = new Map(); + eventChildren.forEach((child) => result.set(child.status, (result.get(child.status) ?? 0) + 1)); + return result; + }, [eventChildren]); + const summary = [ + localize( + eventChildren.length === 1 ? 'com_ui_subagent_agent_count' : 'com_ui_subagent_agents_count', + { 0: String(eventChildren.length) }, + ), + ...Array.from(counts.entries()).map(([status, count]) => + localize(STATUS_COUNT_LABEL_KEYS[status][count === 1 ? 'one' : 'other'], { + 0: String(count), + }), + ), + ].join(' · '); const openChild = useCallback( (child: ParentSubagentSummary) => { - const selection = eventSubagentSelection(conversationId, child); + const selection = eventSubagentSelection(conversationId, child, siblingParentMessageIds); if (selection == null) return; resetCurrentArtifactId(); setArtifactsVisible(false); @@ -56,7 +125,11 @@ function EventSubagentRows({ void refresh().then((index) => { const fresh = index?.children.find((candidate) => candidate.threadId === child.threadId); if (fresh == null || fresh.latestTaskId === child.latestTaskId) return; - const freshSelection = eventSubagentSelection(conversationId, fresh); + const freshSelection = eventSubagentSelection( + conversationId, + fresh, + siblingParentMessageIds, + ); if (freshSelection != null) { setSelected((current) => { if ( @@ -70,18 +143,43 @@ function EventSubagentRows({ } }); }, - [conversationId, refresh, resetCurrentArtifactId, setArtifactsVisible, setSelected], + [ + conversationId, + refresh, + resetCurrentArtifactId, + setArtifactsVisible, + setSelected, + siblingParentMessageIds, + ], ); return (
-
- {localize('com_ui_subagent_activity')} -
-
+ +