Compare commits

..

5 Commits

Author SHA1 Message Date
claude code agent 227
b392219659 fix(ai-mcp): show Failed when the inline Test request itself rejects (#170)
The per-row MCP Test button derived its presentation solely from the test
mutation's data ({ ok, tools } | { ok, error }). When the request itself
rejected (401/403/500/network) there is no payload, so the row silently spun
back to the idle "Test" instead of reporting the failure.

Feed the mutation error into mcpTestButtonView so a reject also renders a red
"Failed", with the tooltip taken from the server message
(error.response.data.message) or a generic i18n fallback. Enable the tooltip
for any non-idle state. Cover the reject branch (with and without a server
message) in the helper unit test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 17:37:08 +03:00
claude code agent 227
ba5cd02439 Address PR #197 review: test coverage + dedup + CI log capture
Code-review follow-ups (Approve-with-comments) for batch #197
(context badge #189 / e2e in CI #187 / inline MCP test #170):

- server: extract the duplicated chatContextWindow ::text->positive-int
  coercion (resolve() + getMasked()) into an exported parsePositiveInt
  helper and unit-test its branches (200000/1.9/0/-5/""/abc/undefined),
  closing the untested read-path gap.
- client: merge the two backward scans over messageRows into one pure,
  exported selectContextBadge helper (numerator and denominator still
  taken from the most recent row carrying EACH value) and unit-test the
  different-rows and fresh-zero-doesn't-shadow cases.
- client: extract the MCP "Test" button tristate presentation into a pure
  mcpTestButtonView helper (collapses the two parallel if/else chains) and
  unit-test idle/ok-with-tools/ok-no-tools/failed label+tooltip branches.
- ci: redirect the backgrounded prod server's stdout/stderr to a log file
  in e2e-mcp and cat it on failure, so a start-up crash is diagnosable
  instead of surfacing only as the generic health timeout.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 17:24:29 +03:00
claude_code
9b61024b95 feat(ai-chat): header badge shows current/max context, max from AI settings (#189)
The floating chat window's header badge flipped meaning — a live per-turn token
counter while streaming, the persisted context size at rest — so it "reset to 1"
on each prompt and conflated two different numbers. Replace it with a stable
"current / max" context badge (e.g. `572 / 200k`). The live "Thinking · N tokens"
inside the chat body stays; only the duplicate live counter is removed from the
header.

Max comes from a new admin setting "Context window (tokens)". The server resolves
it and attaches `maxContextTokens` to the completed assistant turn's metadata
(next to contextTokens), so the badge needs no client-side model resolution and
this survives public shares / per-role models.

Server:
- ai.types: chatContextWindow on AiProviderSettings + PROVIDER_SETTINGS_KEYS +
  ResolvedAiConfig + MaskedAiSettings.
- workspace.repo: chatContextWindow in AI_PROVIDER_SETTINGS_ALLOWED (parity).
- update-ai-settings.dto: @IsInt @Min(0) chatContextWindow.
- ai-settings.service: coerce the ::text-stored value to a positive int in
  resolve()/getMasked().
- ai-chat.service: flushAssistant writes metadata.maxContextTokens (>0); the
  completed turn passes resolved.chatContextWindow.

Client:
- ai-chat.types: maxContextTokens on the message-row metadata.
- ai-chat-window: read maxContextTokens; render "current [/ max]"; drop the
  liveTurnTokens state/branch and the onLiveTurnTokens prop; new tooltip.
- chat-thread: remove the live-turn-token throttle effect and plumbing.
- count-stream-tokens: drop the now-dead liveTurnTokens()/types; keep
  estimateTokens.
- settings: chatContextWindow on IAiSettings(+Update) + a NumberInput in the AI
  provider settings form.

i18n: add the badge/settings keys (en, ru); remove the two now-unused keys.
Tests: flushAssistant maxContextTokens, DTO validation, trim token tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 22:39:09 +03:00
claude_code
2644fe6a83 feat(ai-chat): inline Test button per external MCP server row (#170)
Add a per-row Test button to the external MCP servers list that shows the
connection result inline (no toasts). Extract the row into AiMcpServerRow so
each row owns its own useTestAiMcpServerMutation instance — independent loading
and result, no cross-row flicker.

States: idle (Test), pending (loading), success (green, "OK · N" with the tool
count), failure (red, "Failed"); a tooltip shows the tool list or the error.
The result resets when url/transport/headers change (the row is keyed by id, so
it does not remount). Backend, service and mutation are unchanged.

- ai-mcp-servers.tsx: AiMcpServerRow + Test button + reset effect + tooltip.
- i18n: add Failed / "OK · {{n}}" (en, ru) and ru Test / tool-list keys.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 22:22:48 +03:00
claude_code
993f884e64 ci(develop): run server + mcp e2e on every develop push without blocking deploy (#187)
Add two independent jobs to develop.yml — e2e-server and e2e-mcp — that run on
each push to develop alongside test/build. `build` stays `needs: test` only, so
a failing e2e never blocks the :develop image build/publish; the red run plus
GitHub's email to the pusher is the notification.

- e2e-server: pgvector + redis services, migrations, apps/server test:e2e.
- e2e-mcp: build editor-ext/server/mcp, migrate, start the prod server
  (REST + /collab in one process), wait for /api/health, seed the admin via
  /api/auth/setup, then run @docmost/mcp test:e2e.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 22:22:48 +03:00
25 changed files with 804 additions and 450 deletions

View File

@@ -56,3 +56,160 @@ jobs:
tags: ${{ env.IMAGE }}:develop tags: ${{ env.IMAGE }}:develop
cache-from: type=gha,scope=develop-amd64 cache-from: type=gha,scope=develop-amd64
cache-to: type=gha,scope=develop-amd64,mode=max,ignore-error=true cache-to: type=gha,scope=develop-amd64,mode=max,ignore-error=true
# e2e jobs run on every develop push but DO NOT gate the build/publish above:
# `build` stays `needs: test` only, so the :develop image still ships even if
# e2e fails. A failing e2e job turns the run red and triggers GitHub's email
# to the pusher — that red run + email is the intended notification, not a
# deploy block.
e2e-server:
runs-on: ubuntu-latest
env:
DATABASE_URL: postgresql://docmost:docmost@localhost:5432/docmost
REDIS_URL: redis://localhost:6379
APP_SECRET: ci-e2e-secret-change-me-min-32-characters
APP_URL: http://localhost:3000
services:
postgres:
image: pgvector/pgvector:pg18
env:
POSTGRES_DB: docmost
POSTGRES_USER: docmost
POSTGRES_PASSWORD: docmost
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U docmost"
--health-interval 5s
--health-timeout 5s
--health-retries 20
redis:
image: redis:7
ports:
- 6379:6379
options: >-
--health-cmd "redis-cli ping"
--health-interval 5s
--health-timeout 5s
--health-retries 20
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up pnpm
uses: pnpm/action-setup@v4
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build editor-ext
run: pnpm --filter @docmost/editor-ext build
- name: Run migrations
run: pnpm --filter ./apps/server migration:latest
- name: Run server e2e
run: pnpm --filter ./apps/server test:e2e
# Same rationale as e2e-server: this job is intentionally NOT in
# `build.needs`. Deploy of the :develop image must not be blocked by e2e;
# a red run plus GitHub's email to the pusher is the notification mechanism.
e2e-mcp:
runs-on: ubuntu-latest
env:
DATABASE_URL: postgresql://docmost:docmost@localhost:5432/docmost
REDIS_URL: redis://localhost:6379
APP_SECRET: ci-e2e-secret-change-me-min-32-characters
APP_URL: http://localhost:3000
NODE_ENV: production
services:
postgres:
image: pgvector/pgvector:pg18
env:
POSTGRES_DB: docmost
POSTGRES_USER: docmost
POSTGRES_PASSWORD: docmost
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U docmost"
--health-interval 5s
--health-timeout 5s
--health-retries 20
redis:
image: redis:7
ports:
- 6379:6379
options: >-
--health-cmd "redis-cli ping"
--health-interval 5s
--health-timeout 5s
--health-retries 20
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up pnpm
uses: pnpm/action-setup@v4
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build editor-ext
run: pnpm --filter @docmost/editor-ext build
- name: Build server
run: pnpm server:build
- name: Build mcp
run: pnpm --filter @docmost/mcp build
- name: Run migrations
run: pnpm --filter ./apps/server migration:latest
- name: Start server (prod)
# Capture stdout/stderr so a start-up crash (bind error, stack trace,
# migration mismatch) is diagnosable; without this the only signal is
# the generic health-loop timeout below, ~120s later.
run: pnpm --filter ./apps/server start:prod > /tmp/server.log 2>&1 &
- name: Wait for server health
run: |
for i in $(seq 1 60); do
if curl -fsS http://localhost:3000/api/health > /dev/null; then
echo "Server is healthy"
exit 0
fi
sleep 2
done
echo "Server did not become healthy in time"
exit 1
- name: Dump server log on failure
if: failure()
run: cat /tmp/server.log || true
- name: Seed admin
run: |
curl -fsS -X POST http://localhost:3000/api/auth/setup \
-H "Content-Type: application/json" \
-d '{"name":"E2E","email":"e2e@example.com","password":"E2ePassword123","workspaceName":"E2E"}'
- name: Run mcp e2e
env:
DOCMOST_API_URL: http://localhost:3000/api
DOCMOST_EMAIL: e2e@example.com
DOCMOST_PASSWORD: E2ePassword123
run: pnpm --filter @docmost/mcp test:e2e

View File

@@ -43,13 +43,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
OpenRouter, etc.; `openai` uses the official provider (real-OpenAI OpenRouter, etc.; `openai` uses the official provider (real-OpenAI
reasoning-model request shaping). Chosen explicitly rather than inferred from reasoning-model request shaping). Chosen explicitly rather than inferred from
the base URL, since a custom URL can front real OpenAI too. (#175, #177) the base URL, since a custom URL can front real OpenAI too. (#175, #177)
- **AI chat "Context window (tokens)" setting (`chatContextWindow`).** A new
admin field in AI settings that records the chat model's context-window size.
When set (> 0) it becomes the denominator of the header context-badge, which
now reads "used / max"; `0`/empty clears the limit and the badge shows only
the current context as before. There is no provider-independent way to read a
model's window automatically, so it is an explicit workspace-level value.
(#189)
- **Per-MCP-server instructions in the agent prompt.** Each external MCP server - **Per-MCP-server instructions in the agent prompt.** Each external MCP server
now has an admin-authored `instructions` field ("how/when to use this server's now has an admin-authored `instructions` field ("how/when to use this server's
tools") that is injected into the agent's system prompt next to that server's tools") that is injected into the agent's system prompt next to that server's
@@ -68,12 +61,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
model's reasoning out of the box. An endpoint that is real OpenAI behind a model's reasoning out of the box. An endpoint that is real OpenAI behind a
custom base URL should set the new `chatApiStyle` "Protocol" to `openai`. (#177) custom base URL should set the new `chatApiStyle` "Protocol" to `openai`. (#177)
- **AI chat header context-badge now shows "used / max".** When an admin sets
the new `chatContextWindow`, the badge displays the current context size over
the configured window (e.g. `120k / 200k`) instead of switching to a live
per-turn token counter during streaming. With no window configured the badge
keeps showing just the current context. (#189)
- **Footnotes now reuse (Pandoc semantics).** Multiple `[^a]` references to the - **Footnotes now reuse (Pandoc semantics).** Multiple `[^a]` references to the
same id are ONE footnote — one number, one definition, several back-references same id are ONE footnote — one number, one definition, several back-references
— instead of being renamed to `a__2`, `a__3`. Duplicate `[^a]:` definitions are — instead of being renamed to `a__2`, `a__3`. Duplicate `[^a]:` definitions are

View File

@@ -715,6 +715,8 @@
"Test": "Test", "Test": "Test",
"Available tools": "Available tools", "Available tools": "Available tools",
"No tools available": "No tools available", "No tools available": "No tools available",
"Failed": "Failed",
"OK · {{n}}": "OK · {{n}}",
"Created successfully": "Created successfully", "Created successfully": "Created successfully",
"Deleted successfully": "Deleted successfully", "Deleted successfully": "Deleted successfully",
"Clear": "Clear", "Clear": "Clear",
@@ -1167,11 +1169,9 @@
"Pick an agent role whose persona the public assistant adopts. The safety rules always still apply.": "Pick an agent role whose persona the public assistant adopts. The safety rules always still apply.", "Pick an agent role whose persona the public assistant adopts. The safety rules always still apply.": "Pick an agent role whose persona the public assistant adopts. The safety rules always still apply.",
"Built-in assistant persona": "Built-in assistant persona", "Built-in assistant persona": "Built-in assistant persona",
"Minimize": "Minimize", "Minimize": "Minimize",
"Current context size": "Current context size",
"Context size / model limit": "Context size / model limit", "Context size / model limit": "Context size / model limit",
"Context window (tokens)": "Context window (tokens)", "Context window (tokens)": "Context window (tokens)",
"Shows used / total in the chat header badge; empty hides the total.": "Shows used / total in the chat header badge; empty hides the total.", "Shown as used / total in the chat header. Leave empty to hide the limit.": "Shown as used / total in the chat header. Leave empty to hide the limit.",
"e.g. 200000": "e.g. 200000",
"AI agent": "AI agent", "AI agent": "AI agent",
"Take a look at the current document": "Take a look at the current document", "Take a look at the current document": "Take a look at the current document",
"AI agent is typing…": "AI agent is typing…", "AI agent is typing…": "AI agent is typing…",

View File

@@ -704,16 +704,19 @@
"Ask the AI agent…": "Спросите AI-агента…", "Ask the AI agent…": "Спросите AI-агента…",
"Copy chat": "Копировать чат", "Copy chat": "Копировать чат",
"Created successfully": "Успешно создано", "Created successfully": "Успешно создано",
"Current context size": "Текущий размер контекста",
"Context size / model limit": "Размер контекста / лимит модели", "Context size / model limit": "Размер контекста / лимит модели",
"Context window (tokens)": "Размер окна контекста (токены)", "Context window (tokens)": "Окно контекста (токены)",
"Shows used / total in the chat header badge; empty hides the total.": "Показывает использовано/всего в шапке чата; пусто — скрыть лимит.", "Shown as used / total in the chat header. Leave empty to hide the limit.": "Показывается в шапке чата как использовано / всего. Пусто — лимит скрыт.",
"e.g. 200000": "напр. 200000",
"Delete this chat?": "Удалить этот чат?", "Delete this chat?": "Удалить этот чат?",
"Deleted successfully": "Успешно удалено", "Deleted successfully": "Успешно удалено",
"Edited by AI agent on behalf of {{name}}": "Отредактировано AI-агентом от имени {{name}}", "Edited by AI agent on behalf of {{name}}": "Отредактировано AI-агентом от имени {{name}}",
"Failed to delete chat": "Не удалось удалить чат", "Failed to delete chat": "Не удалось удалить чат",
"Failed to rename chat": "Не удалось переименовать чат", "Failed to rename chat": "Не удалось переименовать чат",
"Failed": "Ошибка",
"OK · {{n}}": "OK · {{n}}",
"Test": "Тест",
"No tools available": "Инструменты недоступны",
"Available tools": "Доступные инструменты",
"Minimize": "Свернуть", "Minimize": "Свернуть",
"No chats yet.": "Чатов пока нет.", "No chats yet.": "Чатов пока нет.",
"Send": "Отправить", "Send": "Отправить",

View File

@@ -6,7 +6,7 @@ import {
useRef, useRef,
useState, useState,
} from "react"; } from "react";
import { Group, Loader } from "@mantine/core"; import { Group, Loader, Tooltip } from "@mantine/core";
import { import {
IconArrowsDiagonal, IconArrowsDiagonal,
IconCheck, IconCheck,
@@ -39,13 +39,13 @@ import {
} from "@/features/ai-chat/queries/ai-chat-query.ts"; } from "@/features/ai-chat/queries/ai-chat-query.ts";
import ConversationList from "@/features/ai-chat/components/conversation-list.tsx"; import ConversationList from "@/features/ai-chat/components/conversation-list.tsx";
import ChatThread from "@/features/ai-chat/components/chat-thread.tsx"; import ChatThread from "@/features/ai-chat/components/chat-thread.tsx";
import { ContextBadge } from "@/features/ai-chat/components/context-badge.tsx";
import { exportAiChat } from "@/features/ai-chat/services/ai-chat-service.ts"; import { exportAiChat } from "@/features/ai-chat/services/ai-chat-service.ts";
import { useChatSession } from "@/features/ai-chat/hooks/use-chat-session.ts"; import { useChatSession } from "@/features/ai-chat/hooks/use-chat-session.ts";
import { import {
shouldCollapseOnOutsidePointer, shouldCollapseOnOutsidePointer,
isHeaderClick, isHeaderClick,
} from "@/features/ai-chat/utils/collapse-helpers.ts"; } from "@/features/ai-chat/utils/collapse-helpers.ts";
import { selectContextBadge } from "@/features/ai-chat/utils/context-badge.ts";
import { useClipboard } from "@/hooks/use-clipboard"; import { useClipboard } from "@/hooks/use-clipboard";
import { notifications } from "@mantine/notifications"; import { notifications } from "@mantine/notifications";
import classes from "@/features/ai-chat/components/ai-chat-window.module.css"; import classes from "@/features/ai-chat/components/ai-chat-window.module.css";
@@ -61,6 +61,13 @@ const MIN_HEIGHT = 400;
// Margin kept between the window and the viewport edges while dragging. // Margin kept between the window and the viewport edges while dragging.
const EDGE_MARGIN = 8; const EDGE_MARGIN = 8;
/** Compact token formatter: 1.2M / 3.4k / 950. */
function formatTokens(n: number): string {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`;
return String(n);
}
// Compute the initial top-right placement at the default size, fitted to the // Compute the initial top-right placement at the default size, fitted to the
// current viewport. Reads `window` only when called (inside an effect). // current viewport. Reads `window` only when called (inside an effect).
function computeInitialGeom() { function computeInitialGeom() {
@@ -275,39 +282,19 @@ export default function AiChatWindow() {
// shipped; older rows fall back to that turn's `usage` total. NOTE: reflects // shipped; older rows fall back to that turn's `usage` total. NOTE: reflects
// PERSISTED rows (updates on chat open/switch); it does not tick live // PERSISTED rows (updates on chat open/switch); it does not tick live
// mid-stream — acceptable for v1. // mid-stream — acceptable for v1.
const contextTokens = useMemo(() => { //
if (!activeChatId || !messageRows) return 0; // The denominator `maxContextTokens` (the model's configured max window) is
for (let i = messageRows.length - 1; i >= 0; i--) { // derived in the SAME backward scan: it is stamped alongside `contextTokens`
const meta = messageRows[i].metadata; // on a completed turn, but the numerator and denominator are taken from the
if (!meta) continue; // most recent row carrying EACH value independently — they may land on
if (typeof meta.contextTokens === "number" && meta.contextTokens > 0) { // different rows (e.g. a fresh error row can carry contextTokens but not
return meta.contextTokens; // maxContextTokens), so we keep scanning for whichever is still unset. 0 when
} // no row has it (older rows, or no admin-configured limit) — the badge then
const usage = meta.usage; // shows just the current size with no denominator.
if (usage) { const { contextTokens, maxContextTokens } = useMemo(
const fallback = () => selectContextBadge(activeChatId ? messageRows : undefined),
usage.totalTokens ?? [activeChatId, messageRows],
(usage.inputTokens ?? 0) + (usage.outputTokens ?? 0); );
if (fallback > 0) return fallback;
}
}
return 0;
}, [activeChatId, messageRows]);
// The model's context-window size (badge denominator), read from the most
// recent assistant row that carries it. Admin-configured in AI settings and
// stamped onto the turn server-side, so it travels with the message metadata —
// no client-side model resolution, and it survives public shares / per-role
// models automatically. 0 (no limit configured, or older rows) → the badge
// hides the denominator and shows only the current context size.
const maxContextTokens = useMemo(() => {
if (!activeChatId || !messageRows) return 0;
for (let i = messageRows.length - 1; i >= 0; i--) {
const max = messageRows[i].metadata?.maxContextTokens;
if (typeof max === "number" && max > 0) return max;
}
return 0;
}, [activeChatId, messageRows]);
// On (re)open, settle the geometry before paint (useLayoutEffect → no // On (re)open, settle the geometry before paint (useLayoutEffect → no
// first-frame jump): compute an initial top-right placement the first time, // first-frame jump): compute an initial top-right placement the first time,
@@ -498,14 +485,20 @@ export default function AiChatWindow() {
)} )}
<div style={{ flex: 1, display: "flex", justifyContent: "center" }}> <div style={{ flex: 1, display: "flex", justifyContent: "center" }}>
{/* Context badge: always "current / max" context size (or just current {/* Always show the persisted "current / max" context. The denominator
when no model limit is configured). It no longer flips to a live (the admin-configured model limit) is appended only when known;
per-turn generation counter mid-stream — that live feedback lives in not clamped when current > max (shown as-is, e.g. "210k / 200k").
the chat body's "Thinking · N tokens" block. */} Hidden entirely until a turn has recorded a context figure. */}
<ContextBadge {contextTokens > 0 ? (
contextTokens={contextTokens} <Tooltip label={t("Context size / model limit")} withArrow>
maxContextTokens={maxContextTokens} <span className={classes.badge}>
/> {formatTokens(contextTokens)}
{maxContextTokens > 0
? ` / ${formatTokens(maxContextTokens)}`
: ""}
</span>
</Tooltip>
) : null}
</div> </div>
<div style={{ display: "flex", alignItems: "center", gap: 1 }}> <div style={{ display: "flex", alignItems: "center", gap: 1 }}>

View File

@@ -1,69 +0,0 @@
import { describe, it, expect } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { MantineProvider } from "@mantine/core";
import { ContextBadge, formatTokens } from "./context-badge";
// matchMedia (read by MantineProvider) is stubbed globally in vitest.setup.ts.
// Without an I18nextProvider, `t(key)` returns the key verbatim, so tooltip
// labels assert against their English source strings.
function renderBadge(props: {
contextTokens: number;
maxContextTokens?: number;
}) {
return render(
<MantineProvider>
<ContextBadge {...props} />
</MantineProvider>,
);
}
describe("formatTokens", () => {
it("formats with k / M suffixes", () => {
expect(formatTokens(572)).toBe("572");
expect(formatTokens(200_000)).toBe("200.0k");
expect(formatTokens(1_500_000)).toBe("1.5M");
});
});
describe("ContextBadge", () => {
it("shows `current / max` when a limit is configured", () => {
renderBadge({ contextTokens: 572, maxContextTokens: 200_000 });
expect(screen.getByText("572 / 200.0k")).toBeDefined();
});
it("shows only the current size when no limit is configured", () => {
renderBadge({ contextTokens: 572, maxContextTokens: 0 });
expect(screen.getByText("572")).toBeDefined();
// No denominator rendered.
expect(screen.queryByText(/\//)).toBeNull();
});
it("treats an undefined limit as no limit", () => {
renderBadge({ contextTokens: 1234 });
expect(screen.getByText("1.2k")).toBeDefined();
expect(screen.queryByText(/\//)).toBeNull();
});
it("renders nothing until there is a current context size", () => {
const { container } = renderBadge({
contextTokens: 0,
maxContextTokens: 200_000,
});
expect(container.querySelector("span")).toBeNull();
});
it("never flips to a live per-turn counter (no live mode); shows context as-is even above max", () => {
// `current > max` (estimate drift / smaller-model role) is shown unclamped.
renderBadge({ contextTokens: 210_000, maxContextTokens: 200_000 });
expect(screen.getByText("210.0k / 200.0k")).toBeDefined();
});
it("exposes the limit tooltip label on hover", async () => {
renderBadge({ contextTokens: 572, maxContextTokens: 200_000 });
fireEvent.mouseEnter(screen.getByText("572 / 200.0k"));
expect(
await screen.findByText("Context size / model limit"),
).toBeDefined();
});
});

View File

@@ -1,61 +0,0 @@
import { Tooltip } from "@mantine/core";
import { useTranslation } from "react-i18next";
import classes from "@/features/ai-chat/components/ai-chat-window.module.css";
/** Compact token formatter: 1.2M / 3.4k / 950. */
export function formatTokens(n: number): string {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`;
return String(n);
}
interface ContextBadgeProps {
// Current context size for the active chat (tokens occupied in the model's
// window). 0 = unknown → nothing is rendered.
contextTokens: number;
// The model's context-window size (tokens), from AI settings. 0/undefined =
// no limit known → only the current size is shown (no denominator).
maxContextTokens?: number;
}
/**
* Header badge that ALWAYS shows the current context size, and — when the model's
* context-window size is configured — appends "/ max" so the badge reads
* "current / max" (e.g. `572 / 200k`). This is a single, stable meaning: unlike
* the previous design it never flips to a live per-turn generation counter while
* streaming (that live feedback lives in the chat body's "Thinking · N tokens").
*
* No limit configured (or older history rows without it) → the denominator is
* hidden and the badge shows the current size only, matching the prior at-rest
* behaviour. `context > max` (estimate drift, or a role on a smaller model) is
* shown as-is, without clamping.
*/
export function ContextBadge({
contextTokens,
maxContextTokens,
}: ContextBadgeProps) {
const { t } = useTranslation();
// Nothing to show until the first persisted context figure exists.
if (!(contextTokens > 0)) return null;
const hasMax = typeof maxContextTokens === "number" && maxContextTokens > 0;
const label = hasMax
? `${formatTokens(contextTokens)} / ${formatTokens(maxContextTokens)}`
: formatTokens(contextTokens);
return (
<Tooltip
label={
hasMax
? t("Context size / model limit")
: t("Current context size")
}
withArrow
>
<span className={classes.badge}>{label}</span>
</Tooltip>
);
}
export default ContextBadge;

View File

@@ -113,13 +113,11 @@ export interface IAiChatMessageRow {
}; };
// Current context size for the turn = final-step (input+output) tokens, i.e. // Current context size for the turn = final-step (input+output) tokens, i.e.
// how much the conversation occupies in the model's context window after this // how much the conversation occupies in the model's context window after this
// turn. Distinct from `usage` (legacy cumulative totalUsage). Shown as the // turn. Distinct from `usage` (legacy cumulative totalUsage). Shown in the
// numerator of the floating window's "current / max" header badge. // floating window's header badge.
contextTokens?: number; contextTokens?: number;
// The model's context-window size (tokens), admin-configured in AI settings // The model's max context window (denominator for the header badge); set
// and stamped onto the turn server-side. The denominator of the header badge. // alongside contextTokens on a completed turn; absent on older rows.
// Absent/0 (older rows, or no limit configured) → the badge hides the
// denominator and shows only the current context size (`contextTokens`).
maxContextTokens?: number; maxContextTokens?: number;
// Set on an assistant row whose turn ended in a provider/stream error; the // Set on an assistant row whose turn ended in a provider/stream error; the
// raw provider error text (e.g. "402: ...") for inline display in the thread. // raw provider error text (e.g. "402: ...") for inline display in the thread.

View File

@@ -0,0 +1,90 @@
import { describe, expect, it } from "vitest";
import type { IAiChatMessageRow } from "@/features/ai-chat/types/ai-chat.types.ts";
import { selectContextBadge } from "@/features/ai-chat/utils/context-badge.ts";
/**
* Pure-helper tests for the header context badge selection. Covers the two
* non-obvious rules: numerator and denominator are each taken from the most
* recent row carrying THAT value (they may live on different rows), and a fresh
* row with a zero/absent value must NOT shadow an older positive one.
*/
const row = (metadata: IAiChatMessageRow["metadata"]): IAiChatMessageRow => ({
id: Math.random().toString(),
role: "assistant",
content: null,
metadata,
createdAt: "2026-01-01T00:00:00.000Z",
});
describe("selectContextBadge", () => {
it("returns zeros for empty / nullish input", () => {
expect(selectContextBadge(undefined)).toEqual({
contextTokens: 0,
maxContextTokens: 0,
});
expect(selectContextBadge(null)).toEqual({
contextTokens: 0,
maxContextTokens: 0,
});
expect(selectContextBadge([])).toEqual({
contextTokens: 0,
maxContextTokens: 0,
});
});
it("reads both figures from the most recent row that carries them", () => {
expect(
selectContextBadge([
row({ contextTokens: 100, maxContextTokens: 200000 }),
row({ contextTokens: 1500, maxContextTokens: 200000 }),
]),
).toEqual({ contextTokens: 1500, maxContextTokens: 200000 });
});
it("falls back to legacy usage total for older rows without contextTokens", () => {
expect(
selectContextBadge([
row({ usage: { inputTokens: 30, outputTokens: 70 } }),
]),
).toEqual({ contextTokens: 100, maxContextTokens: 0 });
expect(
selectContextBadge([row({ usage: { totalTokens: 250 } })]),
).toEqual({ contextTokens: 250, maxContextTokens: 0 });
});
it("takes numerator and denominator from different rows", () => {
// Freshest row (an error turn) carries contextTokens but no max; the older
// completed turn carries the max. Each is picked from its own latest row.
expect(
selectContextBadge([
row({ contextTokens: 800, maxContextTokens: 200000 }),
row({ contextTokens: 1200, error: "402: nope" }),
]),
).toEqual({ contextTokens: 1200, maxContextTokens: 200000 });
});
it("does not let a fresh zero/absent max shadow an older positive max", () => {
expect(
selectContextBadge([
row({ contextTokens: 100, maxContextTokens: 200000 }),
row({ contextTokens: 1200, maxContextTokens: 0 }),
]),
).toEqual({ contextTokens: 1200, maxContextTokens: 200000 });
});
it("skips rows with null metadata", () => {
expect(
selectContextBadge([
row({ contextTokens: 500, maxContextTokens: 200000 }),
row(null),
]),
).toEqual({ contextTokens: 500, maxContextTokens: 200000 });
});
it("reports current > max as-is (no clamp)", () => {
expect(
selectContextBadge([row({ contextTokens: 250000, maxContextTokens: 200000 })]),
).toEqual({ contextTokens: 250000, maxContextTokens: 200000 });
});
});

View File

@@ -0,0 +1,49 @@
import type { IAiChatMessageRow } from "@/features/ai-chat/types/ai-chat.types.ts";
/**
* Derive the header context badge figures from the persisted message rows.
*
* - `contextTokens` (numerator): how much the conversation now occupies in the
* model's context window. Read from the most recent row carrying a context
* figure — `contextTokens` (final-step input+output) on rows recorded after
* this shipped, else that turn's legacy `usage` total for older rows.
* - `maxContextTokens` (denominator): the model's configured max window, stamped
* alongside `contextTokens` on a completed turn.
*
* Each value is taken from the most recent row carrying THAT value
* independently — they may land on different rows (e.g. a fresh error row can
* carry `contextTokens` but not `maxContextTokens`), so the scan continues for
* whichever is still unset. `0` means "no row has it" (older rows, or no
* admin-configured limit); the badge then omits the value.
*/
export function selectContextBadge(
messageRows: readonly IAiChatMessageRow[] | undefined | null,
): { contextTokens: number; maxContextTokens: number } {
let contextTokens = 0;
let maxContextTokens = 0;
if (!messageRows) return { contextTokens, maxContextTokens };
for (let i = messageRows.length - 1; i >= 0; i--) {
const meta = messageRows[i].metadata;
if (!meta) continue;
if (contextTokens === 0) {
if (typeof meta.contextTokens === "number" && meta.contextTokens > 0) {
contextTokens = meta.contextTokens;
} else if (meta.usage) {
const usage = meta.usage;
const fallback =
usage.totalTokens ??
(usage.inputTokens ?? 0) + (usage.outputTokens ?? 0);
if (fallback > 0) contextTokens = fallback;
}
}
if (
maxContextTokens === 0 &&
typeof meta.maxContextTokens === "number" &&
meta.maxContextTokens > 0
) {
maxContextTokens = meta.maxContextTokens;
}
if (contextTokens !== 0 && maxContextTokens !== 0) break;
}
return { contextTokens, maxContextTokens };
}

View File

@@ -1,16 +1,11 @@
/** /**
* Live token ESTIMATION for a streaming AI-chat turn. * Rough client-side token estimation for AI-chat UI affordances.
* *
* No provider streams exact per-token usage mid-stream, so the live number is a * No provider streams exact per-token usage mid-stream, so any in-flight figure
* CLIENT ESTIMATE (chars/≈4 heuristic). It powers the chat body's * is a CLIENT ESTIMATE (chars/≈4 heuristic). Pure + unit-testable: it never runs
* `Thinking… · N tokens` indicator (see `ReasoningBlock`), which reconciles to * a real BPE tokenizer (that would be O(n²) on the hot path, bloat the bundle,
* the authoritative server usage once it lands. Pure + unit-testable: it never * and be wrong for Gemini/Ollama anyway). Used by the in-body reasoning counter
* runs a real BPE tokenizer (that would be O(n²) on the hot path, bloat the * ("Thinking · N tokens").
* bundle, and be wrong for Gemini/Ollama anyway).
*
* The former header-badge `liveTurnTokens()` split was removed with #189 (the
* header badge now shows the stable "current / max" context size, not a live
* per-turn counter); the live feedback remains in `ReasoningBlock`.
*/ */
/** /**

View File

@@ -0,0 +1,87 @@
import { describe, expect, it } from "vitest";
import { mcpTestButtonView } from "./ai-mcp-server-test-view";
/**
* Pure-helper tests for the inline "Test" button presentation. Covers the four
* states (idle / loading is handled by the component's `isPending`, so here:
* idle / ok-with-tools / ok-without-tools / failed) and the tooltip text
* branches that are easiest to break silently.
*/
// Identity-ish translator that echoes the key and interpolates {{n}} so the
// label/tooltip branches are observable without the real i18n bundle.
const t = (key: string, options?: Record<string, unknown>): string =>
options && "n" in options
? key.replace("{{n}}", String((options as { n: unknown }).n))
: key;
describe("mcpTestButtonView", () => {
it("idle when there is no result", () => {
expect(mcpTestButtonView(undefined, t)).toEqual({
state: "idle",
color: undefined,
variant: "default",
label: "Test",
tooltip: "",
});
});
it("ok with tools lists them in the tooltip", () => {
expect(mcpTestButtonView({ ok: true, tools: ["a", "b"] }, t)).toEqual({
state: "ok",
color: "green",
variant: "light",
label: "OK · 2",
tooltip: "a, b",
});
});
it('ok with zero tools shows "No tools available"', () => {
expect(mcpTestButtonView({ ok: true, tools: [] }, t)).toEqual({
state: "ok",
color: "green",
variant: "light",
label: "OK · 0",
tooltip: "No tools available",
});
});
it("failed surfaces the error text in the tooltip", () => {
expect(
mcpTestButtonView({ ok: false, error: "402: nope" }, t),
).toEqual({
state: "failed",
color: "red",
variant: "light",
label: "Failed",
tooltip: "402: nope",
});
});
it("failed when the request itself rejects (no result payload)", () => {
// 401/403/500/network: there is no { ok } body, only a thrown error. The
// row must still show a red "Failed" rather than reverting to idle "Test".
expect(
mcpTestButtonView(undefined, t, {
response: { data: { message: "Unauthorized" } },
}),
).toEqual({
state: "failed",
color: "red",
variant: "light",
label: "Failed",
tooltip: "Unauthorized",
});
});
it("reject without a server message falls back to the generic label", () => {
// A bare network error (no response body) still surfaces as failed, using
// the i18n fallback for the tooltip.
expect(mcpTestButtonView(undefined, t, new Error("network down"))).toEqual({
state: "failed",
color: "red",
variant: "light",
label: "Failed",
tooltip: "Failed to update data",
});
});
});

View File

@@ -0,0 +1,90 @@
import type { IAiMcpServerTestResult } from "@/features/workspace/services/ai-mcp-server-service.ts";
/** Minimal translator shape (i18next `t`): key + optional interpolation. */
type Translate = (key: string, options?: Record<string, unknown>) => string;
/** Subset of an axios-style rejection we read for the reject tooltip. */
type McpTestRequestError = {
response?: { data?: { message?: string } };
};
/**
* Best-effort extraction of a server-sent message from a rejected test request
* (axios stores it at `error.response.data.message`). Returns undefined for a
* bare/network error so the caller can fall back to a generic label.
*/
function readRequestErrorMessage(error: unknown): string | undefined {
if (error && typeof error === "object" && "response" in error) {
return (error as McpTestRequestError).response?.data?.message;
}
return undefined;
}
/**
* Presentation for the inline "Test" button, derived from the current test
* result tristate (no result yet / ok / failed). Color is never the only signal
* — the label and icon change too (a11y / colorblind-friendly). Kept as a single
* pure derivation (rather than two parallel if/else chains) so the button and
* tooltip can never drift apart, and so the text branches are unit-testable
* without rendering the row.
*/
export interface McpTestButtonView {
/** Tristate; the component maps this to the leftSection icon. */
state: "idle" | "ok" | "failed";
/** Mantine Button color; undefined = theme default (idle). */
color?: string;
/** Mantine Button variant. */
variant: string;
/** Translated button label. */
label: string;
/** Translated tooltip text; "" while there is no result (tooltip disabled). */
tooltip: string;
}
export function mcpTestButtonView(
result: IAiMcpServerTestResult | undefined,
t: Translate,
error?: unknown,
): McpTestButtonView {
if (result?.ok) {
return {
state: "ok",
color: "green",
variant: "light",
label: t("OK · {{n}}", { n: result.tools.length }),
tooltip:
result.tools.length > 0
? result.tools.join(", ")
: t("No tools available"),
};
}
if (result && result.ok === false) {
return {
state: "failed",
color: "red",
variant: "light",
label: t("Failed"),
tooltip: result.error,
};
}
if (error) {
// The test request itself rejected (401/403/500/network) — there is no
// `{ ok }` payload, so without this branch the row would silently revert to
// the idle "Test" instead of reporting the failure. Tooltip prefers the
// server-sent message, else the generic i18n fallback.
return {
state: "failed",
color: "red",
variant: "light",
label: t("Failed"),
tooltip: readRequestErrorMessage(error) ?? t("Failed to update data"),
};
}
return {
state: "idle",
color: undefined,
variant: "default",
label: t("Test"),
tooltip: "",
};
}

View File

@@ -1,4 +1,4 @@
import { useState } from "react"; import { useEffect, useState } from "react";
import { import {
ActionIcon, ActionIcon,
Badge, Badge,
@@ -10,18 +10,28 @@ import {
Stack, Stack,
Switch, Switch,
Text, Text,
Tooltip,
} from "@mantine/core"; } from "@mantine/core";
import { useDisclosure } from "@mantine/hooks"; import { useDisclosure } from "@mantine/hooks";
import { modals } from "@mantine/modals"; import { modals } from "@mantine/modals";
import { IconPencil, IconPlus, IconTrash } from "@tabler/icons-react"; import {
IconCheck,
IconPencil,
IconPlugConnected,
IconPlus,
IconTrash,
IconX,
} from "@tabler/icons-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import useUserRole from "@/hooks/use-user-role.tsx"; import useUserRole from "@/hooks/use-user-role.tsx";
import { import {
useAiMcpServersQuery, useAiMcpServersQuery,
useDeleteAiMcpServerMutation, useDeleteAiMcpServerMutation,
useTestAiMcpServerMutation,
useUpdateAiMcpServerMutation, useUpdateAiMcpServerMutation,
} from "@/features/workspace/queries/ai-mcp-server-query.ts"; } from "@/features/workspace/queries/ai-mcp-server-query.ts";
import { IAiMcpServer } from "@/features/workspace/services/ai-mcp-server-service.ts"; import { IAiMcpServer } from "@/features/workspace/services/ai-mcp-server-service.ts";
import { mcpTestButtonView } from "@/features/workspace/components/settings/components/ai-mcp-server-test-view.ts";
import AiMcpServerForm from "./ai-mcp-server-form.tsx"; import AiMcpServerForm from "./ai-mcp-server-form.tsx";
/** /**
@@ -112,55 +122,15 @@ export default function AiMcpServers() {
<Stack gap="xs" mt="sm"> <Stack gap="xs" mt="sm">
{servers?.map((server) => ( {servers?.map((server) => (
<Group key={server.id} justify="space-between" wrap="nowrap"> <AiMcpServerRow
<Stack gap={2} style={{ minWidth: 0 }}> key={server.id}
<Group gap="xs"> server={server}
<Text fw={500} truncate> onEdit={openEdit}
{server.name} onDelete={confirmDelete}
</Text> onToggleEnabled={(enabled) =>
<Badge size="xs" variant="light"> updateMutation.mutate({ id: server.id, enabled })
{server.transport.toUpperCase()} }
</Badge> />
</Group>
<Text
size="xs"
c="dimmed"
truncate
style={{ fontFamily: "ui-monospace, Menlo, monospace" }}
>
{server.url}
</Text>
</Stack>
<Group gap="xs" wrap="nowrap">
<Switch
size="sm"
checked={server.enabled}
aria-label={t("Enabled")}
onChange={(event) =>
updateMutation.mutate({
id: server.id,
enabled: event.currentTarget.checked,
})
}
/>
<ActionIcon
variant="subtle"
aria-label={t("Edit")}
onClick={() => openEdit(server)}
>
<IconPencil size={16} />
</ActionIcon>
<ActionIcon
variant="subtle"
color="red"
aria-label={t("Delete")}
onClick={() => confirmDelete(server)}
>
<IconTrash size={16} />
</ActionIcon>
</Group>
</Group>
))} ))}
</Stack> </Stack>
@@ -180,3 +150,127 @@ export default function AiMcpServers() {
</Paper> </Paper>
); );
} }
interface AiMcpServerRowProps {
server: IAiMcpServer;
onEdit: (server: IAiMcpServer) => void;
onDelete: (server: IAiMcpServer) => void;
onToggleEnabled: (enabled: boolean) => void;
}
/**
* A single external MCP server row: name/badge/url on the left and the
* Test / Switch / Edit / Delete controls on the right. Each row owns its own
* `useTestAiMcpServerMutation()` so the inline Test result and loading state are
* independent per row (a shared mutation would make `isPending` global and make
* every row flicker).
*/
function AiMcpServerRow({
server,
onEdit,
onDelete,
onToggleEnabled,
}: AiMcpServerRowProps) {
const { t } = useTranslation();
const testMutation = useTestAiMcpServerMutation();
const result = testMutation.data;
// The row is keyed by `server.id`, so editing the connection-relevant fields
// (url/transport/headers) does NOT remount it — an old success/failure result
// would otherwise stick. Clear the result when those fields change.
useEffect(() => {
testMutation.reset();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [server.url, server.transport, server.hasHeaders]);
// Single derivation of the button/tooltip presentation from the test tristate
// (idle / ok / failed), so the two can never drift apart. Tooltip is "" while
// there is no result; the icon is mapped from `view.state` below. When the
// request itself rejects (401/403/500/network) there is no `data` payload, so
// we feed the mutation error in too — otherwise the row would silently revert
// to "Test" instead of showing a red "Failed".
const view = mcpTestButtonView(
result,
t,
testMutation.isError ? testMutation.error : undefined,
);
const tooltipLabel = view.tooltip;
const buttonColor = view.color;
const buttonVariant = view.variant;
const buttonLabel = view.label;
const buttonIcon =
view.state === "ok" ? (
<IconCheck size={16} />
) : view.state === "failed" ? (
<IconX size={16} />
) : (
<IconPlugConnected size={16} />
);
return (
<Group justify="space-between" wrap="nowrap">
<Stack gap={2} style={{ minWidth: 0 }}>
<Group gap="xs">
<Text fw={500} truncate>
{server.name}
</Text>
<Badge size="xs" variant="light">
{server.transport.toUpperCase()}
</Badge>
</Group>
<Text
size="xs"
c="dimmed"
truncate
style={{ fontFamily: "ui-monospace, Menlo, monospace" }}
>
{server.url}
</Text>
</Stack>
<Group gap="xs" wrap="nowrap">
{/* Always clickable: testing a disabled server before enabling it is useful. */}
<Tooltip
label={tooltipLabel}
disabled={view.state === "idle"}
multiline
maw={320}
withinPortal
>
<Button
size="xs"
miw={88}
color={buttonColor}
variant={buttonVariant}
leftSection={testMutation.isPending ? undefined : buttonIcon}
loading={testMutation.isPending}
onClick={() => testMutation.mutate(server.id)}
>
{buttonLabel}
</Button>
</Tooltip>
<Switch
size="sm"
checked={server.enabled}
aria-label={t("Enabled")}
onChange={(event) => onToggleEnabled(event.currentTarget.checked)}
/>
<ActionIcon
variant="subtle"
aria-label={t("Edit")}
onClick={() => onEdit(server)}
>
<IconPencil size={16} />
</ActionIcon>
<ActionIcon
variant="subtle"
color="red"
aria-label={t("Delete")}
onClick={() => onDelete(server)}
>
<IconTrash size={16} />
</ActionIcon>
</Group>
</Group>
);
}

View File

@@ -84,11 +84,11 @@ const STT_LANGUAGE_OPTIONS: { value: string; label: string }[] = [
// (empty means "leave unchanged" unless explicitly cleared). // (empty means "leave unchanged" unless explicitly cleared).
const formSchema = z.object({ const formSchema = z.object({
chatModel: z.string(), chatModel: z.string(),
// Max context window in tokens shown in the chat header badge. A number, or ""
// when the NumberInput is empty (no limit).
chatContextWindow: z.union([z.number(), z.literal("")]),
// Chat provider implementation (reasoning surfacing). Default openai-compatible. // Chat provider implementation (reasoning surfacing). Default openai-compatible.
chatApiStyle: z.enum(["openai-compatible", "openai"]), chatApiStyle: z.enum(["openai-compatible", "openai"]),
// Model context-window size (tokens) shown as the chat header badge's "max".
// Empty string = no limit (NumberInput emits "" when cleared).
chatContextWindow: z.union([z.number(), z.literal("")]),
// Cheap model id for the anonymous public-share assistant; empty = use chatModel. // Cheap model id for the anonymous public-share assistant; empty = use chatModel.
publicShareChatModel: z.string(), publicShareChatModel: z.string(),
// Agent-role id whose persona the public-share assistant adopts; empty = // Agent-role id whose persona the public-share assistant adopts; empty =
@@ -315,8 +315,8 @@ export default function AiProviderSettings() {
validate: zod4Resolver(formSchema), validate: zod4Resolver(formSchema),
initialValues: { initialValues: {
chatModel: "", chatModel: "",
chatContextWindow: "",
chatApiStyle: "openai-compatible" as ChatApiStyle, chatApiStyle: "openai-compatible" as ChatApiStyle,
chatContextWindow: "" as number | "",
publicShareChatModel: "", publicShareChatModel: "",
publicShareAssistantRoleId: "", publicShareAssistantRoleId: "",
embeddingModel: "", embeddingModel: "",
@@ -339,11 +339,8 @@ export default function AiProviderSettings() {
if (!settings) return; if (!settings) return;
form.setValues({ form.setValues({
chatModel: settings.chatModel ?? "", chatModel: settings.chatModel ?? "",
chatContextWindow: settings.chatContextWindow ?? "",
chatApiStyle: settings.chatApiStyle ?? "openai-compatible", chatApiStyle: settings.chatApiStyle ?? "openai-compatible",
// 0/unset = no limit → show an empty field (not a literal "0").
chatContextWindow: settings.chatContextWindow
? settings.chatContextWindow
: "",
publicShareChatModel: settings.publicShareChatModel ?? "", publicShareChatModel: settings.publicShareChatModel ?? "",
publicShareAssistantRoleId: settings.publicShareAssistantRoleId ?? "", publicShareAssistantRoleId: settings.publicShareAssistantRoleId ?? "",
embeddingModel: settings.embeddingModel ?? "", embeddingModel: settings.embeddingModel ?? "",
@@ -373,12 +370,13 @@ export default function AiProviderSettings() {
// Everything is OpenAI-compatible. // Everything is OpenAI-compatible.
driver: "openai", driver: "openai",
chatModel: values.chatModel, chatModel: values.chatModel,
chatApiStyle: values.chatApiStyle, // Max context window for the chat header badge; empty NumberInput ("") →
// Empty → 0, which clears the limit server-side (badge shows current only). // 0, which clears the limit server-side (no denominator shown).
chatContextWindow: chatContextWindow:
typeof values.chatContextWindow === "number" typeof values.chatContextWindow === "number"
? values.chatContextWindow ? values.chatContextWindow
: 0, : 0,
chatApiStyle: values.chatApiStyle,
// Cheap model id for the anonymous public-share assistant; empty falls // Cheap model id for the anonymous public-share assistant; empty falls
// back to chatModel server-side. // back to chatModel server-side.
publicShareChatModel: values.publicShareChatModel, publicShareChatModel: values.publicShareChatModel,
@@ -781,6 +779,18 @@ export default function AiProviderSettings() {
{t("Resolves to {{url}}", { url: chatResolved })} {t("Resolves to {{url}}", { url: chatResolved })}
</Text> </Text>
<NumberInput
mt="sm"
label={t("Context window (tokens)")}
description={t(
"Shown as used / total in the chat header. Leave empty to hide the limit.",
)}
min={0}
allowDecimal={false}
disabled={isLoading}
{...form.getInputProps("chatContextWindow")}
/>
<Select <Select
mt="sm" mt="sm"
label={t("Protocol")} label={t("Protocol")}
@@ -799,22 +809,6 @@ export default function AiProviderSettings() {
{...form.getInputProps("chatApiStyle")} {...form.getInputProps("chatApiStyle")}
/> />
<NumberInput
mt="sm"
label={t("Context window (tokens)")}
description={t(
"Shows used / total in the chat header badge; empty hides the total.",
)}
placeholder={t("e.g. 200000")}
min={0}
step={1000}
allowDecimal={false}
allowNegative={false}
thousandSeparator=" "
disabled={isLoading}
{...form.getInputProps("chatContextWindow")}
/>
{/* Anonymous public-share assistant: a single master toggle + an {/* Anonymous public-share assistant: a single master toggle + an
optional cheaper model id. Reuses this card's driver/URL/key. */} optional cheaper model id. Reuses this card's driver/URL/key. */}
<Group justify="space-between" align="center" wrap="nowrap" mt="md"> <Group justify="space-between" align="center" wrap="nowrap" mt="md">

View File

@@ -22,10 +22,9 @@ export type ChatApiStyle = "openai-compatible" | "openai";
export interface IAiSettings { export interface IAiSettings {
driver?: AiDriver; driver?: AiDriver;
chatModel?: string; chatModel?: string;
chatApiStyle?: ChatApiStyle; // Max context window in tokens shown in the chat header badge; 0/unset = no limit.
// Chat model context-window size (tokens); shown as the "max" in the chat
// header context badge. 0/unset = no limit (badge shows the current size only).
chatContextWindow?: number; chatContextWindow?: number;
chatApiStyle?: ChatApiStyle;
// Cheap model id for the anonymous public-share assistant; empty = chatModel. // Cheap model id for the anonymous public-share assistant; empty = chatModel.
publicShareChatModel?: string; publicShareChatModel?: string;
// Agent-role id whose persona the public-share assistant adopts; empty = // Agent-role id whose persona the public-share assistant adopts; empty =
@@ -59,9 +58,9 @@ export interface IAiSettings {
export interface IAiSettingsUpdate { export interface IAiSettingsUpdate {
driver?: AiDriver; driver?: AiDriver;
chatModel?: string; chatModel?: string;
chatApiStyle?: ChatApiStyle; // Max context window in tokens for the chat header badge; 0 = clear the limit.
// Chat model context-window size (tokens); 0 clears the limit.
chatContextWindow?: number; chatContextWindow?: number;
chatApiStyle?: ChatApiStyle;
publicShareChatModel?: string; publicShareChatModel?: string;
// Agent-role id whose persona the public-share assistant adopts; empty = // Agent-role id whose persona the public-share assistant adopts; empty =
// built-in locked persona. // built-in locked persona.

View File

@@ -275,11 +275,12 @@ describe('flushAssistant', () => {
expect(f.toolCalls).not.toBeNull(); expect(f.toolCalls).not.toBeNull();
}); });
it('completed: attaches finishReason + normalized usage + contextTokens', () => { it('completed: attaches finishReason + normalized usage + contextTokens + maxContextTokens', () => {
const f = flushAssistant([toolStep], '', 'completed', { const f = flushAssistant([toolStep], '', 'completed', {
finishReason: 'stop', finishReason: 'stop',
usage: { inputTokens: 10, outputTokens: 5, totalTokens: 15 }, usage: { inputTokens: 10, outputTokens: 5, totalTokens: 15 },
contextTokens: 15, contextTokens: 15,
maxContextTokens: 200000,
}); });
expect(f.status).toBe('completed'); expect(f.status).toBe('completed');
expect(f.metadata.finishReason).toBe('stop'); expect(f.metadata.finishReason).toBe('stop');
@@ -290,26 +291,23 @@ describe('flushAssistant', () => {
reasoningTokens: undefined, reasoningTokens: undefined,
}); });
expect(f.metadata.contextTokens).toBe(15); expect(f.metadata.contextTokens).toBe(15);
expect(f.metadata.maxContextTokens).toBe(200000);
}); });
it('completed: writes maxContextTokens when the model limit is > 0', () => { it('completed: omits maxContextTokens when unset or 0', () => {
// No maxContextTokens in the extra (admin set no context window).
const f = flushAssistant([toolStep], '', 'completed', { const f = flushAssistant([toolStep], '', 'completed', {
contextTokens: 15, finishReason: 'stop',
maxContextTokens: 200_000,
});
expect(f.metadata.maxContextTokens).toBe(200_000);
});
it('omits maxContextTokens when the limit is unset or 0', () => {
const unset = flushAssistant([toolStep], '', 'completed', {
contextTokens: 15, contextTokens: 15,
}); });
expect('maxContextTokens' in unset.metadata).toBe(false); expect('maxContextTokens' in f.metadata).toBe(false);
const zero = flushAssistant([toolStep], '', 'completed', { // Explicit 0 is treated the same as unset (no limit -> key omitted).
const f0 = flushAssistant([toolStep], '', 'completed', {
finishReason: 'stop',
contextTokens: 15, contextTokens: 15,
maxContextTokens: 0, maxContextTokens: 0,
}); });
expect('maxContextTokens' in zero.metadata).toBe(false); expect('maxContextTokens' in f0.metadata).toBe(false);
}); });
it('error: records the error and a derived finishReason', () => { it('error: records the error and a derived finishReason', () => {

View File

@@ -616,8 +616,9 @@ export class AiChatService implements OnModuleInit {
contextTokens: contextTokens:
(usage?.inputTokens ?? 0) + (usage?.outputTokens ?? 0) || (usage?.inputTokens ?? 0) + (usage?.outputTokens ?? 0) ||
undefined, undefined,
// Admin-configured context-window size for this model (badge max). // Max context window for the chat header badge denominator;
// Resolved once per turn above; written to metadata only when > 0. // resolved from the admin-configured provider settings (in
// closure scope here). Omitted/0 = no limit.
maxContextTokens: resolved?.chatContextWindow, maxContextTokens: resolved?.chatContextWindow,
}), }),
); );
@@ -1215,8 +1216,9 @@ export async function applyFinalize(
* `metadata.parts` is built by assistantParts over the finished steps, then the * `metadata.parts` is built by assistantParts over the finished steps, then the
* in-progress text appended as a trailing text part, so rowToUiMessage / * in-progress text appended as a trailing text part, so rowToUiMessage /
* findRecent keep replaying the turn unchanged. `metadata.finishReason`, * findRecent keep replaying the turn unchanged. `metadata.finishReason`,
* `metadata.error`, `metadata.usage` and `metadata.contextTokens` are attached * `metadata.error`, `metadata.usage`, `metadata.contextTokens` and
* only when provided/relevant, matching the pre-#183 onFinish/onError records. * `metadata.maxContextTokens` are attached only when provided/relevant, matching
* the pre-#183 onFinish/onError records.
*/ */
export function flushAssistant( export function flushAssistant(
capturedSteps: ReadonlyArray<StepLike> | undefined, capturedSteps: ReadonlyArray<StepLike> | undefined,
@@ -1226,9 +1228,6 @@ export function flushAssistant(
finishReason?: string; finishReason?: string;
usage?: ChatStreamUsage | StreamUsage | undefined; usage?: ChatStreamUsage | StreamUsage | undefined;
contextTokens?: number; contextTokens?: number;
// Admin-configured context-window size (tokens) for this turn's model; the
// denominator of the client's "current / max" header badge. Written only
// when > 0 (0/unset = no limit known → the badge shows current only).
maxContextTokens?: number; maxContextTokens?: number;
error?: string; error?: string;
}, },
@@ -1260,9 +1259,8 @@ export function flushAssistant(
normalizeStreamUsage(extra.usage as StreamUsage) ?? extra.usage; normalizeStreamUsage(extra.usage as StreamUsage) ?? extra.usage;
} }
if (extra?.contextTokens) metadata.contextTokens = extra.contextTokens; if (extra?.contextTokens) metadata.contextTokens = extra.contextTokens;
if (extra?.maxContextTokens && extra.maxContextTokens > 0) { if (extra?.maxContextTokens)
metadata.maxContextTokens = extra.maxContextTokens; metadata.maxContextTokens = extra.maxContextTokens;
}
if (extra?.error) metadata.error = extra.error; if (extra?.error) metadata.error = extra.error;
return { return {

View File

@@ -20,8 +20,8 @@ import { DB, Workspaces } from '@docmost/db/types/db';
export const AI_PROVIDER_SETTINGS_ALLOWED: readonly string[] = [ export const AI_PROVIDER_SETTINGS_ALLOWED: readonly string[] = [
'driver', 'driver',
'chatModel', 'chatModel',
'chatApiStyle',
'chatContextWindow', 'chatContextWindow',
'chatApiStyle',
'embeddingModel', 'embeddingModel',
'baseUrl', 'baseUrl',
'embeddingBaseUrl', 'embeddingBaseUrl',
@@ -256,17 +256,11 @@ export class WorkspaceRepo {
): Promise<Workspace> { ): Promise<Workspace> {
const db = dbOrTx(this.db, trx); const db = dbOrTx(this.db, trx);
// Assemble the provider object IN SQL. Keys are fixed provider field names // Assemble the provider object IN SQL. Keys are fixed provider field names
// (sql.lit -> inlined literals, no injection); values are bound params with // (sql.lit -> inlined literals, no injection); values are bound params cast
// an explicit cast — postgres.js sends bound params untyped, and // to ::text — postgres.js sends bound params untyped, and jsonb_build_object's
// jsonb_build_object's value args are polymorphic ("any"), so without the // value args are polymorphic ("any"), so without the explicit ::text cast
// cast Postgres throws "could not determine data type of parameter $1". The // Postgres throws "could not determine data type of parameter $1". The result
// cast is branched by the JS runtime type so the value lands in jsonb with // is a real jsonb object, never a double-encoded string. The CASE self-heals
// the matching JSON type: a number stays a JSON number (e.g.
// chatContextWindow → `{"chatContextWindow":200000}`, jsonb_typeof 'number'),
// a boolean a JSON boolean, everything else a JSON string. A plain `::text`
// for all would store a numeric field as the JSON STRING `"200000"`, which
// the client's `typeof === "number"` guards reject. The result is a real
// jsonb object, never a double-encoded string. The CASE self-heals
// workspaces whose settings.ai.provider was previously corrupted into an // workspaces whose settings.ai.provider was previously corrupted into an
// array/string. // array/string.
const entries = Object.entries(provider).filter( const entries = Object.entries(provider).filter(
@@ -274,14 +268,7 @@ export class WorkspaceRepo {
); );
const patch = entries.length const patch = entries.length
? sql`jsonb_build_object(${sql.join( ? sql`jsonb_build_object(${sql.join(
entries.flatMap(([k, v]) => [ entries.flatMap(([k, v]) => [sql.lit(k), sql`${v}::text`]),
sql.lit(k),
typeof v === 'number'
? sql`${v}::numeric`
: typeof v === 'boolean'
? sql`${v}::boolean`
: sql`${v}::text`,
]),
)})` )})`
: sql`'{}'::jsonb`; : sql`'{}'::jsonb`;
return db return db

View File

@@ -42,7 +42,7 @@ describe('UpdateAiSettingsDto.chatApiStyle', () => {
}); });
}); });
/** DTO validation for chatContextWindow (@IsOptional @IsInt @Min(0)). */ /** DTO validation for the new chatContextWindow field (@IsInt @Min(0)). */
describe('UpdateAiSettingsDto.chatContextWindow', () => { describe('UpdateAiSettingsDto.chatContextWindow', () => {
const errorsFor = async (chatContextWindow: unknown) => const errorsFor = async (chatContextWindow: unknown) =>
validate(plainToInstance(UpdateAiSettingsDto, { chatContextWindow })); validate(plainToInstance(UpdateAiSettingsDto, { chatContextWindow }));

View File

@@ -0,0 +1,43 @@
import { parsePositiveInt } from './ai-settings.service';
/**
* Round-trip coercion for numeric `::text` provider settings (e.g.
* chatContextWindow). Values are stored as text and read back as strings, so
* this guards the read path the DTO write-validation does not cover: a silent
* loss of `Math.floor` or a `> 0` → `>= 0` drift would otherwise go unnoticed.
*/
describe('parsePositiveInt', () => {
it('keeps a valid positive integer string', () => {
expect(parsePositiveInt('200000')).toBe(200000);
});
it('floors a fractional string', () => {
expect(parsePositiveInt('1.9')).toBe(1);
expect(parsePositiveInt('1.0')).toBe(1);
});
it('returns undefined for zero', () => {
expect(parsePositiveInt('0')).toBeUndefined();
});
it('returns undefined for a negative value', () => {
expect(parsePositiveInt('-5')).toBeUndefined();
});
it('returns undefined for an empty string', () => {
expect(parsePositiveInt('')).toBeUndefined();
});
it('returns undefined for a non-numeric string', () => {
expect(parsePositiveInt('abc')).toBeUndefined();
});
it('returns undefined for undefined / null', () => {
expect(parsePositiveInt(undefined)).toBeUndefined();
expect(parsePositiveInt(null)).toBeUndefined();
});
it('accepts a real number too (not only ::text strings)', () => {
expect(parsePositiveInt(42)).toBe(42);
});
});

View File

@@ -18,6 +18,18 @@ import {
PROVIDER_SETTINGS_KEYS, PROVIDER_SETTINGS_KEYS,
} from './ai.types'; } from './ai.types';
/**
* Coerce a raw provider value (stored as `::text`, so it arrives as a string —
* see workspace.repo.ts) into a positive integer, or `undefined` when it is not
* a finite number greater than zero. Used for numeric `::text` settings such as
* `chatContextWindow`. Fractions are floored: `"1.9" → 1`, `"0"`/`"-5"`/`""`/
* `"abc"`/`undefined` → `undefined`.
*/
export function parsePositiveInt(raw: unknown): number | undefined {
const n = Number(raw);
return Number.isFinite(n) && n > 0 ? Math.floor(n) : undefined;
}
/** /**
* Shape of the partial update accepted by `update`. Mirrors the validated * Shape of the partial update accepted by `update`. Mirrors the validated
* controller DTO. `apiKey` / `embeddingApiKey` are write-only: undefined = * controller DTO. `apiKey` / `embeddingApiKey` are write-only: undefined =
@@ -26,9 +38,9 @@ import {
export interface UpdateAiSettingsInput { export interface UpdateAiSettingsInput {
driver?: AiDriver; driver?: AiDriver;
chatModel?: string; chatModel?: string;
chatApiStyle?: ChatApiStyle; // Max context window in tokens for the chat header badge. 0/empty = no limit.
// Chat context-window size (tokens); 0/empty clears the limit.
chatContextWindow?: number; chatContextWindow?: number;
chatApiStyle?: ChatApiStyle;
embeddingModel?: string; embeddingModel?: string;
baseUrl?: string; baseUrl?: string;
embeddingBaseUrl?: string; embeddingBaseUrl?: string;
@@ -162,10 +174,11 @@ export class AiSettingsService {
const config: ResolvedAiConfig = { const config: ResolvedAiConfig = {
driver: provider.driver, driver: provider.driver,
chatModel: provider.chatModel, chatModel: provider.chatModel,
// Max context window for the chat header badge denominator. Stored as
// ::text; 0/unset/invalid = no limit (undefined).
chatContextWindow: parsePositiveInt(provider.chatContextWindow),
// Plain passthrough; getChatModel defaults unset to 'openai-compatible'. // Plain passthrough; getChatModel defaults unset to 'openai-compatible'.
chatApiStyle: provider.chatApiStyle, chatApiStyle: provider.chatApiStyle,
// Admin-configured context-window size; 0/unset = no limit (badge denominator).
chatContextWindow: provider.chatContextWindow,
// Cheap model id for the anonymous public-share assistant; reuses the chat // Cheap model id for the anonymous public-share assistant; reuses the chat
// driver/baseUrl/apiKey. Empty/unset → callers fall back to chatModel. // driver/baseUrl/apiKey. Empty/unset → callers fall back to chatModel.
publicShareChatModel: provider.publicShareChatModel, publicShareChatModel: provider.publicShareChatModel,
@@ -223,6 +236,10 @@ export class AiSettingsService {
async getMasked(workspaceId: string): Promise<MaskedAiSettings> { async getMasked(workspaceId: string): Promise<MaskedAiSettings> {
const provider = await this.readProvider(workspaceId); const provider = await this.readProvider(workspaceId);
// Stored as ::text; coerce to a positive integer (or undefined) so the
// client receives a real number.
const chatContextWindow = parsePositiveInt(provider.chatContextWindow);
let hasApiKey = false; let hasApiKey = false;
let hasEmbeddingApiKey = false; let hasEmbeddingApiKey = false;
let hasSttApiKey = false; let hasSttApiKey = false;
@@ -247,8 +264,8 @@ export class AiSettingsService {
return { return {
driver: provider.driver, driver: provider.driver,
chatModel: provider.chatModel, chatModel: provider.chatModel,
chatContextWindow,
chatApiStyle: provider.chatApiStyle, chatApiStyle: provider.chatApiStyle,
chatContextWindow: provider.chatContextWindow,
embeddingModel: provider.embeddingModel, embeddingModel: provider.embeddingModel,
baseUrl: provider.baseUrl, baseUrl: provider.baseUrl,
embeddingBaseUrl: provider.embeddingBaseUrl, embeddingBaseUrl: provider.embeddingBaseUrl,

View File

@@ -32,16 +32,12 @@ export const CHAT_API_STYLES: ChatApiStyle[] = ['openai-compatible', 'openai'];
export interface AiProviderSettings { export interface AiProviderSettings {
driver: AiDriver; driver: AiDriver;
chatModel: string; chatModel: string;
// Max context window in tokens; surfaced to the chat header badge as the
// denominator ("current / max"). 0/unset = no limit (badge shows no denominator).
chatContextWindow?: number;
// Chat provider implementation for the `openai` driver. Unset → defaults to // Chat provider implementation for the `openai` driver. Unset → defaults to
// 'openai-compatible' (so reasoning is surfaced by default). See ChatApiStyle. // 'openai-compatible' (so reasoning is surfaced by default). See ChatApiStyle.
chatApiStyle?: ChatApiStyle; chatApiStyle?: ChatApiStyle;
// Admin-configured chat model context-window size, in tokens. There is no
// provider-independent way to discover this (OpenAI's /v1/models usually omits
// it, Gemini/Ollama/OpenRouter each expose it differently), so it is entered
// manually. Surfaced to the chat client (via assistant message metadata) as the
// denominator of the header "current / max" context badge. Empty/0 = no limit
// known → the badge shows only the current context size.
chatContextWindow?: number;
embeddingModel?: string; embeddingModel?: string;
baseUrl?: string; baseUrl?: string;
// Embedding-specific base URL. Falls back to `baseUrl` when empty/unset. // Embedding-specific base URL. Falls back to `baseUrl` when empty/unset.
@@ -79,8 +75,8 @@ export interface AiProviderSettings {
export const PROVIDER_SETTINGS_KEYS = [ export const PROVIDER_SETTINGS_KEYS = [
'driver', 'driver',
'chatModel', 'chatModel',
'chatApiStyle',
'chatContextWindow', 'chatContextWindow',
'chatApiStyle',
'embeddingModel', 'embeddingModel',
'baseUrl', 'baseUrl',
'embeddingBaseUrl', 'embeddingBaseUrl',
@@ -106,9 +102,8 @@ export const PROVIDER_SETTINGS_KEYS = [
export interface ResolvedAiConfig extends Partial<AiProviderSettings> { export interface ResolvedAiConfig extends Partial<AiProviderSettings> {
driver?: AiDriver; driver?: AiDriver;
chatModel?: string; chatModel?: string;
// Admin-configured chat context-window size (tokens); 0/unset = no limit. Used // Max context window in tokens; surfaced to the chat header badge as the
// as the header context-badge denominator. Re-declared for parity with the // "current / max" denominator. 0/unset = no limit.
// explicit fields above.
chatContextWindow?: number; chatContextWindow?: number;
// Cheap model id for the public-share assistant; reuses the chat creds. // Cheap model id for the public-share assistant; reuses the chat creds.
publicShareChatModel?: string; publicShareChatModel?: string;
@@ -128,9 +123,10 @@ export interface ResolvedAiConfig extends Partial<AiProviderSettings> {
export interface MaskedAiSettings { export interface MaskedAiSettings {
driver?: AiDriver; driver?: AiDriver;
chatModel?: string; chatModel?: string;
chatApiStyle?: ChatApiStyle; // Max context window in tokens; the chat header badge denominator. 0/unset =
// Admin-configured chat context-window size (tokens); 0/unset = no limit. // no limit.
chatContextWindow?: number; chatContextWindow?: number;
chatApiStyle?: ChatApiStyle;
embeddingModel?: string; embeddingModel?: string;
baseUrl?: string; baseUrl?: string;
embeddingBaseUrl?: string; embeddingBaseUrl?: string;

View File

@@ -25,17 +25,17 @@ export class UpdateAiSettingsDto {
@IsString() @IsString()
chatModel?: string; chatModel?: string;
@IsOptional() // Max context window in tokens shown in the chat header badge. 0/empty =
@IsIn(CHAT_API_STYLES) // clear the limit (no denominator shown).
chatApiStyle?: ChatApiStyle;
// Chat model context-window size in tokens (header context-badge denominator).
// 0 (or empty) clears the limit so the badge shows only the current context.
@IsOptional() @IsOptional()
@IsInt() @IsInt()
@Min(0) @Min(0)
chatContextWindow?: number; chatContextWindow?: number;
@IsOptional()
@IsIn(CHAT_API_STYLES)
chatApiStyle?: ChatApiStyle;
@IsOptional() @IsOptional()
@IsString() @IsString()
embeddingModel?: string; embeddingModel?: string;

View File

@@ -1,91 +0,0 @@
import { Kysely, sql } from 'kysely';
import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo';
import { getTestDb, destroyTestDb, createWorkspace } from './db';
/**
* WorkspaceRepo.updateAiProviderSettings numeric round-trip (#189, #213).
*
* `chatContextWindow` is the first NUMERIC provider field routed through this
* generic SQL layer. The patch builder must cast a JS number so it lands in
* jsonb as a JSON NUMBER, not the JSON STRING `"200000"` — the client guards
* (`typeof === "number"`) reject a string, silently killing the `/ max` badge
* denominator. A plain `::text` cast (the prior code) regressed exactly this.
* These specs are real SQL and assert both the JS value type and the on-disk
* `jsonb_typeof`.
*/
describe('WorkspaceRepo.updateAiProviderSettings (numeric round-trip) [integration]', () => {
let db: Kysely<any>;
let repo: WorkspaceRepo;
beforeAll(() => {
db = getTestDb();
repo = new WorkspaceRepo(db as any);
});
afterAll(async () => {
await destroyTestDb();
});
it('stores chatContextWindow as a JSON number (not a "200000" string)', async () => {
const ws = await createWorkspace(db, { settings: undefined });
const updated = await repo.updateAiProviderSettings(ws.id, {
driver: 'openai',
chatModel: 'gpt-4o',
chatContextWindow: 200000,
});
// Returned row: the number survives as a real JS number, alongside the
// string fields which stay strings.
const provider = (updated.settings as any)?.ai?.provider;
expect(provider.chatContextWindow).toBe(200000);
expect(typeof provider.chatContextWindow).toBe('number');
expect(provider.driver).toBe('openai');
expect(provider.chatModel).toBe('gpt-4o');
// On disk: the jsonb value is typed 'number' (the must-fix assertion), and
// sibling string fields are typed 'string'.
const typed = await db
.selectFrom('workspaces')
.select([
sql<string>`jsonb_typeof(settings->'ai'->'provider'->'chatContextWindow')`.as(
'windowType',
),
sql<string>`jsonb_typeof(settings->'ai'->'provider'->'chatModel')`.as(
'modelType',
),
])
.where('id', '=', ws.id)
.executeTakeFirstOrThrow();
expect(typed.windowType).toBe('number');
expect(typed.modelType).toBe('string');
});
it('re-reads chatContextWindow as a number after a partial-merge update', async () => {
const ws = await createWorkspace(db, {
settings: { ai: { provider: { driver: 'openai', chatModel: 'x' } } },
});
// Merge in only the numeric field; siblings must be preserved and the value
// must still be a JSON number, not a string.
await repo.updateAiProviderSettings(ws.id, { chatContextWindow: 128000 });
const row = await db
.selectFrom('workspaces')
.select([
'settings',
sql<string>`jsonb_typeof(settings->'ai'->'provider'->'chatContextWindow')`.as(
'windowType',
),
])
.where('id', '=', ws.id)
.executeTakeFirstOrThrow();
expect(row.windowType).toBe('number');
const provider = (row.settings as any)?.ai?.provider;
expect(provider.chatContextWindow).toBe(128000);
expect(provider.driver).toBe('openai');
expect(provider.chatModel).toBe('x');
});
});