diff --git a/.github/workflows/develop.yml b/.github/workflows/develop.yml index 957def23..f25bac74 100644 --- a/.github/workflows/develop.yml +++ b/.github/workflows/develop.yml @@ -180,7 +180,10 @@ jobs: run: pnpm --filter ./apps/server migration:latest - name: Start server (prod) - run: pnpm --filter ./apps/server start: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: | @@ -194,6 +197,10 @@ jobs: 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 \ diff --git a/apps/client/src/features/ai-chat/components/ai-chat-window.tsx b/apps/client/src/features/ai-chat/components/ai-chat-window.tsx index abd38952..9aa30522 100644 --- a/apps/client/src/features/ai-chat/components/ai-chat-window.tsx +++ b/apps/client/src/features/ai-chat/components/ai-chat-window.tsx @@ -45,6 +45,7 @@ import { shouldCollapseOnOutsidePointer, isHeaderClick, } 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 { notifications } from "@mantine/notifications"; import classes from "@/features/ai-chat/components/ai-chat-window.module.css"; @@ -281,43 +282,19 @@ export default function AiChatWindow() { // 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 // mid-stream — acceptable for v1. - const contextTokens = useMemo(() => { - if (!activeChatId || !messageRows) return 0; - for (let i = messageRows.length - 1; i >= 0; i--) { - const meta = messageRows[i].metadata; - if (!meta) continue; - if (typeof meta.contextTokens === "number" && meta.contextTokens > 0) { - return meta.contextTokens; - } - const usage = meta.usage; - if (usage) { - const fallback = - usage.totalTokens ?? - (usage.inputTokens ?? 0) + (usage.outputTokens ?? 0); - if (fallback > 0) return fallback; - } - } - return 0; - }, [activeChatId, messageRows]); - - // The model's max context window (badge denominator). Read the most recent row - // carrying `maxContextTokens` (set alongside contextTokens on a completed - // turn); 0 when no row has it (older rows, or no admin-configured limit) — the - // badge then shows just the current size with no denominator. - const maxContextTokens = useMemo(() => { - if (!activeChatId || !messageRows) return 0; - for (let i = messageRows.length - 1; i >= 0; i--) { - const meta = messageRows[i].metadata; - if (!meta) continue; - if ( - typeof meta.maxContextTokens === "number" && - meta.maxContextTokens > 0 - ) { - return meta.maxContextTokens; - } - } - return 0; - }, [activeChatId, messageRows]); + // + // The denominator `maxContextTokens` (the model's configured max window) is + // derived in the SAME backward scan: it is stamped alongside `contextTokens` + // on a completed turn, but the numerator and denominator are taken from the + // most recent row carrying EACH value independently — they may land on + // different rows (e.g. a fresh error row can carry contextTokens but not + // 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 + // shows just the current size with no denominator. + const { contextTokens, maxContextTokens } = useMemo( + () => selectContextBadge(activeChatId ? messageRows : undefined), + [activeChatId, messageRows], + ); // On (re)open, settle the geometry before paint (useLayoutEffect → no // first-frame jump): compute an initial top-right placement the first time, diff --git a/apps/client/src/features/ai-chat/utils/context-badge.test.ts b/apps/client/src/features/ai-chat/utils/context-badge.test.ts new file mode 100644 index 00000000..93c7f3a7 --- /dev/null +++ b/apps/client/src/features/ai-chat/utils/context-badge.test.ts @@ -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 }); + }); +}); diff --git a/apps/client/src/features/ai-chat/utils/context-badge.ts b/apps/client/src/features/ai-chat/utils/context-badge.ts new file mode 100644 index 00000000..d3a4f74f --- /dev/null +++ b/apps/client/src/features/ai-chat/utils/context-badge.ts @@ -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 }; +} diff --git a/apps/client/src/features/workspace/components/settings/components/ai-mcp-server-test-view.test.ts b/apps/client/src/features/workspace/components/settings/components/ai-mcp-server-test-view.test.ts new file mode 100644 index 00000000..cde45cc6 --- /dev/null +++ b/apps/client/src/features/workspace/components/settings/components/ai-mcp-server-test-view.test.ts @@ -0,0 +1,59 @@ +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 => + 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", + }); + }); +}); diff --git a/apps/client/src/features/workspace/components/settings/components/ai-mcp-server-test-view.ts b/apps/client/src/features/workspace/components/settings/components/ai-mcp-server-test-view.ts new file mode 100644 index 00000000..b438935a --- /dev/null +++ b/apps/client/src/features/workspace/components/settings/components/ai-mcp-server-test-view.ts @@ -0,0 +1,59 @@ +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; + +/** + * 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, +): 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, + }; + } + return { + state: "idle", + color: undefined, + variant: "default", + label: t("Test"), + tooltip: "", + }; +} diff --git a/apps/client/src/features/workspace/components/settings/components/ai-mcp-servers.tsx b/apps/client/src/features/workspace/components/settings/components/ai-mcp-servers.tsx index 5dabd174..5ccdb380 100644 --- a/apps/client/src/features/workspace/components/settings/components/ai-mcp-servers.tsx +++ b/apps/client/src/features/workspace/components/settings/components/ai-mcp-servers.tsx @@ -31,6 +31,7 @@ import { useUpdateAiMcpServerMutation, } from "@/features/workspace/queries/ai-mcp-server-query.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"; /** @@ -182,34 +183,22 @@ function AiMcpServerRow({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [server.url, server.transport, server.hasHeaders]); - // Tooltip text describes the cause/details; disabled while there is no result. - let tooltipLabel = ""; - if (result?.ok) { - tooltipLabel = - result.tools.length > 0 - ? result.tools.join(", ") - : t("No tools available"); - } else if (result && result.ok === false) { - tooltipLabel = result.error; - } - - // Pick the button presentation from the current test state. Color is never the - // only signal — the label changes too (a11y / colorblind-friendly). - let buttonColor: string | undefined; - let buttonVariant = "default"; - let buttonIcon = ; - let buttonLabel = t("Test"); - if (result?.ok) { - buttonColor = "green"; - buttonVariant = "light"; - buttonIcon = ; - buttonLabel = t("OK · {{n}}", { n: result.tools.length }); - } else if (result && result.ok === false) { - buttonColor = "red"; - buttonVariant = "light"; - buttonIcon = ; - buttonLabel = t("Failed"); - } + // 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. + const view = mcpTestButtonView(result, t); + const tooltipLabel = view.tooltip; + const buttonColor = view.color; + const buttonVariant = view.variant; + const buttonLabel = view.label; + const buttonIcon = + view.state === "ok" ? ( + + ) : view.state === "failed" ? ( + + ) : ( + + ); return ( diff --git a/apps/server/src/integrations/ai/ai-settings.service.spec.ts b/apps/server/src/integrations/ai/ai-settings.service.spec.ts new file mode 100644 index 00000000..b0efaa21 --- /dev/null +++ b/apps/server/src/integrations/ai/ai-settings.service.spec.ts @@ -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); + }); +}); diff --git a/apps/server/src/integrations/ai/ai-settings.service.ts b/apps/server/src/integrations/ai/ai-settings.service.ts index 388ac74a..2ccf5580 100644 --- a/apps/server/src/integrations/ai/ai-settings.service.ts +++ b/apps/server/src/integrations/ai/ai-settings.service.ts @@ -18,6 +18,18 @@ import { PROVIDER_SETTINGS_KEYS, } 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 * controller DTO. `apiKey` / `embeddingApiKey` are write-only: undefined = @@ -159,20 +171,12 @@ export class AiSettingsService { const provider = await this.readProvider(workspaceId); if (!provider.driver) return null; - // Provider values are stored as ::text (see workspace.repo.ts), so - // chatContextWindow arrives as a string here; parse it back to a positive - // integer or undefined. - const ctxWindow = Number(provider.chatContextWindow); - const config: ResolvedAiConfig = { driver: provider.driver, chatModel: provider.chatModel, - // Max context window for the chat header badge denominator. 0/unset = no - // limit. - chatContextWindow: - Number.isFinite(ctxWindow) && ctxWindow > 0 - ? Math.floor(ctxWindow) - : undefined, + // 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'. chatApiStyle: provider.chatApiStyle, // Cheap model id for the anonymous public-share assistant; reuses the chat @@ -232,14 +236,9 @@ export class AiSettingsService { async getMasked(workspaceId: string): Promise { const provider = await this.readProvider(workspaceId); - // Provider values are stored as ::text (see workspace.repo.ts), so - // chatContextWindow arrives as a string; coerce it to a positive integer or - // undefined so the client receives a real number. - const ctxWindow = Number(provider.chatContextWindow); - const chatContextWindow = - Number.isFinite(ctxWindow) && ctxWindow > 0 - ? Math.floor(ctxWindow) - : undefined; + // 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 hasEmbeddingApiKey = false;