From 8a03e4fc23843adfa9157787ece4dcd8cd338b93 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Thu, 25 Jun 2026 19:38:03 -0700 Subject: [PATCH 01/50] feat(core): per-stage column dwell instrumentation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `columnDwellMs?: Record` to Task — a per-column accumulator (column name -> cumulative ms) recorded at the same store column-transition seam as `cumulativeActiveMs`. On every move it adds `columnMovedAt(new) - columnMovedAt(prev)` to the bucket for the column being left, clamped >= 0; unparseable/missing prior timestamps and 0-dwell moves are skipped, and second visits add to the existing bucket. Motivation: `cumulativeActiveMs` only measures in-progress time. Diagnosis of slow tasks showed the dominant wall-clock is *waiting* (queue time in todo, review wait in in-review), which previously had to be reconstructed from agent logs. This makes per-stage dwell directly queryable, like productivity-analytics already consumes cumulativeActiveMs. Persisted as a JSON-text task column following the v129 workspaceWorktrees precedent: SCHEMA_SQL column + SCHEMA_VERSION 129->130 + versioned addColumnIfMissing migration. Additive and behavior-preserving; pre-existing rows start NULL and accumulate from their next transition. Survives archive/restore. @fusion/core is private — no changeset. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../__tests__/store-execution-timing.test.ts | 52 +++++++++++++++++++ packages/core/src/db.ts | 19 ++++++- packages/core/src/store.ts | 46 +++++++++++++++- packages/core/src/types.ts | 15 ++++++ 4 files changed, 129 insertions(+), 3 deletions(-) diff --git a/packages/core/src/__tests__/store-execution-timing.test.ts b/packages/core/src/__tests__/store-execution-timing.test.ts index 515e8e01ac..f288c884c8 100644 --- a/packages/core/src/__tests__/store-execution-timing.test.ts +++ b/packages/core/src/__tests__/store-execution-timing.test.ts @@ -88,4 +88,56 @@ describe("TaskStore execution timing semantics", () => { expect(done.cumulativeActiveMs).toBe(5 * 60_000); }); + + /* + FNXC:TaskTiming 2026-06-26-10:14: + Per-stage dwell instrumentation regression. Asserts columnDwellMs accumulates the correct + wall-clock per column across a full todo->in-progress->in-review->done sequence, that a + re-entered column (second in-progress / second todo visit) ADDS to the existing bucket rather + than overwriting it, and that the JSON map survives the SQLite round-trip (getTask rehydration). + */ + it("accumulates per-column dwell across a multi-column, multi-visit sequence", async () => { + vi.useFakeTimers(); + // todo entry anchor. Create + first move share this instant => leaving the + // creation column is a 0ms dwell and records no spurious bucket. + vi.setSystemTime(new Date("2026-06-26T10:00:00.000Z")); + + const task = await store.createTask({ description: "per-stage dwell" }); + await store.moveTask(task.id, "todo"); + + // todo dwell visit #1: 5 min + vi.setSystemTime(new Date("2026-06-26T10:05:00.000Z")); + await store.moveTask(task.id, "in-progress"); + + // in-progress dwell visit #1: 3 min + vi.setSystemTime(new Date("2026-06-26T10:08:00.000Z")); + await store.moveTask(task.id, "in-review"); + + // in-review dwell: 10 min + vi.setSystemTime(new Date("2026-06-26T10:18:00.000Z")); + await store.moveTask(task.id, "done"); + + // done dwell: 2 min (reopen leaves done) + vi.setSystemTime(new Date("2026-06-26T10:20:00.000Z")); + await store.moveTask(task.id, "todo", { moveSource: "user" }); + + // todo dwell visit #2: 1 min => bucket adds to the prior 5 min + vi.setSystemTime(new Date("2026-06-26T10:21:00.000Z")); + await store.moveTask(task.id, "in-progress"); + + // in-progress dwell visit #2: 4 min => bucket adds to the prior 3 min + vi.setSystemTime(new Date("2026-06-26T10:25:00.000Z")); + const final = await store.moveTask(task.id, "in-review"); + + expect(final.columnDwellMs).toEqual({ + todo: 6 * 60_000, // 5 + 1 + "in-progress": 7 * 60_000, // 3 + 4 + "in-review": 10 * 60_000, + done: 2 * 60_000, + }); + + // JSON map survives the DB round-trip. + const reloaded = await store.getTask(task.id); + expect(reloaded?.columnDwellMs).toEqual(final.columnDwellMs); + }); }); diff --git a/packages/core/src/db.ts b/packages/core/src/db.ts index 025f77e1bb..415a7fe4a9 100644 --- a/packages/core/src/db.ts +++ b/packages/core/src/db.ts @@ -162,7 +162,7 @@ export function isFts5CorruptionError(error: unknown): boolean { // ── Schema Definition ──────────────────────────────────────────────── -const SCHEMA_VERSION = 129; +const SCHEMA_VERSION = 130; const TASKS_FTS_AUTOMERGE = 8; const TASKS_FTS_CRISISMERGE = 16; @@ -296,6 +296,12 @@ CREATE TABLE IF NOT EXISTS tasks ( columnMovedAt TEXT, firstExecutionAt TEXT, cumulativeActiveMs INTEGER, + -- FNXC:TaskTiming 2026-06-26-10:14: per-column dwell map (JSON text) accumulated at the + -- column-transition seam (store.ts moveTaskInternal). Fills the gap left by cumulativeActiveMs + -- (in-progress only) so todo/in-review/done wall-clock is queryable per stage. Source of truth + -- for getSchemaCompatibilityTableSchemas(); fresh DBs get it here, existing DBs are backfilled + -- by the version-130 migration / ensureSchemaCompatibility() at boot. + columnDwellMs TEXT, executionStartedAt TEXT, executionCompletedAt TEXT, -- JSON columns for nested arrays/objects @@ -5339,6 +5345,17 @@ export class Database { }); } + if (version < 130) { + // FNXC:TaskTiming 2026-06-26-10:14: add the columnDwellMs column so existing DBs durably + // persist per-stage dwell going forward. Backfill is also covered by ensureSchemaCompatibility() + // (SCHEMA_SQL is its source of truth); this versioned migration keeps migrated and + // fresh-from-SCHEMA_SQL DBs converged. No data backfill: pre-existing rows start with NULL + // (= undefined map) and accumulate from their next column transition. + this.applyMigration(130, () => { + this.addColumnIfMissing("tasks", "columnDwellMs", "TEXT"); + }); + } + } /** diff --git a/packages/core/src/store.ts b/packages/core/src/store.ts index 9b4eeb08fd..3ea0fcf63a 100644 --- a/packages/core/src/store.ts +++ b/packages/core/src/store.ts @@ -283,6 +283,10 @@ interface TaskRow { columnMovedAt: string | null; firstExecutionAt: string | null; cumulativeActiveMs: number | null; + // FNXC:TaskTiming 2026-06-26-10:14: per-column dwell map (JSON text), populated by the + // column-transition seam in moveTaskInternal. Persisted alongside cumulativeActiveMs so + // per-stage wall-clock survives the SQLite round-trip getChangedTaskColumns/rowToTask use. + columnDwellMs: string | null; executionStartedAt: string | null; executionCompletedAt: string | null; dependencies: string | null; @@ -442,6 +446,8 @@ const TASK_COLUMN_DESCRIPTORS: TaskColumnDescriptor[] = [ defineTaskColumn("columnMovedAt", (task) => task.columnMovedAt ?? null), defineTaskColumn("firstExecutionAt", (task) => task.firstExecutionAt ?? null), defineTaskColumn("cumulativeActiveMs", (task) => task.cumulativeActiveMs ?? null), + // FNXC:TaskTiming 2026-06-26-10:14: serialize per-column dwell map as JSON text (same as mergeDetails/workspaceWorktrees). + defineTaskColumn("columnDwellMs", (task) => toJsonNullable(task.columnDwellMs)), defineTaskColumn("executionStartedAt", (task) => task.executionStartedAt ?? null), defineTaskColumn("executionCompletedAt", (task) => task.executionCompletedAt ?? null), defineTaskColumn("dependencies", (task) => toJson(task.dependencies || [])), @@ -2084,6 +2090,11 @@ export class TaskStore extends EventEmitter { columnMovedAt: row.columnMovedAt || undefined, firstExecutionAt: row.firstExecutionAt || undefined, cumulativeActiveMs: row.cumulativeActiveMs ?? undefined, + // FNXC:TaskTiming 2026-06-26-10:14: rehydrate per-column dwell map; drop empty maps to undefined like workspaceWorktrees. + columnDwellMs: (() => { + const d = fromJson>(row.columnDwellMs); + return d && Object.keys(d).length > 0 ? d : undefined; + })(), executionStartedAt: row.executionStartedAt || undefined, executionCompletedAt: row.executionCompletedAt || undefined, dependencies: fromJson(row.dependencies) || [], @@ -2266,6 +2277,7 @@ export class TaskStore extends EventEmitter { columnMovedAt: entry.columnMovedAt, firstExecutionAt: entry.firstExecutionAt, cumulativeActiveMs: entry.cumulativeActiveMs, + columnDwellMs: entry.columnDwellMs, executionStartedAt: entry.executionStartedAt, executionCompletedAt: entry.executionCompletedAt, modelPresetId: entry.modelPresetId, @@ -2402,6 +2414,7 @@ export class TaskStore extends EventEmitter { columnMovedAt: task.columnMovedAt, firstExecutionAt: task.firstExecutionAt, cumulativeActiveMs: task.cumulativeActiveMs, + columnDwellMs: task.columnDwellMs, executionStartedAt: task.executionStartedAt, executionCompletedAt: task.executionCompletedAt, archivedAt, @@ -2612,7 +2625,7 @@ export class TaskStore extends EventEmitter { "mergeRetries", "workflowStepRetries", "stuckKillCount", "resumeLimboCount", "graphResumeRetryCount", "resumeLimboTipSha", "resumeLimboStepSignature", "postReviewFixCount", "recoveryRetryCount", "taskDoneRetryCount", "worktreeSessionRetryCount", "completionHandoffLimboRecoveryCount", "verificationFailureCount", "mergeConflictBounceCount", "mergeAuditBounceCount", "mergeTransientRetryCount", "branchConflictRecoveryCount", "reviewerContextRetryCount", "reviewerFallbackRetryCount", "nextRecoveryAt", "error", "summary", "thinkingLevel", "executionMode", "tokenUsageInputTokens", "tokenUsageOutputTokens", "tokenUsageCachedTokens", "tokenUsageCacheWriteTokens", "tokenUsageTotalTokens", "tokenUsageFirstUsedAt", "tokenUsageLastUsedAt", "tokenUsageModelProvider", "tokenUsageModelId", "tokenUsagePerModel", "tokenBudgetSoftAlertedAt", "tokenBudgetHardAlertedAt", "tokenBudgetOverride", - "createdAt", "updatedAt", "columnMovedAt", "firstExecutionAt", "cumulativeActiveMs", "executionStartedAt", "executionCompletedAt", + "createdAt", "updatedAt", "columnMovedAt", "firstExecutionAt", "cumulativeActiveMs", "columnDwellMs", "executionStartedAt", "executionCompletedAt", "dependencies", "steps", "customFields", "comments", "review", "reviewState", "workflowStepResults", "steeringComments", "attachments", "prInfo", "prInfos", "issueInfo", "githubTracking", "sourceIssueProvider", "sourceIssueRepository", "sourceIssueExternalIssueId", "sourceIssueNumber", "sourceIssueUrl", "sourceIssueClosedAt", "mergeDetails", "workspaceWorktrees", "breakIntoSubtasks", "noCommitsExpected", "enabledWorkflowSteps", "modifiedFiles", @@ -2661,7 +2674,7 @@ export class TaskStore extends EventEmitter { "mergeRetries", "workflowStepRetries", "stuckKillCount", "resumeLimboCount", "graphResumeRetryCount", "resumeLimboTipSha", "resumeLimboStepSignature", "postReviewFixCount", "recoveryRetryCount", "taskDoneRetryCount", "worktreeSessionRetryCount", "completionHandoffLimboRecoveryCount", "verificationFailureCount", "mergeConflictBounceCount", "mergeAuditBounceCount", "mergeTransientRetryCount", "branchConflictRecoveryCount", "reviewerContextRetryCount", "reviewerFallbackRetryCount", "nextRecoveryAt", "error", "summary", "thinkingLevel", "executionMode", "tokenUsageInputTokens", "tokenUsageOutputTokens", "tokenUsageCachedTokens", "tokenUsageCacheWriteTokens", "tokenUsageTotalTokens", "tokenUsageFirstUsedAt", "tokenUsageLastUsedAt", "tokenUsageModelProvider", "tokenUsageModelId", "tokenUsagePerModel", "tokenBudgetSoftAlertedAt", "tokenBudgetHardAlertedAt", "tokenBudgetOverride", - "createdAt", "updatedAt", "columnMovedAt", "firstExecutionAt", "cumulativeActiveMs", "executionStartedAt", "executionCompletedAt", + "createdAt", "updatedAt", "columnMovedAt", "firstExecutionAt", "cumulativeActiveMs", "columnDwellMs", "executionStartedAt", "executionCompletedAt", "dependencies", "steps", "customFields", "attachments", "steeringComments", "comments", "review", "reviewState", "workflowStepResults", "prInfo", "prInfos", "issueInfo", "githubTracking", "sourceIssueProvider", "sourceIssueRepository", "sourceIssueExternalIssueId", "sourceIssueNumber", "sourceIssueUrl", "sourceIssueClosedAt", "mergeDetails", "workspaceWorktrees", "breakIntoSubtasks", "noCommitsExpected", "enabledWorkflowSteps", "modifiedFiles", @@ -7361,10 +7374,39 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS} } const movedAt = internal.now ?? new Date().toISOString(); + /* + FNXC:TaskTiming 2026-06-26-10:14: + Capture the previous column-entry timestamp BEFORE it is overwritten so we can record + per-stage dwell. `cumulativeActiveMs` only covers `in-progress`; this seam fills the gap + for todo / in-review / done so per-stage wall-clock is measurable going forward without + reconstructing it from agent logs. + */ + const previousColumnMovedAt = task.columnMovedAt; task.column = toColumn; task.columnMovedAt = movedAt; task.updatedAt = movedAt; + /* + FNXC:TaskTiming 2026-06-26-10:14: + Accumulate dwell for the column being LEFT into `columnDwellMs[fromColumn]`, mirroring the + `cumulativeActiveMs` accumulation pattern. Flag-INDEPENDENT (runs for both the workflow-hook + and legacy-inline paths) because it keys off the generic columnMovedAt delta, not in-progress + execution timestamps. Skip when the previous timestamp is missing/unparseable (e.g. first move + or legacy rows), and clamp to >= 0 to defend against clock skew / out-of-order `internal.now`. + Multi-visit columns add to the existing bucket, never decrement. + */ + { + const prevMs = Date.parse(previousColumnMovedAt ?? ""); + const nowMs = Date.parse(movedAt); + if (Number.isFinite(prevMs) && Number.isFinite(nowMs)) { + const dwellMs = Math.max(0, nowMs - prevMs); + if (dwellMs > 0) { + const buckets = (task.columnDwellMs ??= {}); + buckets[fromColumn] = Math.max(0, buckets[fromColumn] ?? 0) + dwellMs; + } + } + } + if (useWorkflow) { // ── Flag-ON: route the legacy per-column side effects through the // default-workflow trait hooks (timing, reset-on-entry, abort-on-exit, diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 40af737a30..8c39fcfd1d 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -2667,6 +2667,18 @@ export interface Task { * Incremented whenever the task leaves `in-progress`; never decremented and * never cleared by reopen flows. */ cumulativeActiveMs?: number; + /* + FNXC:TaskTiming 2026-06-26-10:14: + Per-stage dwell-time instrumentation. `cumulativeActiveMs` only measures `in-progress`, + so "how long did a task sit in todo / in-review" was unrecoverable without reconstructing + it from agent logs. This map records cumulative wall-clock milliseconds spent in EACH + column (column name -> total ms), accumulated at the column-transition seam in store.ts + exactly like `cumulativeActiveMs`: on every transition we add the dwell of the column being + LEFT (newColumnMovedAt - previousColumnMovedAt, clamped >= 0). Multi-visit columns add to + the existing bucket; never decremented and never cleared by reopen flows. Directly queryable + per stage by consumers like productivity-analytics.ts. + */ + columnDwellMs?: Record; /** ISO-8601 wall-clock timestamp for the current execution attempt. * Set when entering `in-progress`; may be cleared on reopen to * todo/triage when resume state is not preserved. */ @@ -4844,6 +4856,9 @@ export interface ArchivedTaskEntry { firstExecutionAt?: string; /** Accumulated active runtime spent in `in-progress` across attempts. */ cumulativeActiveMs?: number; + /** FNXC:TaskTiming 2026-06-26-10:14: per-column cumulative dwell (ms) carried through + * archive/restore so per-stage wall-clock survives archival. See Task.columnDwellMs. */ + columnDwellMs?: Record; /** Current-attempt execution anchor; may be cleared on reopen. */ executionStartedAt?: string; /** First-time completion anchor; may be cleared on reopen. */ From e48f80b7647263d98096c301fb3ba10aee95f35d Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Thu, 25 Jun 2026 19:51:21 -0700 Subject: [PATCH 02/50] FN-7028: split AgentLogViewer tests by concern Split the large AgentLogViewer suite into focused, line-count-friendly test files. - Move header, layout, markdown, and rendering coverage into separate test modules. - Add a shared AgentLogViewer test helper for reusable setup. - Remove the oversized combined AgentLogViewer test and update the line-count baseline. Files changed: .../__tests__/AgentLogViewer.header.test.tsx | 490 +++++ .../__tests__/AgentLogViewer.layout.test.tsx | 367 ++++ .../__tests__/AgentLogViewer.markdown.test.tsx | 797 ++++++++ .../__tests__/AgentLogViewer.rendering.test.tsx | 410 ++++ .../__tests__/AgentLogViewer.test-helpers.ts | 16 + .../components/__tests__/AgentLogViewer.test.tsx | 2010 -------------------- scripts/line-count-baseline.json | 1 - 7 files changed, 2080 insertions(+), 2011 deletions(-) Fusion-Task-Id: FN-7028 Fusion-Task-Lineage: 5ef134c7-0969-4b9b-908c-9713daa9d0d2 --- .../__tests__/AgentLogViewer.header.test.tsx | 490 ++++ .../__tests__/AgentLogViewer.layout.test.tsx | 367 +++ .../AgentLogViewer.markdown.test.tsx | 797 +++++++ .../AgentLogViewer.rendering.test.tsx | 410 ++++ .../__tests__/AgentLogViewer.test-helpers.ts | 16 + .../__tests__/AgentLogViewer.test.tsx | 2010 ----------------- scripts/line-count-baseline.json | 1 - 7 files changed, 2080 insertions(+), 2011 deletions(-) create mode 100644 packages/dashboard/app/components/__tests__/AgentLogViewer.header.test.tsx create mode 100644 packages/dashboard/app/components/__tests__/AgentLogViewer.layout.test.tsx create mode 100644 packages/dashboard/app/components/__tests__/AgentLogViewer.markdown.test.tsx create mode 100644 packages/dashboard/app/components/__tests__/AgentLogViewer.rendering.test.tsx create mode 100644 packages/dashboard/app/components/__tests__/AgentLogViewer.test-helpers.ts delete mode 100644 packages/dashboard/app/components/__tests__/AgentLogViewer.test.tsx diff --git a/packages/dashboard/app/components/__tests__/AgentLogViewer.header.test.tsx b/packages/dashboard/app/components/__tests__/AgentLogViewer.header.test.tsx new file mode 100644 index 0000000000..fdb7c14be4 --- /dev/null +++ b/packages/dashboard/app/components/__tests__/AgentLogViewer.header.test.tsx @@ -0,0 +1,490 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { AgentLogViewer } from "../AgentLogViewer"; +import { makeEntry, getScrollContainer } from "./AgentLogViewer.test-helpers"; +import "../../styles.css"; +import "../TaskDetailModal.css"; + +// Mock lucide-react icons used by AgentLogViewer and ProviderIcon +vi.mock("lucide-react", () => ({ + Maximize2: () => null, + Minimize2: () => null, + Loader2: () => null, + Cpu: () => null, + ChevronDown: () => null, + ChevronRight: () => null, +})); + +describe("AgentLogViewer", () => { + beforeEach(() => { + window.localStorage.clear(); + }); + + describe("model info header", () => { + it("renders model info header with executor model when set", () => { + const entries = [makeEntry()]; + const { container } = render( + , + ); + const header = container.querySelector("[data-testid='agent-log-model-header']"); + expect(header).toBeTruthy(); + expect(container.querySelector('[data-provider="anthropic"]')).toBeTruthy(); + expect(header!.textContent).not.toContain("Executor:"); + + fireEvent.click(screen.getByTestId("agent-log-model-expand")); + expect(header!.textContent).toContain("Executor:"); + expect(header!.textContent).toContain("anthropic/claude-sonnet-4-5"); + }); + + it("renders 'Using default' when no executor model override is set", () => { + const entries = [makeEntry()]; + const { container } = render( + , + ); + const header = container.querySelector("[data-testid='agent-log-model-header']"); + expect(header).toBeTruthy(); + expect(header!.textContent).not.toContain("Using default"); + }); + + it("renders 'Using default' when executorModel is undefined", () => { + const entries = [makeEntry()]; + const { container } = render(); + const header = container.querySelector("[data-testid='agent-log-model-header']"); + expect(header).toBeTruthy(); + expect(header!.textContent).not.toContain("Using default"); + }); + + it("renders model info header with validator model when set", () => { + const entries = [makeEntry()]; + const { container } = render( + , + ); + const header = container.querySelector("[data-testid='agent-log-model-header']"); + expect(header).toBeTruthy(); + expect(container.querySelector('[data-provider="openai"]')).toBeTruthy(); + expect(header!.textContent).not.toContain("Reviewer:"); + + fireEvent.click(screen.getByTestId("agent-log-model-expand")); + expect(header!.textContent).toContain("Reviewer:"); + expect(header!.textContent).toContain("openai/gpt-4o"); + }); + + it("renders 'Using default' when no validator model override is set", () => { + const entries = [makeEntry()]; + const { container } = render( + , + ); + const header = container.querySelector("[data-testid='agent-log-model-header']"); + expect(header).toBeTruthy(); + expect(header!.textContent).not.toContain("Using default"); + }); + + it("renders both models when both are configured", () => { + const entries = [makeEntry()]; + const { container } = render( + , + ); + const header = container.querySelector("[data-testid='agent-log-model-header']"); + expect(header).toBeTruthy(); + expect(container.querySelector('[data-provider="anthropic"]')).toBeTruthy(); + expect(container.querySelector('[data-provider="openai"]')).toBeTruthy(); + expect(header!.textContent).not.toContain("anthropic/claude-opus-4"); + expect(header!.textContent).not.toContain("openai/gpt-4o"); + + fireEvent.click(screen.getByTestId("agent-log-model-expand")); + expect(header!.textContent).toContain("anthropic/claude-opus-4"); + expect(header!.textContent).toContain("openai/gpt-4o"); + }); + + it("renders header with 'Using default' for both models when both are null/undefined", () => { + const entries = [makeEntry()]; + const { container } = render(); + const header = container.querySelector("[data-testid='agent-log-model-header']"); + expect(header).toBeTruthy(); + expect(header!.textContent).not.toContain("Using default"); + }); + + it("shows 'Using default' when executorModel has only provider but no modelId", () => { + const entries = [makeEntry()]; + const { container } = render( + , + ); + const header = container.querySelector("[data-testid='agent-log-model-header']"); + expect(header).toBeTruthy(); + expect(header!.textContent).not.toContain("Using default"); + }); + + it("shows 'Using default' when executorModel has only modelId but no provider", () => { + const entries = [makeEntry()]; + const { container } = render( + , + ); + const header = container.querySelector("[data-testid='agent-log-model-header']"); + expect(header).toBeTruthy(); + expect(header!.textContent).not.toContain("Using default"); + }); + + it("renders model info header with planning model when set", () => { + const entries = [makeEntry()]; + const { container } = render( + , + ); + const header = container.querySelector("[data-testid='agent-log-model-header']"); + expect(header).toBeTruthy(); + expect(container.querySelector('[data-provider="anthropic"]')).toBeTruthy(); + expect(header!.textContent).not.toContain("Planning:"); + + fireEvent.click(screen.getByTestId("agent-log-model-expand")); + expect(header!.textContent).toContain("Planning:"); + expect(header!.textContent).toContain("anthropic/claude-opus-4"); + }); + + it("renders 'Using default' for planning when no planning model is set", () => { + const entries = [makeEntry()]; + const { container } = render( + , + ); + const header = container.querySelector("[data-testid='agent-log-model-header']"); + expect(header).toBeTruthy(); + expect(header!.textContent).not.toContain("Using default"); + }); + + it("renders 'Using default' for planning when planningModel is undefined", () => { + const entries = [makeEntry()]; + const { container } = render(); + const header = container.querySelector("[data-testid='agent-log-model-header']"); + expect(header).toBeTruthy(); + expect(header!.textContent).not.toContain("Using default"); + }); + + it("renders all three models when all are configured", () => { + const entries = [makeEntry()]; + const { container } = render( + , + ); + const header = container.querySelector("[data-testid='agent-log-model-header']"); + expect(header).toBeTruthy(); + expect(container.querySelector('[data-provider="anthropic"]')).toBeTruthy(); + expect(container.querySelector('[data-provider="openai"]')).toBeTruthy(); + expect(container.querySelector('[data-provider="google"]')).toBeTruthy(); + expect(header!.textContent).not.toContain("anthropic/claude-opus-4"); + expect(header!.textContent).not.toContain("openai/gpt-4o"); + expect(header!.textContent).not.toContain("google/gemini-pro"); + + fireEvent.click(screen.getByTestId("agent-log-model-expand")); + expect(header!.textContent).toContain("anthropic/claude-opus-4"); + expect(header!.textContent).toContain("openai/gpt-4o"); + expect(header!.textContent).toContain("google/gemini-pro"); + }); + + it("shows 'Using default' for planning when planningModel has only provider but no modelId", () => { + const entries = [makeEntry()]; + const { container } = render( + , + ); + const header = container.querySelector("[data-testid='agent-log-model-header']"); + expect(header).toBeTruthy(); + expect(header!.textContent).not.toContain("Using default"); + }); + }); + + describe("model header expand/collapse", () => { + it("shows only provider icons in collapsed state, hides model text", () => { + const entries = [makeEntry()]; + const { container } = render( + , + ); + + expect(container.querySelector('[data-provider="anthropic"]')).toBeTruthy(); + expect(screen.getByTestId("agent-log-model-expand")).toBeTruthy(); + expect(container.textContent).not.toContain("Executor:"); + expect(container.textContent).not.toContain("claude-sonnet-4-5"); + }); + + it("shows model details when expand button is clicked", () => { + const entries = [makeEntry()]; + const { container } = render( + , + ); + + fireEvent.click(screen.getByTestId("agent-log-model-expand")); + expect(container.textContent).toContain("Executor:"); + expect(container.textContent).toContain("anthropic/claude-sonnet-4-5"); + }); + + it("collapses model details when expand button is clicked again", () => { + const entries = [makeEntry()]; + const { container } = render( + , + ); + + const button = screen.getByTestId("agent-log-model-expand"); + fireEvent.click(button); + expect(container.textContent).toContain("Executor:"); + fireEvent.click(button); + expect(container.textContent).not.toContain("Executor:"); + }); + + it("shows no provider icons when no model overrides are set", () => { + const entries = [makeEntry()]; + const { container } = render(); + + expect(container.querySelector("[data-provider]")).toBeNull(); + }); + + it("has aria-expanded=false when collapsed and aria-expanded=true when expanded", () => { + const entries = [makeEntry()]; + render( + , + ); + + const button = screen.getByTestId("agent-log-model-expand"); + expect(button.getAttribute("aria-expanded")).toBe("false"); + fireEvent.click(button); + expect(button.getAttribute("aria-expanded")).toBe("true"); + }); + + it("renders multiple provider icons for multiple overrides", () => { + const entries = [makeEntry()]; + const { container } = render( + , + ); + + expect(container.querySelector('[data-provider="anthropic"]')).toBeTruthy(); + expect(container.querySelector('[data-provider="openai"]')).toBeTruthy(); + expect(container.querySelector('[data-provider="google"]')).toBeTruthy(); + }); + }); + + describe("timestamp display", () => { + it("renders no timestamps for entries without agent field", () => { + const entries = [ + makeEntry({ text: "legacy 1", type: "text" }), + makeEntry({ text: "legacy 2", type: "text" }), + ]; + const { container } = render(); + const timestamps = container.querySelectorAll(".agent-log-timestamp"); + expect(timestamps).toHaveLength(0); + }); + + it("renders relative timestamps for recent entries next to the badge", () => { + const recentTimestamp = new Date(Date.now() - 5 * 60 * 1000).toISOString(); + const entries = [ + makeEntry({ text: "hello", type: "text", agent: "executor", timestamp: recentTimestamp }), + ]; + const { container } = render(); + const timestamp = container.querySelector(".agent-log-timestamp"); + expect(timestamp).toBeTruthy(); + expect(timestamp!.textContent).toBe("5m ago"); + + const badge = container.querySelector(".agent-log-agent-badge") as HTMLElement; + expect(badge).toBeTruthy(); + expect(badge.parentElement?.querySelector(".agent-log-timestamp")).toBeTruthy(); + }); + + it("renders 'just now' for entries less than a minute old", () => { + const recentTimestamp = new Date(Date.now() - 30 * 1000).toISOString(); + const entries = [ + makeEntry({ text: "hello", type: "text", agent: "executor", timestamp: recentTimestamp }), + ]; + const { container } = render(); + const timestamp = container.querySelector(".agent-log-timestamp"); + expect(timestamp!.textContent).toBe("just now"); + }); + + it("renders hours ago for older entries", () => { + const olderTimestamp = new Date(Date.now() - 3 * 60 * 60 * 1000).toISOString(); + const entries = [ + makeEntry({ text: "hello", type: "text", agent: "executor", timestamp: olderTimestamp }), + ]; + const { container } = render(); + const timestamp = container.querySelector(".agent-log-timestamp"); + expect(timestamp!.textContent).toBe("3h ago"); + }); + + it("renders days ago for entries older than a day", () => { + const oldTimestamp = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString(); + const entries = [ + makeEntry({ text: "hello", type: "text", agent: "executor", timestamp: oldTimestamp }), + ]; + const { container } = render(); + const timestamp = container.querySelector(".agent-log-timestamp"); + expect(timestamp!.textContent).toBe("2d ago"); + }); + + it("renders locale date for entries older than 7 days", () => { + const veryOldTimestamp = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString(); + const entries = [ + makeEntry({ text: "hello", type: "text", agent: "executor", timestamp: veryOldTimestamp }), + ]; + const { container } = render(); + const timestamp = container.querySelector(".agent-log-timestamp"); + // Should be a locale date string, not a relative time + expect(timestamp!.textContent).not.toContain("ago"); + expect(timestamp!.textContent).not.toBe("just now"); + }); + + it("uses the timestamp class inside the badge row", () => { + const entries = [makeEntry({ text: "hello", type: "text", agent: "executor" })]; + const { container } = render(); + const badge = container.querySelector(".agent-log-agent-badge") as HTMLElement; + expect(badge).toBeTruthy(); + const timestamp = badge.parentElement?.querySelector(".agent-log-timestamp") as HTMLElement; + expect(timestamp).toBeTruthy(); + expect(timestamp.classList.contains("agent-log-timestamp")).toBe(true); + // Theme styles are class-based now, not inline. + expect(timestamp.style.fontSize).toBe(""); + expect(timestamp.style.opacity).toBe(""); + }); + + it("renders the agent badge as a sticky overlay on a full-width text block", () => { + const entries = [makeEntry({ text: "long executor output", type: "text", agent: "executor" })]; + const { container } = render(); + const block = container.querySelector(".agent-log-text") as HTMLElement; + const badgeRow = container.querySelector(".agent-log-badge-row") as HTMLElement; + + expect(block).toBeTruthy(); + expect(badgeRow).toBeTruthy(); + expect(getComputedStyle(block).width).toBe("100%"); + expect(getComputedStyle(badgeRow).position).toBe("sticky"); + expect(getComputedStyle(badgeRow).left).not.toBe(""); + expect(getComputedStyle(badgeRow).pointerEvents).toBe("none"); + }); + + it("includes timestamp in the badge container for tool entries", () => { + const entries = [makeEntry({ text: "Bash", type: "tool", agent: "executor" })]; + const { container } = render(); + const toolDiv = container.querySelector(".agent-log-tool"); + expect(toolDiv).toBeTruthy(); + const badge = toolDiv!.querySelector(".agent-log-agent-badge") as HTMLElement; + expect(badge).toBeTruthy(); + expect(badge.parentElement?.querySelector(".agent-log-timestamp")).toBeTruthy(); + }); + + it("includes timestamp in the badge container for tool_result entries", () => { + const entries = [makeEntry({ text: "ok", type: "tool_result", agent: "executor" })]; + const { container } = render(); + const resultDiv = container.querySelector(".agent-log-tool-result"); + expect(resultDiv).toBeTruthy(); + const badge = resultDiv!.querySelector(".agent-log-agent-badge") as HTMLElement; + expect(badge).toBeTruthy(); + expect(badge.parentElement?.querySelector(".agent-log-timestamp")).toBeTruthy(); + }); + + it("includes timestamp in the badge container for tool_error entries", () => { + const entries = [makeEntry({ text: "fail", type: "tool_error", agent: "executor" })]; + const { container } = render(); + const errorDiv = container.querySelector(".agent-log-tool-error"); + expect(errorDiv).toBeTruthy(); + const badge = errorDiv!.querySelector(".agent-log-agent-badge") as HTMLElement; + expect(badge).toBeTruthy(); + expect(badge.parentElement?.querySelector(".agent-log-timestamp")).toBeTruthy(); + }); + + it("includes timestamp in the badge container for thinking entries", () => { + const entries = [makeEntry({ text: "hmm", type: "thinking", agent: "executor" })]; + const { container } = render(); + const thinkingSpan = container.querySelector(".agent-log-thinking"); + expect(thinkingSpan).toBeTruthy(); + const badge = thinkingSpan!.querySelector(".agent-log-agent-badge") as HTMLElement; + expect(badge).toBeTruthy(); + expect(badge.parentElement?.querySelector(".agent-log-timestamp")).toBeTruthy(); + }); + + it("shows exactly one timestamp for consecutive text entries from the same agent", () => { + const entries = [ + makeEntry({ text: "chunk 1", type: "text", agent: "executor" }), + makeEntry({ text: "chunk 2", type: "text", agent: "executor" }), + ]; + const { container } = render(); + const timestamps = container.querySelectorAll(".agent-log-timestamp"); + expect(timestamps).toHaveLength(1); + }); + + it("renders timestamps at each agent transition", () => { + const entries = [ + makeEntry({ text: "triage output", type: "text", agent: "triage" }), + makeEntry({ text: "executor output", type: "text", agent: "executor" }), + makeEntry({ text: "review notes", type: "text", agent: "reviewer" }), + ]; + const { container } = render(); + const badges = Array.from(container.querySelectorAll(".agent-log-agent-badge")); + const timestamps = container.querySelectorAll(".agent-log-timestamp"); + + expect(badges).toHaveLength(3); + expect(timestamps).toHaveLength(3); + expect(badges.map((badge) => badge.textContent)).toEqual(["[Plan]", "[executor]", "[reviewer]"]); + }); + + it("badge container includes both badge text and timestamp text", () => { + const recentTimestamp = new Date(Date.now() - 5 * 60 * 1000).toISOString(); + const entries = [ + makeEntry({ text: "hello", type: "text", agent: "executor", timestamp: recentTimestamp }), + ]; + const { container } = render(); + const badge = container.querySelector(".agent-log-agent-badge") as HTMLElement; + expect(badge).toBeTruthy(); + + const badgeContainer = badge.parentElement as HTMLElement; + expect(badgeContainer.textContent).toContain("[executor]"); + expect(badgeContainer.textContent).toContain("5m ago"); + }); + }); +}); diff --git a/packages/dashboard/app/components/__tests__/AgentLogViewer.layout.test.tsx b/packages/dashboard/app/components/__tests__/AgentLogViewer.layout.test.tsx new file mode 100644 index 0000000000..db7cf13b66 --- /dev/null +++ b/packages/dashboard/app/components/__tests__/AgentLogViewer.layout.test.tsx @@ -0,0 +1,367 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { AgentLogViewer } from "../AgentLogViewer"; +import { makeEntry, getScrollContainer } from "./AgentLogViewer.test-helpers"; +import "../../styles.css"; +import "../TaskDetailModal.css"; + +// Mock lucide-react icons used by AgentLogViewer and ProviderIcon +vi.mock("lucide-react", () => ({ + Maximize2: () => null, + Minimize2: () => null, + Loader2: () => null, + Cpu: () => null, + ChevronDown: () => null, + ChevronRight: () => null, +})); + +describe("AgentLogViewer", () => { + beforeEach(() => { + window.localStorage.clear(); + }); + + describe("horizontal overflow prevention", () => { + it("uses the scroll container class for overflow-x handling", () => { + const longString = "A".repeat(300); + const entries = [makeEntry({ text: longString })]; + const { container } = render(); + const scrollContainer = getScrollContainer(container); + expect(scrollContainer.classList.contains("agent-log-viewer-scroll")).toBe(true); + expect(scrollContainer.style.overflowX).toBe(""); + }); + + it("uses the scroll container class for overflow-wrap handling", () => { + const entries = [makeEntry({ text: "x".repeat(250) })]; + const { container } = render(); + const scrollContainer = getScrollContainer(container); + expect(scrollContainer.classList.contains("agent-log-viewer-scroll")).toBe(true); + expect(scrollContainer.style.overflowWrap).toBe(""); + }); + + it("renders pre elements with overflow-x auto for internal scrolling", () => { + const longLine = "const x = " + "'a'.repeat(500)"; + const entries = [makeEntry({ text: "```\n" + longLine + "\n```" })]; + const { container } = render(); + const pre = container.querySelector("pre") as HTMLElement; + expect(pre).toBeTruthy(); + expect(pre.style.overflowX).toBe("auto"); + expect(pre.style.maxWidth).toBe("100%"); + }); + + it("applies model-header wrapping via class", () => { + const entries = [makeEntry()]; + const { container } = render(); + const header = container.querySelector("[data-testid='agent-log-model-header']") as HTMLElement; + expect(header.classList.contains("agent-log-model-header")).toBe(true); + expect(header.style.flexWrap).toBe(""); + }); + }); + + describe("full-height layout", () => { + it("does not have a fixed maxHeight constraint", () => { + const entries = [makeEntry()]; + const { container } = render(); + const viewer = container.querySelector("[data-testid='agent-log-viewer']") as HTMLElement; + // The viewer should NOT have a maxHeight of 500px (the old fixed constraint) + expect(viewer.style.maxHeight).not.toBe("500px"); + // maxHeight should be empty (unset) so the viewer can grow to fill available space + expect(viewer.style.maxHeight).toBe(""); + }); + + it("uses class-based overflow-y scrolling on the entries container", () => { + const entries = [makeEntry()]; + const { container } = render(); + const scrollContainer = getScrollContainer(container); + // Scrolling behavior is now defined in CSS. + expect(scrollContainer.classList.contains("agent-log-viewer-scroll")).toBe(true); + expect(scrollContainer.style.overflowY).toBe(""); + }); + + it("uses agent-log-viewer--streaming class when entries are present", () => { + const entries = [makeEntry()]; + const { container } = render(); + const viewer = container.querySelector("[data-testid='agent-log-viewer']") as HTMLElement; + expect(viewer.classList.contains("agent-log-viewer")).toBe(true); + expect(viewer.classList.contains("agent-log-viewer--streaming")).toBe(true); + }); + + it("does not use streaming class on loading state", () => { + const { container } = render(); + const viewer = container.querySelector("[data-testid='agent-log-viewer']") as HTMLElement; + expect(viewer.classList.contains("agent-log-viewer")).toBe(true); + expect(viewer.classList.contains("agent-log-viewer--streaming")).toBe(false); + }); + }); + + describe("sticky header layout", () => { + it("renders the model header as a sibling of the scroll container", () => { + const entries = [makeEntry()]; + const { container } = render(); + const viewer = container.querySelector("[data-testid='agent-log-viewer']") as HTMLElement; + const header = screen.getByTestId("agent-log-model-header"); + const scrollContainer = getScrollContainer(container); + + expect(header.parentElement).toBe(viewer); + expect(scrollContainer.parentElement).toBe(viewer); + expect(scrollContainer.contains(header)).toBe(false); + }); + + it("renders log entry rows inside the scroll container", () => { + const entries = [ + makeEntry({ type: "text", text: "hello" }), + makeEntry({ type: "tool", text: "Bash" }), + ]; + const { container } = render(); + const scrollContainer = getScrollContainer(container); + + expect(scrollContainer.querySelector(".agent-log-text")).toBeTruthy(); + expect(scrollContainer.querySelector(".agent-log-tool")).toBeTruthy(); + }); + + it("renders pagination summary and load-more controls inside the scroll container", () => { + const entries = [makeEntry({ text: "hello" })]; + const { container } = render( + {}} + />, + ); + const scrollContainer = getScrollContainer(container); + + expect(scrollContainer.querySelector("[data-testid='agent-log-summary']")).toBeTruthy(); + expect(scrollContainer.querySelector("[data-testid='agent-log-load-more']")).toBeTruthy(); + }); + + it("renders the return-to-live button inside the scroll container", () => { + const entries = [ + makeEntry({ text: "first", timestamp: "2026-01-01T00:00:00Z" }), + makeEntry({ text: "second", timestamp: "2026-01-01T00:00:01Z" }), + ]; + const { container } = render(); + const scrollContainer = getScrollContainer(container); + + Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 1000 }); + Object.defineProperty(scrollContainer, "clientHeight", { configurable: true, value: 200 }); + + scrollContainer.scrollTop = 300; + fireEvent.scroll(scrollContainer); + + const returnToLive = screen.getByTestId("agent-log-return-to-live"); + expect(returnToLive.parentElement).toBe(scrollContainer); + }); + }); + + describe("auto-scroll behavior", () => { + it("scrolls to bottom when streaming updates arrive and user is near the bottom", () => { + const initialEntries = [ + makeEntry({ text: "first", timestamp: "2026-01-01T00:00:00Z" }), + ]; + const streamedEntries = [ + ...initialEntries, + makeEntry({ text: "second", timestamp: "2026-01-01T00:00:01Z" }), + ]; + + const { rerender, container } = render(); + const viewer = getScrollContainer(container); + + let scrollHeight = 600; + Object.defineProperty(viewer, "scrollHeight", { + configurable: true, + get: () => scrollHeight, + }); + + viewer.scrollTop = 560; + rerender(); + + scrollHeight = 720; + rerender(); + + expect(viewer.scrollTop).toBe(720); + }); + + it("does not auto-scroll when streaming updates arrive and user is reading older output", () => { + const initialEntries = [ + makeEntry({ text: "first", timestamp: "2026-01-01T00:00:00Z" }), + ]; + const streamedEntries = [ + ...initialEntries, + makeEntry({ text: "second", timestamp: "2026-01-01T00:00:01Z" }), + ]; + + const { rerender, container } = render(); + const viewer = getScrollContainer(container); + + let scrollHeight = 1000; + Object.defineProperty(viewer, "scrollHeight", { + configurable: true, + get: () => scrollHeight, + }); + + viewer.scrollTop = 220; + rerender(); + + scrollHeight = 1120; + rerender(); + + expect(viewer.scrollTop).toBe(220); + }); + + it("keeps viewport anchored when older history is prepended", () => { + const initialEntries = [ + makeEntry({ text: "recent", timestamp: "2026-01-01T00:00:00Z" }), + ]; + const olderLoadedEntries = [ + makeEntry({ text: "older", timestamp: "2025-12-31T23:59:00Z" }), + ...initialEntries, + ]; + + const { rerender, container } = render(); + const viewer = getScrollContainer(container); + + let scrollHeight = 900; + Object.defineProperty(viewer, "scrollHeight", { + configurable: true, + get: () => scrollHeight, + }); + + viewer.scrollTop = 260; + rerender(); + + scrollHeight = 1030; + rerender(); + + // Anchored by delta (1030 - 900): 260 + 130 + expect(viewer.scrollTop).toBe(390); + }); + + it("shows return-to-live button when user scrolls away from bottom", () => { + const entries = [ + makeEntry({ text: "first", timestamp: "2026-01-01T00:00:00Z" }), + makeEntry({ text: "second", timestamp: "2026-01-01T00:00:01Z" }), + ]; + const { container } = render(); + const viewer = getScrollContainer(container); + + Object.defineProperty(viewer, "scrollHeight", { configurable: true, value: 1000 }); + Object.defineProperty(viewer, "clientHeight", { configurable: true, value: 200 }); + + viewer.scrollTop = 300; + fireEvent.scroll(viewer); + + expect(screen.getByTestId("agent-log-return-to-live")).toBeTruthy(); + }); + + it("hides return-to-live button when user is following live output", () => { + const entries = [ + makeEntry({ text: "first", timestamp: "2026-01-01T00:00:00Z" }), + makeEntry({ text: "second", timestamp: "2026-01-01T00:00:01Z" }), + ]; + const { container } = render(); + const viewer = getScrollContainer(container); + + Object.defineProperty(viewer, "scrollHeight", { configurable: true, value: 1000 }); + Object.defineProperty(viewer, "clientHeight", { configurable: true, value: 200 }); + + viewer.scrollTop = 760; + fireEvent.scroll(viewer); + + expect(screen.queryByTestId("agent-log-return-to-live")).toBeNull(); + }); + + it("returns to bottom and resumes following when return-to-live is clicked", () => { + const entries = [ + makeEntry({ text: "first", timestamp: "2026-01-01T00:00:00Z" }), + makeEntry({ text: "second", timestamp: "2026-01-01T00:00:01Z" }), + ]; + const { container } = render(); + const viewer = getScrollContainer(container); + + Object.defineProperty(viewer, "scrollHeight", { configurable: true, value: 1000 }); + Object.defineProperty(viewer, "clientHeight", { configurable: true, value: 200 }); + + viewer.scrollTop = 280; + fireEvent.scroll(viewer); + + const returnButton = screen.getByTestId("agent-log-return-to-live"); + fireEvent.click(returnButton); + + expect(viewer.scrollTop).toBe(1000); + expect(screen.queryByTestId("agent-log-return-to-live")).toBeNull(); + }); + + it("re-pins to bottom on resize while following", () => { + const resizeCallbacks: Array<() => void> = []; + const originalResizeObserver = globalThis.ResizeObserver; + + class ResizeObserverMock { + constructor(callback: ResizeObserverCallback) { + resizeCallbacks.push(() => callback([], this as unknown as ResizeObserver)); + } + + observe() {} + unobserve() {} + disconnect() {} + } + + Object.defineProperty(globalThis, "ResizeObserver", { + configurable: true, + value: ResizeObserverMock, + }); + + try { + const entries = [ + makeEntry({ text: "first", timestamp: "2026-01-01T00:00:00Z" }), + makeEntry({ text: "second", timestamp: "2026-01-01T00:00:01Z" }), + ]; + const { container } = render(); + const viewer = getScrollContainer(container); + + Object.defineProperty(viewer, "scrollHeight", { configurable: true, value: 1200 }); + Object.defineProperty(viewer, "clientHeight", { configurable: true, value: 200 }); + viewer.scrollTop = 980; + fireEvent.scroll(viewer); + + viewer.scrollTop = 640; + resizeCallbacks.forEach((callback) => callback()); + + expect(viewer.scrollTop).toBe(1200); + } finally { + if (originalResizeObserver) { + Object.defineProperty(globalThis, "ResizeObserver", { + configurable: true, + value: originalResizeObserver, + }); + } else { + delete (globalThis as { ResizeObserver?: typeof ResizeObserver }).ResizeObserver; + } + } + }); + }); + + describe("pagination placement", () => { + it("renders the load-more control above the first log entry", () => { + const entries = [ + makeEntry({ text: "oldest", timestamp: "2026-01-01T00:00:00Z" }), + makeEntry({ text: "newest", timestamp: "2026-01-01T00:00:01Z" }), + ]; + + const { container } = render( + {}} + />, + ); + + const loadMore = screen.getByTestId("agent-log-load-more"); + const firstRow = container.querySelector(".agent-log-text") as HTMLElement; + expect(firstRow).toBeTruthy(); + + expect(loadMore.compareDocumentPosition(firstRow) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + }); + }); +}); diff --git a/packages/dashboard/app/components/__tests__/AgentLogViewer.markdown.test.tsx b/packages/dashboard/app/components/__tests__/AgentLogViewer.markdown.test.tsx new file mode 100644 index 0000000000..9bdec741de --- /dev/null +++ b/packages/dashboard/app/components/__tests__/AgentLogViewer.markdown.test.tsx @@ -0,0 +1,797 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { AgentLogViewer } from "../AgentLogViewer"; +import { FileBrowserProvider } from "../../context/FileBrowserContext"; +import { makeEntry, getScrollContainer } from "./AgentLogViewer.test-helpers"; +import "../../styles.css"; +import "../TaskDetailModal.css"; + +// Mock lucide-react icons used by AgentLogViewer and ProviderIcon +vi.mock("lucide-react", () => ({ + Maximize2: () => null, + Minimize2: () => null, + Loader2: () => null, + Cpu: () => null, + ChevronDown: () => null, + ChevronRight: () => null, +})); + +describe("AgentLogViewer", () => { + beforeEach(() => { + window.localStorage.clear(); + }); + + describe("long content preservation", () => { + it("renders very long text entries without truncation", () => { + const longText = "A".repeat(5000); + const entries = [makeEntry({ text: longText })]; + const { container } = render(); + const textSpans = container.querySelectorAll(".agent-log-text"); + expect(textSpans).toHaveLength(1); + expect(textSpans[0].textContent).toContain(longText); + }); + + it("renders very long detail text without truncation", () => { + const longDetail = "B".repeat(5000); + const entries = [makeEntry({ text: "Read", type: "tool", detail: longDetail })]; + const { container } = render(); + fireEvent.click(screen.getByTestId("tool-detail-toggle")); + const detail = container.querySelector(".agent-log-tool-detail"); + expect(detail).toBeTruthy(); + expect(detail!.textContent).toContain(longDetail); + expect(detail!.textContent!.length).toBe(5000); + }); + + it("renders multiline text content without truncation", () => { + const multilineText = [ + "## Analysis", + "", + "After reviewing the codebase:", + "", + "1. First issue found", + "2. Second issue found", + "", + "```typescript", + "const x = 1;", + "```", + "", + "Line " + "C".repeat(2000) + " end", + ].join("\n"); + const entries = [makeEntry({ text: multilineText })]; + const { container } = render(); + const textSpans = container.querySelectorAll(".agent-log-text"); + expect(textSpans).toHaveLength(1); + // The markdown-rendered content should still contain the essential parts + expect(textSpans[0].textContent).toContain("Analysis"); + expect(textSpans[0].textContent).toContain("First issue found"); + expect(textSpans[0].textContent).toContain("const x = 1"); + }); + + it("renders long tool_result detail without truncation", () => { + const longDetail = "D".repeat(5000); + const entries = [makeEntry({ text: "Bash", type: "tool_result", detail: longDetail })]; + const { container } = render(); + fireEvent.click(screen.getByTestId("tool-detail-toggle")); + const detail = container.querySelector(".agent-log-tool-detail"); + expect(detail).toBeTruthy(); + expect(detail!.textContent).toContain(longDetail); + }); + + it("renders long tool_error detail without truncation", () => { + const longDetail = "E".repeat(5000); + const entries = [makeEntry({ text: "Write", type: "tool_error", detail: longDetail })]; + const { container } = render(); + fireEvent.click(screen.getByTestId("tool-detail-toggle")); + const detail = container.querySelector(".agent-log-tool-detail"); + expect(detail).toBeTruthy(); + expect(detail!.textContent).toContain(longDetail); + }); + + it("preserves raw whitespace in tool detail blocks", () => { + const detailText = "stdout:\n line one\n indented line two\n"; + const entries = [makeEntry({ text: "Bash", type: "tool_result", detail: detailText })]; + const { container } = render(); + fireEvent.click(screen.getByTestId("tool-detail-toggle")); + const detail = container.querySelector(".agent-log-tool-detail") as HTMLElement; + expect(detail).toBeTruthy(); + expect(detail.tagName).toBe("PRE"); + expect(detail.textContent).toBe(detailText); + }); + }); + + describe("markdown rendering", () => { + it("renders plain text without markdown correctly", () => { + const entries = [ + makeEntry({ text: "Hello world, this is plain text." }), + ]; + const { container } = render(); + const textSpans = container.querySelectorAll(".agent-log-text"); + expect(textSpans).toHaveLength(1); + expect(textSpans[0].textContent).toContain("Hello world, this is plain text."); + }); + + it("renders text entries inside markdown-body in markdown mode", () => { + const entries = [ + makeEntry({ text: "Paragraph one\n\nParagraph two" }), + ]; + const { container } = render(); + const textRow = container.querySelector(".agent-log-text") as HTMLElement; + expect(textRow).toBeTruthy(); + + const proseContainer = textRow.querySelector(".markdown-body") as HTMLElement; + expect(proseContainer).toBeTruthy(); + expect(proseContainer.querySelectorAll("p")).toHaveLength(2); + }); + + it("renders thinking entries inside markdown-body in markdown mode", () => { + const entries = [ + makeEntry({ text: "Considering:\n\n- option A\n- option B", type: "thinking" }), + ]; + const { container } = render(); + const thinkingRow = container.querySelector(".agent-log-thinking") as HTMLElement; + expect(thinkingRow).toBeTruthy(); + + const proseContainer = thinkingRow.querySelector(".markdown-body") as HTMLElement; + expect(proseContainer).toBeTruthy(); + expect(proseContainer.querySelector("ul")).toBeTruthy(); + }); + + it("renders inline markdown elements (bold, italic, inline code)", () => { + const entries = [ + makeEntry({ text: "This is **bold** and *italic* with `inline code`." }), + ]; + const { container } = render(); + const textSpans = container.querySelectorAll(".agent-log-text"); + expect(textSpans).toHaveLength(1); + // Check that the markdown elements are rendered + const strong = textSpans[0].querySelector("strong"); + expect(strong).toBeTruthy(); + expect(strong!.textContent).toBe("bold"); + const em = textSpans[0].querySelector("em"); + expect(em).toBeTruthy(); + expect(em!.textContent).toBe("italic"); + const code = textSpans[0].querySelector("code"); + expect(code).toBeTruthy(); + expect(code!.textContent).toBe("inline code"); + }); + + it("renders file links inside inline code with the code wrapper preserved", () => { + const openFile = vi.fn(); + const entries = [ + makeEntry({ text: "Check `packages/engine/src/scheduler.ts:7` now." }), + ]; + const { container } = render( + + + , + ); + + const fileLink = screen.getByRole("button", { name: "packages/engine/src/scheduler.ts:7" }); + const code = fileLink.closest("code"); + expect(code).toBeTruthy(); + expect(code?.querySelector("button.file-path-link")).toBe(fileLink); + + fireEvent.click(fileLink); + expect(openFile).toHaveBeenCalledWith("packages/engine/src/scheduler.ts", { line: 7, col: undefined }); + expect(container.querySelectorAll("code button.file-path-link")).toHaveLength(1); + }); + + it("renders code blocks with GFM support", () => { + const entries = [ + makeEntry({ text: "```typescript\nconst x = 1;\n```" }), + ]; + const { container } = render(); + const textSpans = container.querySelectorAll(".agent-log-text"); + expect(textSpans).toHaveLength(1); + // Check that code block is rendered + const pre = textSpans[0].querySelector("pre"); + expect(pre).toBeTruthy(); + const code = pre!.querySelector("code"); + expect(code).toBeTruthy(); + expect(code!.textContent).toContain("const x = 1"); + }); + + it("renders GFM task lists", () => { + const entries = [ + makeEntry({ text: "- [x] Completed task\n- [ ] Pending task" }), + ]; + const { container } = render(); + const textSpans = container.querySelectorAll(".agent-log-text"); + expect(textSpans).toHaveLength(1); + // Check that task list is rendered + const ul = textSpans[0].querySelector("ul"); + expect(ul).toBeTruthy(); + const taskListItems = ul!.querySelectorAll("li"); + expect(taskListItems).toHaveLength(2); + // Check checkboxes + const checkboxes = ul!.querySelectorAll('input[type="checkbox"]'); + expect(checkboxes).toHaveLength(2); + expect((checkboxes[0] as HTMLInputElement).checked).toBe(true); + expect((checkboxes[1] as HTMLInputElement).checked).toBe(false); + }); + + it("renders blockquotes", () => { + const entries = [ + makeEntry({ text: "> This is a blockquote\n> with multiple lines" }), + ]; + const { container } = render(); + const textSpans = container.querySelectorAll(".agent-log-text"); + expect(textSpans).toHaveLength(1); + // Check that blockquote is rendered + const blockquote = textSpans[0].querySelector("blockquote"); + expect(blockquote).toBeTruthy(); + expect(blockquote!.textContent).toContain("This is a blockquote"); + }); + + it("renders markdown in thinking entries", () => { + const entries = [ + makeEntry({ text: "Let me think about **this problem**...", type: "thinking" }), + ]; + const { container } = render(); + const thinkingSpans = container.querySelectorAll(".agent-log-thinking"); + expect(thinkingSpans).toHaveLength(1); + const strong = thinkingSpans[0].querySelector("strong"); + expect(strong).toBeTruthy(); + expect(strong!.textContent).toBe("this problem"); + }); + + it("renders mixed content with markdown and plain text", () => { + const entries = [ + makeEntry({ text: "The code:\n\n```js\nconsole.log('hello');\n```\n\nworks!" }), + ]; + const { container } = render(); + const textSpans = container.querySelectorAll(".agent-log-text"); + expect(textSpans).toHaveLength(1); + const pre = textSpans[0].querySelector("pre"); + expect(pre).toBeTruthy(); + // Plain text before and after should be preserved + expect(textSpans[0].textContent).toContain("The code:"); + expect(textSpans[0].textContent).toContain("works!"); + }); + }); + + describe("markdown render toggle", () => { + it("renders the toggle button in the model info header", () => { + const entries = [makeEntry()]; + const { container } = render(); + const toggle = container.querySelector("[data-testid='agent-log-mode-toggle']"); + expect(toggle).toBeTruthy(); + expect(toggle!.textContent).toBe("Markdown"); + }); + + it("defaults to markdown mode", () => { + const entries = [makeEntry({ text: "**bold** text" })]; + const { container } = render(); + // In markdown mode, bold should be rendered as + const textSpans = container.querySelectorAll(".agent-log-text"); + const strong = textSpans[0].querySelector("strong"); + expect(strong).toBeTruthy(); + expect(strong!.textContent).toBe("bold"); + }); + + it("has correct aria attributes on the toggle", () => { + const entries = [makeEntry()]; + const { container } = render(); + const toggle = container.querySelector("[data-testid='agent-log-mode-toggle']") as HTMLButtonElement; + expect(toggle).toBeTruthy(); + expect(toggle.getAttribute("aria-pressed")).toBe("true"); + expect(toggle.getAttribute("aria-label")).toBe("Switch to plain text mode"); + }); + + it("FN-3847: uses accent text color for pressed markdown/tools toggles", () => { + window.localStorage.setItem("fn-agent-log-markdown", "true"); + window.localStorage.setItem("fn-agent-log-tool-output", "true"); + const entries = [makeEntry({ text: "hello" })]; + const { container } = render(); + + const markdownToggle = container.querySelector("[data-testid='agent-log-mode-toggle']") as HTMLButtonElement; + const toolsToggle = container.querySelector("[data-testid='agent-log-tool-output-toggle']") as HTMLButtonElement; + const fullscreenToggle = container.querySelector("[data-testid='agent-log-fullscreen-toggle']") as HTMLButtonElement; + + expect(markdownToggle.getAttribute("aria-pressed")).toBe("true"); + expect(toolsToggle.getAttribute("aria-pressed")).toBe("true"); + + const markdownColor = getComputedStyle(markdownToggle).color; + const toolsColor = getComputedStyle(toolsToggle).color; + const unpressedColor = getComputedStyle(fullscreenToggle).color; + + // Contract: unpressed toggles keep the shared muted button color, pressed toggles switch to accent foreground. + expect(markdownColor.length).toBeGreaterThan(0); + expect(toolsColor.length).toBeGreaterThan(0); + expect(markdownColor).toBe(toolsColor); + expect(markdownColor).not.toBe(unpressedColor); + }); + + it("switches to plain text mode when clicked", () => { + const entries = [makeEntry({ text: "**bold** and *italic*" })]; + const { container } = render(); + const toggle = container.querySelector("[data-testid='agent-log-mode-toggle']") as HTMLButtonElement; + + // Markdown mode starts with prose container + rendered markdown + const markdownModeTextRow = container.querySelector(".agent-log-text") as HTMLElement; + expect(markdownModeTextRow.querySelector(".markdown-body")).toBeTruthy(); + expect(markdownModeTextRow.querySelector("strong")?.textContent).toBe("bold"); + + // Click to switch to plain text mode + fireEvent.click(toggle); + + // Button should update + expect(toggle.textContent).toBe("Plain"); + expect(toggle.getAttribute("aria-pressed")).toBe("false"); + expect(toggle.getAttribute("aria-label")).toBe("Switch to markdown mode"); + + // Text should now show raw markdown syntax literally + const textSpans = container.querySelectorAll(".agent-log-text"); + expect(textSpans).toHaveLength(1); + expect(textSpans[0].textContent).toContain("**bold** and *italic*"); + const plainBlock = textSpans[0].querySelector(".agent-log-plain-block") as HTMLElement; + expect(plainBlock).toBeTruthy(); + // Plain mode should remove markdown rendering/prose container + expect(textSpans[0].querySelector(".markdown-body")).toBeNull(); + expect(textSpans[0].querySelector("strong")).toBeNull(); + expect(textSpans[0].querySelector("em")).toBeNull(); + }); + + it("concatenates grouped text into a single markdown render", () => { + const entries = [ + makeEntry({ text: "**bold", type: "text", agent: "executor" }), + makeEntry({ text: "** text", type: "text", agent: "executor" }), + ]; + const { container } = render(); + + const textRows = container.querySelectorAll(".agent-log-text"); + expect(textRows).toHaveLength(1); + expect(textRows[0].querySelectorAll(".markdown-body")).toHaveLength(1); + const strong = textRows[0].querySelector("strong"); + expect(strong).toBeTruthy(); + expect(strong!.textContent).toBe("bold"); + }); + + it("joins grouped chunks inline in plain text mode", () => { + const entries = [ + makeEntry({ text: "hello", type: "text", agent: "executor" }), + makeEntry({ text: " world", type: "text", agent: "executor" }), + ]; + const { container } = render(); + const toggle = container.querySelector("[data-testid='agent-log-mode-toggle']") as HTMLButtonElement; + + fireEvent.click(toggle); + + const textRows = container.querySelectorAll(".agent-log-text"); + expect(textRows).toHaveLength(1); + expect(textRows[0].querySelector(".markdown-body")).toBeNull(); + expect(textRows[0].textContent).toContain("hello world"); + }); + + it("toggles back to markdown mode from plain text", () => { + const entries = [makeEntry({ text: "**bold** text" })]; + const { container } = render(); + const toggle = container.querySelector("[data-testid='agent-log-mode-toggle']") as HTMLButtonElement; + + // Switch to plain text + fireEvent.click(toggle); + expect(toggle.textContent).toBe("Plain"); + + // Switch back to markdown + fireEvent.click(toggle); + expect(toggle.textContent).toBe("Markdown"); + + // Markdown elements should be present again + const textSpans = container.querySelectorAll(".agent-log-text"); + const strong = textSpans[0].querySelector("strong"); + expect(strong).toBeTruthy(); + expect(strong!.textContent).toBe("bold"); + }); + + it("preserves line breaks in plain text mode for thinking entries", () => { + const entries = [makeEntry({ text: "line1\nline2\nline3", type: "thinking", agent: "executor" })]; + const { container } = render(); + const toggle = container.querySelector("[data-testid='agent-log-mode-toggle']") as HTMLButtonElement; + + fireEvent.click(toggle); + + const plainThinking = container.querySelector(".agent-log-thinking .agent-log-plain-block") as HTMLElement; + expect(plainThinking).toBeTruthy(); + expect(plainThinking.textContent).toContain("line1\nline2\nline3"); + }); + + it("shows raw markdown syntax literally in plain text mode for text entries", () => { + const entries = [ + makeEntry({ text: "## Heading\n\n- item 1\n- item 2\n\n`code` and **bold**" }), + ]; + const { container } = render(); + const toggle = container.querySelector("[data-testid='agent-log-mode-toggle']") as HTMLButtonElement; + + // Switch to plain text + fireEvent.click(toggle); + + const textSpans = container.querySelectorAll(".agent-log-text"); + expect(textSpans).toHaveLength(1); + // Raw markdown syntax should appear literally + expect(textSpans[0].textContent).toContain("## Heading"); + expect(textSpans[0].textContent).toContain("- item 1"); + expect(textSpans[0].textContent).toContain("`code`"); + expect(textSpans[0].textContent).toContain("**bold**"); + // No rendered markdown elements + expect(textSpans[0].querySelector("h2")).toBeNull(); + expect(textSpans[0].querySelector("ul")).toBeNull(); + expect(textSpans[0].querySelector("code")).toBeNull(); + expect(textSpans[0].querySelector("strong")).toBeNull(); + }); + + it("respects toggle for thinking entries", () => { + const entries = [ + makeEntry({ text: "Thinking about **this**", type: "thinking" }), + ]; + const { container } = render(); + const toggle = container.querySelector("[data-testid='agent-log-mode-toggle']") as HTMLButtonElement; + + // In markdown mode, bold is rendered + const thinkingSpans = container.querySelectorAll(".agent-log-thinking"); + expect(thinkingSpans[0].querySelector("strong")).toBeTruthy(); + + // Switch to plain text + fireEvent.click(toggle); + + const thinkingSpansUpdated = container.querySelectorAll(".agent-log-thinking"); + expect(thinkingSpansUpdated[0].textContent).toContain("Thinking about **this**"); + expect(thinkingSpansUpdated[0].querySelector("strong")).toBeNull(); + }); + + it("does not affect tool entries in either mode", () => { + const entries = [ + makeEntry({ text: "Read", type: "tool" }), + makeEntry({ text: "done", type: "tool_result" }), + makeEntry({ text: "fail", type: "tool_error" }), + ]; + const { container } = render(); + const toggle = container.querySelector("[data-testid='agent-log-mode-toggle']") as HTMLButtonElement; + + // Tool entries in markdown mode + expect(container.querySelector(".agent-log-tool")!.textContent).toContain("Read"); + expect(container.querySelector(".agent-log-tool-result")!.textContent).toContain("done"); + expect(container.querySelector(".agent-log-tool-error")!.textContent).toContain("fail"); + + // Switch to plain text - tool entries should be unchanged + fireEvent.click(toggle); + + expect(container.querySelector(".agent-log-tool")!.textContent).toContain("Read"); + expect(container.querySelector(".agent-log-tool-result")!.textContent).toContain("done"); + expect(container.querySelector(".agent-log-tool-error")!.textContent).toContain("fail"); + }); + + it("safely renders HTML tags as text in plain text mode (no XSS)", () => { + const entries = [ + makeEntry({ text: ' and bold' }), + ]; + const { container } = render(); + const toggle = container.querySelector("[data-testid='agent-log-mode-toggle']") as HTMLButtonElement; + + // Switch to plain text + fireEvent.click(toggle); + + const textSpans = container.querySelectorAll(".agent-log-text"); + // The text content should contain the literal HTML tags + expect(textSpans[0].textContent).toContain(''); + expect(textSpans[0].textContent).toContain("bold"); + // No actual script or bold HTML elements should be rendered + expect(textSpans[0].querySelector("script")).toBeNull(); + expect(textSpans[0].querySelector("b")).toBeNull(); + }); + + it("safely renders HTML in markdown mode via react-markdown sanitization", () => { + const entries = [ + makeEntry({ text: "**safe** text here" }), + ]; + const { container } = render(); + // In markdown mode, react-markdown sanitizes HTML (no script execution) + const textSpans = container.querySelectorAll(".agent-log-text"); + // Markdown formatting should work + const strong = textSpans[0].querySelector("strong"); + expect(strong).toBeTruthy(); + expect(strong!.textContent).toBe("safe"); + // No script elements are rendered for any HTML content in markdown + expect(textSpans[0].querySelector("script")).toBeNull(); + }); + }); + + describe("tool output toggle", () => { + it("renders the tool output toggle defaulting to On", () => { + const entries = [makeEntry()]; + const { container } = render(); + const toggle = container.querySelector("[data-testid='agent-log-tool-output-toggle']") as HTMLButtonElement; + expect(toggle).toBeTruthy(); + expect(toggle.textContent).toBe("Tools: On"); + expect(toggle.getAttribute("aria-pressed")).toBe("true"); + }); + + it("hides tool entries when toggled off and shows them again when toggled back on", () => { + const entries = [ + makeEntry({ text: "before tool", type: "text", agent: "executor" }), + makeEntry({ text: "Read", type: "tool", agent: "executor" }), + makeEntry({ text: "done", type: "tool_result", agent: "executor" }), + makeEntry({ text: "fail", type: "tool_error", agent: "executor" }), + makeEntry({ text: "after tool", type: "text", agent: "executor" }), + ]; + const { container } = render(); + const toggle = container.querySelector("[data-testid='agent-log-tool-output-toggle']") as HTMLButtonElement; + + expect(container.querySelector(".agent-log-tool")).toBeTruthy(); + expect(container.querySelector(".agent-log-tool-result")).toBeTruthy(); + expect(container.querySelector(".agent-log-tool-error")).toBeTruthy(); + + fireEvent.click(toggle); + expect(toggle.textContent).toBe("Tools: Off"); + expect(toggle.getAttribute("aria-pressed")).toBe("false"); + + expect(container.querySelector(".agent-log-tool")).toBeNull(); + expect(container.querySelector(".agent-log-tool-result")).toBeNull(); + expect(container.querySelector(".agent-log-tool-error")).toBeNull(); + const textRows = container.querySelectorAll(".agent-log-text"); + const combined = Array.from(textRows).map((r) => r.textContent).join(" "); + expect(combined).toContain("before tool"); + expect(combined).toContain("after tool"); + + fireEvent.click(toggle); + expect(toggle.textContent).toBe("Tools: On"); + expect(container.querySelector(".agent-log-tool")).toBeTruthy(); + expect(container.querySelector(".agent-log-tool-result")).toBeTruthy(); + expect(container.querySelector(".agent-log-tool-error")).toBeTruthy(); + }); + + it("keeps the latest non-tool message visible as its own row when tools are hidden", () => { + const entries = [ + makeEntry({ text: "Starting plan", type: "text", agent: "executor", timestamp: "2026-01-01T00:00:00Z" }), + makeEntry({ text: "read file", type: "tool", agent: "executor", timestamp: "2026-01-01T00:00:01Z" }), + makeEntry({ text: "Final answer", type: "text", agent: "executor", timestamp: "2026-01-01T00:00:02Z" }), + ]; + const { container } = render(); + const toggle = container.querySelector("[data-testid='agent-log-tool-output-toggle']") as HTMLButtonElement; + + fireEvent.click(toggle); + + const textRows = container.querySelectorAll(".agent-log-text"); + expect(textRows).toHaveLength(2); + expect(textRows[0].textContent).toContain("Starting plan"); + expect(textRows[1].textContent).toContain("Final answer"); + expect(container.querySelectorAll(".agent-log-agent-badge")).toHaveLength(2); + expect(container.querySelectorAll(".agent-log-timestamp")).toHaveLength(2); + }); + + it("does not render any tool log entries when off (only agent text)", () => { + const entries = [ + makeEntry({ text: "Read", type: "tool", agent: "executor", detail: "some/path" }), + makeEntry({ text: "thinking out loud", type: "thinking", agent: "executor" }), + ]; + const { container } = render(); + const toggle = container.querySelector("[data-testid='agent-log-tool-output-toggle']") as HTMLButtonElement; + fireEvent.click(toggle); + + expect(container.querySelector(".agent-log-tool")).toBeNull(); + expect(container.querySelector("[data-testid='tool-detail-toggle']")).toBeNull(); + expect(container.querySelector(".agent-log-thinking")).toBeTruthy(); + }); + + it("reflects hidden tool entries in the pagination summary", () => { + const entries = [ + makeEntry({ text: "hi", type: "text" }), + makeEntry({ text: "Read", type: "tool" }), + makeEntry({ text: "done", type: "tool_result" }), + ]; + const { container } = render( + , + ); + const toggle = container.querySelector("[data-testid='agent-log-tool-output-toggle']") as HTMLButtonElement; + fireEvent.click(toggle); + + const summary = container.querySelector("[data-testid='agent-log-summary']") as HTMLElement; + expect(summary).toBeTruthy(); + expect(summary.textContent).toContain("Showing 1 of 3 entries"); + expect(summary.textContent).toContain("2 tool entries hidden"); + }); + }); + + describe("toggle persistence across remounts", () => { + it("persists the markdown toggle state in localStorage", () => { + const entries = [makeEntry()]; + const first = render(); + const toggle = first.container.querySelector( + "[data-testid='agent-log-mode-toggle']", + ) as HTMLButtonElement; + fireEvent.click(toggle); + expect(window.localStorage.getItem("fn-agent-log-markdown")).toBe("false"); + first.unmount(); + + const second = render(); + const restoredToggle = second.container.querySelector( + "[data-testid='agent-log-mode-toggle']", + ) as HTMLButtonElement; + expect(restoredToggle.textContent).toBe("Plain"); + expect(restoredToggle.getAttribute("aria-pressed")).toBe("false"); + }); + + it("persists the tool output toggle state in localStorage", () => { + const entries = [ + makeEntry({ text: "Read", type: "tool" }), + makeEntry({ text: "hi", type: "text" }), + ]; + const first = render(); + const toggle = first.container.querySelector( + "[data-testid='agent-log-tool-output-toggle']", + ) as HTMLButtonElement; + fireEvent.click(toggle); + expect(window.localStorage.getItem("fn-agent-log-tool-output")).toBe("false"); + first.unmount(); + + const second = render(); + const restoredToggle = second.container.querySelector( + "[data-testid='agent-log-tool-output-toggle']", + ) as HTMLButtonElement; + expect(restoredToggle.textContent).toBe("Tools: Off"); + expect(second.container.querySelector(".agent-log-tool")).toBeNull(); + }); + + it("uses default true values when no preference is stored", () => { + const entries = [makeEntry({ text: "Read", type: "tool" })]; + const { container } = render(); + const markdown = container.querySelector( + "[data-testid='agent-log-mode-toggle']", + ) as HTMLButtonElement; + const tools = container.querySelector( + "[data-testid='agent-log-tool-output-toggle']", + ) as HTMLButtonElement; + expect(markdown.textContent).toBe("Markdown"); + expect(tools.textContent).toBe("Tools: On"); + }); + }); + + describe("fullscreen toggle", () => { + it("applies matching min dimensions to markdown and fullscreen header toggles", () => { + const entries = [makeEntry()]; + const { container } = render(); + const markdownToggle = container.querySelector( + "[data-testid='agent-log-mode-toggle']", + ) as HTMLButtonElement; + const fullscreenToggle = container.querySelector( + "[data-testid='agent-log-fullscreen-toggle']", + ) as HTMLButtonElement; + + const markdownStyle = getComputedStyle(markdownToggle); + const fullscreenStyle = getComputedStyle(fullscreenToggle); + + expect(markdownStyle.minWidth).toBe(fullscreenStyle.minWidth); + expect(markdownStyle.minHeight).toBe(fullscreenStyle.minHeight); + expect(markdownStyle.minWidth).not.toBe("0px"); + expect(markdownStyle.minHeight).not.toBe("0px"); + }); + + it("adds visible gap spacing between header toggle buttons", () => { + const entries = [makeEntry()]; + const { container } = render(); + const toggleGroup = container.querySelector(".agent-log-model-header-toggle") as HTMLElement; + const toggleGroupStyle = getComputedStyle(toggleGroup); + + expect(toggleGroupStyle.gap).not.toBe(""); + expect(toggleGroupStyle.gap).not.toBe("normal"); + }); + + it("renders the fullscreen toggle button in the model info header", () => { + const entries = [makeEntry()]; + const { container } = render(); + const toggle = container.querySelector("[data-testid='agent-log-fullscreen-toggle']"); + expect(toggle).toBeTruthy(); + }); + + it("has correct aria attributes on the fullscreen toggle", () => { + const entries = [makeEntry()]; + const { container } = render(); + const toggle = container.querySelector("[data-testid='agent-log-fullscreen-toggle']") as HTMLButtonElement; + expect(toggle).toBeTruthy(); + expect(toggle.getAttribute("aria-label")).toBe("Expand agent log to full screen"); + expect(toggle.getAttribute("title")).toBe("Expand agent log to full screen"); + }); + + it("adds fullscreen class when toggle is clicked", () => { + const entries = [makeEntry()]; + const { container } = render(); + const viewer = container.querySelector("[data-testid='agent-log-viewer']") as HTMLElement; + const toggle = container.querySelector("[data-testid='agent-log-fullscreen-toggle']") as HTMLButtonElement; + + // Initially not fullscreen + expect(viewer.classList.contains("agent-log-viewer--fullscreen")).toBe(false); + + // Click to enter fullscreen + fireEvent.click(toggle); + + // Should have fullscreen class + expect(viewer.classList.contains("agent-log-viewer--fullscreen")).toBe(true); + }); + + it("removes fullscreen class when toggle is clicked while in fullscreen", () => { + const entries = [makeEntry()]; + const { container } = render(); + const viewer = container.querySelector("[data-testid='agent-log-viewer']") as HTMLElement; + const toggle = container.querySelector("[data-testid='agent-log-fullscreen-toggle']") as HTMLButtonElement; + + // Enter fullscreen + fireEvent.click(toggle); + expect(viewer.classList.contains("agent-log-viewer--fullscreen")).toBe(true); + + // Exit fullscreen + fireEvent.click(toggle); + expect(viewer.classList.contains("agent-log-viewer--fullscreen")).toBe(false); + }); + + it("updates aria label when toggling fullscreen", () => { + const entries = [makeEntry()]; + const { container } = render(); + const toggle = container.querySelector("[data-testid='agent-log-fullscreen-toggle']") as HTMLButtonElement; + + // Initially shows expand label + expect(toggle.getAttribute("aria-label")).toBe("Expand agent log to full screen"); + + // Enter fullscreen + fireEvent.click(toggle); + expect(toggle.getAttribute("aria-label")).toBe("Exit full screen"); + + // Exit fullscreen + fireEvent.click(toggle); + expect(toggle.getAttribute("aria-label")).toBe("Expand agent log to full screen"); + }); + + it("exits fullscreen when Escape key is pressed", () => { + const entries = [makeEntry()]; + const { container } = render(); + const viewer = container.querySelector("[data-testid='agent-log-viewer']") as HTMLElement; + const toggle = container.querySelector("[data-testid='agent-log-fullscreen-toggle']") as HTMLButtonElement; + + // Enter fullscreen + fireEvent.click(toggle); + expect(viewer.classList.contains("agent-log-viewer--fullscreen")).toBe(true); + + // Press Escape to exit + fireEvent.keyDown(document, { key: "Escape" }); + + expect(viewer.classList.contains("agent-log-viewer--fullscreen")).toBe(false); + }); + + it("does nothing when Escape key is pressed while not in fullscreen", () => { + const entries = [makeEntry()]; + const { container } = render(); + const viewer = container.querySelector("[data-testid='agent-log-viewer']") as HTMLElement; + const toggle = container.querySelector("[data-testid='agent-log-fullscreen-toggle']") as HTMLButtonElement; + + // Initially not fullscreen + expect(viewer.classList.contains("agent-log-viewer--fullscreen")).toBe(false); + + // Press Escape - should do nothing + fireEvent.keyDown(document, { key: "Escape" }); + + expect(viewer.classList.contains("agent-log-viewer--fullscreen")).toBe(false); + + // Toggle should still work normally + fireEvent.click(toggle); + expect(viewer.classList.contains("agent-log-viewer--fullscreen")).toBe(true); + }); + + it("only responds to Escape key when in fullscreen mode", () => { + const entries = [makeEntry()]; + const { container, unmount } = render(); + const viewer = container.querySelector("[data-testid='agent-log-viewer']") as HTMLElement; + const toggle = container.querySelector("[data-testid='agent-log-fullscreen-toggle']") as HTMLButtonElement; + + // Press Escape when not fullscreen - no effect + fireEvent.keyDown(document, { key: "Escape" }); + expect(viewer.classList.contains("agent-log-viewer--fullscreen")).toBe(false); + + // Enter fullscreen + fireEvent.click(toggle); + expect(viewer.classList.contains("agent-log-viewer--fullscreen")).toBe(true); + + // Clean up to remove the keydown listener + unmount(); + + // Verify the listener was removed (no errors should occur when Escape is pressed after unmount) + }); + }); +}); diff --git a/packages/dashboard/app/components/__tests__/AgentLogViewer.rendering.test.tsx b/packages/dashboard/app/components/__tests__/AgentLogViewer.rendering.test.tsx new file mode 100644 index 0000000000..ab74044203 --- /dev/null +++ b/packages/dashboard/app/components/__tests__/AgentLogViewer.rendering.test.tsx @@ -0,0 +1,410 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { AgentLogViewer } from "../AgentLogViewer"; +import { FileBrowserProvider } from "../../context/FileBrowserContext"; +import { makeEntry, getScrollContainer } from "./AgentLogViewer.test-helpers"; +import "../../styles.css"; +import "../TaskDetailModal.css"; + +// Mock lucide-react icons used by AgentLogViewer and ProviderIcon +vi.mock("lucide-react", () => ({ + Maximize2: () => null, + Minimize2: () => null, + Loader2: () => null, + Cpu: () => null, + ChevronDown: () => null, + ChevronRight: () => null, +})); + +describe("AgentLogViewer", () => { + beforeEach(() => { + window.localStorage.clear(); + }); + + it("shows loading message when loading with no entries", () => { + render(); + expect(screen.getByRole("status")).toHaveTextContent("Loading agent logs…"); + expect(screen.queryByText("No agent output yet.")).toBeNull(); + }); + + it("shows empty message when no entries and not loading", () => { + render(); + expect(screen.getByText("No agent output yet.")).toBeTruthy(); + }); + + it("rerenders from empty state to populated logs without changing hook order", () => { + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const entry = makeEntry({ text: "streamed chunk" }); + const { rerender } = render(); + + expect(() => { + rerender(); + }).not.toThrow(); + + expect(screen.getByText("streamed chunk")).toBeTruthy(); + consoleErrorSpy.mockRestore(); + }); + + it("renders grouped text entries in chronological order (oldest first)", () => { + const entries = [ + makeEntry({ text: "first chunk", agent: "executor" }), + makeEntry({ text: " second chunk", agent: "executor" }), + ]; + const { container } = render(); + const textSpans = container.querySelectorAll(".agent-log-text"); + expect(textSpans).toHaveLength(1); + expect(textSpans[0].textContent).toContain("first chunk second chunk"); + }); + + it("preserves just now output for future timestamps", () => { + const futureTimestamp = new Date(Date.now() + 30_000).toISOString(); + + render(); + + expect(screen.getByTestId("agent-log-timestamp")).toHaveTextContent("just now"); + }); + + it("keeps existing DOM rows stable when a new live entry appears at the bottom", () => { + const initialEntries = [ + makeEntry({ text: "first chunk", timestamp: "2026-01-01T00:00:00Z", agent: "triage" }), + makeEntry({ text: "second chunk", timestamp: "2026-01-01T00:00:01Z", agent: "executor" }), + ]; + + const { container, rerender } = render( + , + ); + + const initialTextRows = container.querySelectorAll(".agent-log-text"); + const firstChunkNode = initialTextRows[0] as HTMLElement; + const secondChunkNode = initialTextRows[1] as HTMLElement; + expect(firstChunkNode.textContent).toContain("first chunk"); + expect(secondChunkNode.textContent).toContain("second chunk"); + + const withLiveUpdate = [ + ...initialEntries, + makeEntry({ text: "third chunk", timestamp: "2026-01-01T00:00:02Z", agent: "reviewer" }), + ]; + + rerender(); + + const updatedTextRows = container.querySelectorAll(".agent-log-text"); + expect(updatedTextRows).toHaveLength(3); + expect(updatedTextRows[0].textContent).toContain("first chunk"); + expect(updatedTextRows[1].textContent).toContain("second chunk"); + expect(updatedTextRows[2].textContent).toContain("third chunk"); + expect(updatedTextRows[0]).toBe(firstChunkNode); + expect(updatedTextRows[1]).toBe(secondChunkNode); + }); + + it("avoids duplicate-key collisions when entries are exact duplicates", () => { + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const duplicateEntry = makeEntry({ + timestamp: "2026-01-01T00:00:00Z", + taskId: "FN-001", + text: "same chunk", + type: "text", + agent: "executor", + detail: "same detail", + }); + + const { container, rerender } = render( + , + ); + + rerender( + , + ); + + expect(container.querySelectorAll(".agent-log-text")).toHaveLength(1); + expect( + consoleErrorSpy.mock.calls.some((call) => + String(call[0]).includes("Encountered two children with the same key"), + ), + ).toBe(false); + + consoleErrorSpy.mockRestore(); + }); + + it("renders file paths in plain log lines as clickable file-browser links", async () => { + const openFile = vi.fn(); + render( + + + , + ); + + fireEvent.click(screen.getByRole("button", { name: "packages/engine/src/scheduler.ts" })); + expect(openFile).toHaveBeenCalledWith("packages/engine/src/scheduler.ts", { line: undefined, col: undefined }); + }); + + it("renders tool entries with distinct styling", () => { + const entries = [ + makeEntry({ text: "Read", type: "tool" }), + ]; + const { container } = render(); + const toolDiv = container.querySelector(".agent-log-tool"); + expect(toolDiv).toBeTruthy(); + expect(toolDiv!.textContent).toContain("Read"); + }); + + it("renders a mix of text and tool entries in chronological order", () => { + const entries = [ + makeEntry({ text: "Starting...", type: "text" }), + makeEntry({ text: "Bash", type: "tool" }), + makeEntry({ text: "Done!", type: "text" }), + ]; + const { container } = render(); + const textSpans = container.querySelectorAll(".agent-log-text"); + expect(textSpans).toHaveLength(2); + expect(textSpans[0].textContent).toContain("Starting..."); + expect(textSpans[1].textContent).toContain("Done!"); + + const toolDivs = container.querySelectorAll(".agent-log-tool"); + expect(toolDivs).toHaveLength(1); + }); + + describe("entry grouping", () => { + it("groups consecutive text entries from the same agent into one container", () => { + const entries = [ + makeEntry({ text: "hello", agent: "executor" }), + makeEntry({ text: " world", agent: "executor" }), + makeEntry({ text: "!", agent: "executor" }), + ]; + + const { container } = render(); + const textRows = container.querySelectorAll(".agent-log-text"); + expect(textRows).toHaveLength(1); + expect(textRows[0].textContent).toContain("hello world!"); + }); + + it("groups consecutive thinking entries from the same agent into one container", () => { + const entries = [ + makeEntry({ text: "think", type: "thinking", agent: "triage" }), + makeEntry({ text: "ing", type: "thinking", agent: "triage" }), + ]; + + const { container } = render(); + const thinkingRows = container.querySelectorAll(".agent-log-thinking"); + expect(thinkingRows).toHaveLength(1); + expect(thinkingRows[0].textContent).toContain("thinking"); + }); + + it("does not group text across tool entries", () => { + const entries = [ + makeEntry({ text: "part 1", type: "text", agent: "executor" }), + makeEntry({ text: "Read", type: "tool", agent: "executor" }), + makeEntry({ text: " part 2", type: "text", agent: "executor" }), + ]; + + const { container } = render(); + expect(container.querySelectorAll(".agent-log-text")).toHaveLength(2); + expect(container.querySelectorAll(".agent-log-tool")).toHaveLength(1); + }); + + it("does not group text entries from different agents", () => { + const entries = [ + makeEntry({ text: "triage", agent: "triage" }), + makeEntry({ text: "executor", agent: "executor" }), + ]; + + const { container } = render(); + expect(container.querySelectorAll(".agent-log-text")).toHaveLength(2); + }); + + it("does not group entries across text and thinking type boundaries", () => { + const entries = [ + makeEntry({ text: "text", type: "text", agent: "executor" }), + makeEntry({ text: "thought", type: "thinking", agent: "executor" }), + ]; + + const { container } = render(); + expect(container.querySelectorAll(".agent-log-text")).toHaveLength(1); + expect(container.querySelectorAll(".agent-log-thinking")).toHaveLength(1); + }); + + it("shows badge and timestamp only once at the start of a grouped text run", () => { + const entries = [ + makeEntry({ text: "a", type: "text", agent: "executor", timestamp: "2026-01-01T00:00:00Z" }), + makeEntry({ text: "b", type: "text", agent: "executor", timestamp: "2026-01-01T00:00:01Z" }), + ]; + + const { container } = render(); + expect(container.querySelectorAll(".agent-log-agent-badge")).toHaveLength(1); + expect(container.querySelectorAll(".agent-log-timestamp")).toHaveLength(1); + }); + }); + + it("renders tool entry detail toggle collapsed by default when detail is present", () => { + const entries = [ + makeEntry({ text: "Bash", type: "tool", detail: "ls -la packages/" }), + ]; + render(); + + const toggle = screen.getByTestId("tool-detail-toggle"); + expect(toggle).toBeTruthy(); + expect(toggle.getAttribute("aria-expanded")).toBe("false"); + const content = screen.getByTestId("tool-detail-content"); + expect(content.classList.contains("agent-log-tool-detail-content--collapsed")).toBe(true); + }); + + it("does not render detail toggle when detail is absent", () => { + const entries = [ + makeEntry({ text: "Bash", type: "tool" }), + makeEntry({ text: "Bash", type: "tool_result" }), + makeEntry({ text: "Bash", type: "tool_error" }), + ]; + render(); + expect(screen.queryByTestId("tool-detail-toggle")).toBeNull(); + }); + + it("renders long detail text without breaking layout", () => { + const longDetail = "a/very/long/path/".repeat(10) + "file.ts"; + const entries = [ + makeEntry({ text: "Read", type: "tool", detail: longDetail }), + ]; + const { container } = render(); + fireEvent.click(screen.getByTestId("tool-detail-toggle")); + const detail = container.querySelector(".agent-log-tool-detail"); + expect(detail).toBeTruthy(); + expect(detail!.textContent).toContain(longDetail); + // Verify the tool div still renders correctly + const toolDiv = container.querySelector(".agent-log-tool"); + expect(toolDiv).toBeTruthy(); + }); + + it("collapses tool-like detail by default across tool, tool_result, and tool_error", () => { + const entries = [ + makeEntry({ text: "Read", type: "tool", detail: "tool output" }), + makeEntry({ text: "Done", type: "tool_result", detail: "result output" }), + makeEntry({ text: "Oops", type: "tool_error", detail: "error output" }), + ]; + render(); + + const toggles = screen.getAllByTestId("tool-detail-toggle"); + expect(toggles).toHaveLength(3); + const contents = screen.getAllByTestId("tool-detail-content"); + expect(contents).toHaveLength(3); + for (const content of contents) { + expect(content.classList.contains("agent-log-tool-detail-content--collapsed")).toBe(true); + } + }); + + it("expands and collapses tool detail on toggle click", () => { + const entries = [ + makeEntry({ text: "Bash", type: "tool", detail: "line 1\nline 2" }), + ]; + render(); + + const toggle = screen.getByTestId("tool-detail-toggle"); + expect(toggle.getAttribute("aria-expanded")).toBe("false"); + fireEvent.click(toggle); + expect(toggle.getAttribute("aria-expanded")).toBe("true"); + const content = screen.getByTestId("tool-detail-content"); + expect(content.textContent).toContain("line 1"); + expect(content.classList.contains("agent-log-tool-detail-content--collapsed")).toBe(false); + fireEvent.click(toggle); + expect(toggle.getAttribute("aria-expanded")).toBe("false"); + expect(content.classList.contains("agent-log-tool-detail-content--collapsed")).toBe(true); + }); + + it("applies the viewer styling via the agent-log-viewer class", () => { + const entries = [makeEntry()]; + const { container } = render(); + const viewer = container.querySelector("[data-testid='agent-log-viewer']") as HTMLElement; + expect(viewer.classList.contains("agent-log-viewer")).toBe(true); + // Theme/layout styles come from CSS classes, not inline style attributes. + expect(viewer.style.fontFamily).toBe(""); + }); + + describe("agent badge deduplication", () => { + it("shows badge only on the first (oldest) of consecutive text entries from the same agent", () => { + const entries = [ + makeEntry({ text: "chunk 1", type: "text", agent: "executor" }), + makeEntry({ text: "chunk 2", type: "text", agent: "executor" }), + makeEntry({ text: "chunk 3", type: "text", agent: "executor" }), + ]; + const { container } = render(); + const badges = container.querySelectorAll(".agent-log-agent-badge"); + expect(badges).toHaveLength(1); + // In chronological order, the oldest (chunk 1) gets the badge + expect(badges[0].textContent).toBe("[executor]"); + }); + + it("shows badge on each agent transition in chronological order", () => { + const entries = [ + makeEntry({ text: "hello", type: "text", agent: "triage" }), + makeEntry({ text: "world", type: "text", agent: "triage" }), + makeEntry({ text: "starting", type: "text", agent: "executor" }), + makeEntry({ text: "done", type: "text", agent: "executor" }), + ]; + const { container } = render(); + const badges = container.querySelectorAll(".agent-log-agent-badge"); + expect(badges).toHaveLength(2); + expect(badges[0].textContent).toBe("[Plan]"); + expect(badges[1].textContent).toBe("[executor]"); + }); + + it("shows badge on text, tool, and text-after-tool (same agent, type change) in chronological order", () => { + const entries = [ + makeEntry({ text: "reading...", type: "text", agent: "executor" }), + makeEntry({ text: "Read", type: "tool", agent: "executor" }), + makeEntry({ text: "got it", type: "text", agent: "executor" }), + ]; + const { container } = render(); + const badges = container.querySelectorAll(".agent-log-agent-badge"); + // Chronological: reading... (text), Read (tool), got it (text) + // Badge on reading... (i=0), Read (always block-level), got it (type changed from tool) + expect(badges).toHaveLength(3); + }); + + it("shows badge only on the first (oldest) of consecutive thinking entries from the same agent", () => { + const entries = [ + makeEntry({ text: "hmm", type: "thinking", agent: "triage" }), + makeEntry({ text: "let me think", type: "thinking", agent: "triage" }), + makeEntry({ text: "ok", type: "thinking", agent: "triage" }), + ]; + const { container } = render(); + const badges = container.querySelectorAll(".agent-log-agent-badge"); + expect(badges).toHaveLength(1); + // In chronological order, the oldest (hmm) gets the badge + expect(badges[0].textContent).toBe("[Plan]"); + }); + + it("always shows badge on tool entries regardless of surrounding entries", () => { + const entries = [ + makeEntry({ text: "Bash", type: "tool", agent: "executor" }), + makeEntry({ text: "Read", type: "tool", agent: "executor" }), + makeEntry({ text: "Write", type: "tool", agent: "executor" }), + ]; + const { container } = render(); + const badges = container.querySelectorAll(".agent-log-agent-badge"); + expect(badges).toHaveLength(3); + }); + + it("always shows badge on tool_result and tool_error entries", () => { + const entries = [ + makeEntry({ text: "Bash", type: "tool", agent: "executor" }), + makeEntry({ text: "ok", type: "tool_result", agent: "executor" }), + makeEntry({ text: "Read", type: "tool", agent: "executor" }), + makeEntry({ text: "not found", type: "tool_error", agent: "executor" }), + ]; + const { container } = render(); + const badges = container.querySelectorAll(".agent-log-agent-badge"); + expect(badges).toHaveLength(4); + }); + + it("produces no badges when entries have no agent field", () => { + const entries = [ + makeEntry({ text: "legacy chunk 1", type: "text" }), + makeEntry({ text: "legacy chunk 2", type: "text" }), + ]; + const { container } = render(); + const badges = container.querySelectorAll(".agent-log-agent-badge"); + expect(badges).toHaveLength(0); + }); + }); + +}); diff --git a/packages/dashboard/app/components/__tests__/AgentLogViewer.test-helpers.ts b/packages/dashboard/app/components/__tests__/AgentLogViewer.test-helpers.ts new file mode 100644 index 0000000000..558d15fe50 --- /dev/null +++ b/packages/dashboard/app/components/__tests__/AgentLogViewer.test-helpers.ts @@ -0,0 +1,16 @@ +// FNXC:DashboardTests 2026-06-25-19:31: Shared fixtures for AgentLogViewer.*.test.tsx — split from AgentLogViewer.test.tsx to drop under the 2000-line guard (FN-7028, see FN-7013). +import type { AgentLogEntry } from "@fusion/core"; + +export function makeEntry(overrides: Partial = {}): AgentLogEntry { + return { + timestamp: "2026-01-01T00:00:00Z", + taskId: "FN-001", + text: "Hello world", + type: "text", + ...overrides, + }; +} + +export function getScrollContainer(container: HTMLElement): HTMLDivElement { + return container.querySelector(".agent-log-viewer-scroll") as HTMLDivElement; +} diff --git a/packages/dashboard/app/components/__tests__/AgentLogViewer.test.tsx b/packages/dashboard/app/components/__tests__/AgentLogViewer.test.tsx deleted file mode 100644 index e23d491228..0000000000 --- a/packages/dashboard/app/components/__tests__/AgentLogViewer.test.tsx +++ /dev/null @@ -1,2010 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { render, screen, fireEvent } from "@testing-library/react"; -import { AgentLogViewer } from "../AgentLogViewer"; -import { FileBrowserProvider } from "../../context/FileBrowserContext"; -import type { AgentLogEntry } from "@fusion/core"; -import "../../styles.css"; -import "../TaskDetailModal.css"; - -// Mock lucide-react icons used by AgentLogViewer and ProviderIcon -vi.mock("lucide-react", () => ({ - Maximize2: () => null, - Minimize2: () => null, - Loader2: () => null, - Cpu: () => null, - ChevronDown: () => null, - ChevronRight: () => null, -})); - -function makeEntry(overrides: Partial = {}): AgentLogEntry { - return { - timestamp: "2026-01-01T00:00:00Z", - taskId: "FN-001", - text: "Hello world", - type: "text", - ...overrides, - }; -} - -function getScrollContainer(container: HTMLElement): HTMLDivElement { - return container.querySelector(".agent-log-viewer-scroll") as HTMLDivElement; -} - -describe("AgentLogViewer", () => { - beforeEach(() => { - window.localStorage.clear(); - }); - - it("shows loading message when loading with no entries", () => { - render(); - expect(screen.getByRole("status")).toHaveTextContent("Loading agent logs…"); - expect(screen.queryByText("No agent output yet.")).toBeNull(); - }); - - it("shows empty message when no entries and not loading", () => { - render(); - expect(screen.getByText("No agent output yet.")).toBeTruthy(); - }); - - it("rerenders from empty state to populated logs without changing hook order", () => { - const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); - const entry = makeEntry({ text: "streamed chunk" }); - const { rerender } = render(); - - expect(() => { - rerender(); - }).not.toThrow(); - - expect(screen.getByText("streamed chunk")).toBeTruthy(); - consoleErrorSpy.mockRestore(); - }); - - it("renders grouped text entries in chronological order (oldest first)", () => { - const entries = [ - makeEntry({ text: "first chunk", agent: "executor" }), - makeEntry({ text: " second chunk", agent: "executor" }), - ]; - const { container } = render(); - const textSpans = container.querySelectorAll(".agent-log-text"); - expect(textSpans).toHaveLength(1); - expect(textSpans[0].textContent).toContain("first chunk second chunk"); - }); - - it("preserves just now output for future timestamps", () => { - const futureTimestamp = new Date(Date.now() + 30_000).toISOString(); - - render(); - - expect(screen.getByTestId("agent-log-timestamp")).toHaveTextContent("just now"); - }); - - it("keeps existing DOM rows stable when a new live entry appears at the bottom", () => { - const initialEntries = [ - makeEntry({ text: "first chunk", timestamp: "2026-01-01T00:00:00Z", agent: "triage" }), - makeEntry({ text: "second chunk", timestamp: "2026-01-01T00:00:01Z", agent: "executor" }), - ]; - - const { container, rerender } = render( - , - ); - - const initialTextRows = container.querySelectorAll(".agent-log-text"); - const firstChunkNode = initialTextRows[0] as HTMLElement; - const secondChunkNode = initialTextRows[1] as HTMLElement; - expect(firstChunkNode.textContent).toContain("first chunk"); - expect(secondChunkNode.textContent).toContain("second chunk"); - - const withLiveUpdate = [ - ...initialEntries, - makeEntry({ text: "third chunk", timestamp: "2026-01-01T00:00:02Z", agent: "reviewer" }), - ]; - - rerender(); - - const updatedTextRows = container.querySelectorAll(".agent-log-text"); - expect(updatedTextRows).toHaveLength(3); - expect(updatedTextRows[0].textContent).toContain("first chunk"); - expect(updatedTextRows[1].textContent).toContain("second chunk"); - expect(updatedTextRows[2].textContent).toContain("third chunk"); - expect(updatedTextRows[0]).toBe(firstChunkNode); - expect(updatedTextRows[1]).toBe(secondChunkNode); - }); - - it("avoids duplicate-key collisions when entries are exact duplicates", () => { - const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); - const duplicateEntry = makeEntry({ - timestamp: "2026-01-01T00:00:00Z", - taskId: "FN-001", - text: "same chunk", - type: "text", - agent: "executor", - detail: "same detail", - }); - - const { container, rerender } = render( - , - ); - - rerender( - , - ); - - expect(container.querySelectorAll(".agent-log-text")).toHaveLength(1); - expect( - consoleErrorSpy.mock.calls.some((call) => - String(call[0]).includes("Encountered two children with the same key"), - ), - ).toBe(false); - - consoleErrorSpy.mockRestore(); - }); - - it("renders file paths in plain log lines as clickable file-browser links", async () => { - const openFile = vi.fn(); - render( - - - , - ); - - fireEvent.click(screen.getByRole("button", { name: "packages/engine/src/scheduler.ts" })); - expect(openFile).toHaveBeenCalledWith("packages/engine/src/scheduler.ts", { line: undefined, col: undefined }); - }); - - it("renders tool entries with distinct styling", () => { - const entries = [ - makeEntry({ text: "Read", type: "tool" }), - ]; - const { container } = render(); - const toolDiv = container.querySelector(".agent-log-tool"); - expect(toolDiv).toBeTruthy(); - expect(toolDiv!.textContent).toContain("Read"); - }); - - it("renders a mix of text and tool entries in chronological order", () => { - const entries = [ - makeEntry({ text: "Starting...", type: "text" }), - makeEntry({ text: "Bash", type: "tool" }), - makeEntry({ text: "Done!", type: "text" }), - ]; - const { container } = render(); - const textSpans = container.querySelectorAll(".agent-log-text"); - expect(textSpans).toHaveLength(2); - expect(textSpans[0].textContent).toContain("Starting..."); - expect(textSpans[1].textContent).toContain("Done!"); - - const toolDivs = container.querySelectorAll(".agent-log-tool"); - expect(toolDivs).toHaveLength(1); - }); - - describe("entry grouping", () => { - it("groups consecutive text entries from the same agent into one container", () => { - const entries = [ - makeEntry({ text: "hello", agent: "executor" }), - makeEntry({ text: " world", agent: "executor" }), - makeEntry({ text: "!", agent: "executor" }), - ]; - - const { container } = render(); - const textRows = container.querySelectorAll(".agent-log-text"); - expect(textRows).toHaveLength(1); - expect(textRows[0].textContent).toContain("hello world!"); - }); - - it("groups consecutive thinking entries from the same agent into one container", () => { - const entries = [ - makeEntry({ text: "think", type: "thinking", agent: "triage" }), - makeEntry({ text: "ing", type: "thinking", agent: "triage" }), - ]; - - const { container } = render(); - const thinkingRows = container.querySelectorAll(".agent-log-thinking"); - expect(thinkingRows).toHaveLength(1); - expect(thinkingRows[0].textContent).toContain("thinking"); - }); - - it("does not group text across tool entries", () => { - const entries = [ - makeEntry({ text: "part 1", type: "text", agent: "executor" }), - makeEntry({ text: "Read", type: "tool", agent: "executor" }), - makeEntry({ text: " part 2", type: "text", agent: "executor" }), - ]; - - const { container } = render(); - expect(container.querySelectorAll(".agent-log-text")).toHaveLength(2); - expect(container.querySelectorAll(".agent-log-tool")).toHaveLength(1); - }); - - it("does not group text entries from different agents", () => { - const entries = [ - makeEntry({ text: "triage", agent: "triage" }), - makeEntry({ text: "executor", agent: "executor" }), - ]; - - const { container } = render(); - expect(container.querySelectorAll(".agent-log-text")).toHaveLength(2); - }); - - it("does not group entries across text and thinking type boundaries", () => { - const entries = [ - makeEntry({ text: "text", type: "text", agent: "executor" }), - makeEntry({ text: "thought", type: "thinking", agent: "executor" }), - ]; - - const { container } = render(); - expect(container.querySelectorAll(".agent-log-text")).toHaveLength(1); - expect(container.querySelectorAll(".agent-log-thinking")).toHaveLength(1); - }); - - it("shows badge and timestamp only once at the start of a grouped text run", () => { - const entries = [ - makeEntry({ text: "a", type: "text", agent: "executor", timestamp: "2026-01-01T00:00:00Z" }), - makeEntry({ text: "b", type: "text", agent: "executor", timestamp: "2026-01-01T00:00:01Z" }), - ]; - - const { container } = render(); - expect(container.querySelectorAll(".agent-log-agent-badge")).toHaveLength(1); - expect(container.querySelectorAll(".agent-log-timestamp")).toHaveLength(1); - }); - }); - - it("renders tool entry detail toggle collapsed by default when detail is present", () => { - const entries = [ - makeEntry({ text: "Bash", type: "tool", detail: "ls -la packages/" }), - ]; - render(); - - const toggle = screen.getByTestId("tool-detail-toggle"); - expect(toggle).toBeTruthy(); - expect(toggle.getAttribute("aria-expanded")).toBe("false"); - const content = screen.getByTestId("tool-detail-content"); - expect(content.classList.contains("agent-log-tool-detail-content--collapsed")).toBe(true); - }); - - it("does not render detail toggle when detail is absent", () => { - const entries = [ - makeEntry({ text: "Bash", type: "tool" }), - makeEntry({ text: "Bash", type: "tool_result" }), - makeEntry({ text: "Bash", type: "tool_error" }), - ]; - render(); - expect(screen.queryByTestId("tool-detail-toggle")).toBeNull(); - }); - - it("renders long detail text without breaking layout", () => { - const longDetail = "a/very/long/path/".repeat(10) + "file.ts"; - const entries = [ - makeEntry({ text: "Read", type: "tool", detail: longDetail }), - ]; - const { container } = render(); - fireEvent.click(screen.getByTestId("tool-detail-toggle")); - const detail = container.querySelector(".agent-log-tool-detail"); - expect(detail).toBeTruthy(); - expect(detail!.textContent).toContain(longDetail); - // Verify the tool div still renders correctly - const toolDiv = container.querySelector(".agent-log-tool"); - expect(toolDiv).toBeTruthy(); - }); - - it("collapses tool-like detail by default across tool, tool_result, and tool_error", () => { - const entries = [ - makeEntry({ text: "Read", type: "tool", detail: "tool output" }), - makeEntry({ text: "Done", type: "tool_result", detail: "result output" }), - makeEntry({ text: "Oops", type: "tool_error", detail: "error output" }), - ]; - render(); - - const toggles = screen.getAllByTestId("tool-detail-toggle"); - expect(toggles).toHaveLength(3); - const contents = screen.getAllByTestId("tool-detail-content"); - expect(contents).toHaveLength(3); - for (const content of contents) { - expect(content.classList.contains("agent-log-tool-detail-content--collapsed")).toBe(true); - } - }); - - it("expands and collapses tool detail on toggle click", () => { - const entries = [ - makeEntry({ text: "Bash", type: "tool", detail: "line 1\nline 2" }), - ]; - render(); - - const toggle = screen.getByTestId("tool-detail-toggle"); - expect(toggle.getAttribute("aria-expanded")).toBe("false"); - fireEvent.click(toggle); - expect(toggle.getAttribute("aria-expanded")).toBe("true"); - const content = screen.getByTestId("tool-detail-content"); - expect(content.textContent).toContain("line 1"); - expect(content.classList.contains("agent-log-tool-detail-content--collapsed")).toBe(false); - fireEvent.click(toggle); - expect(toggle.getAttribute("aria-expanded")).toBe("false"); - expect(content.classList.contains("agent-log-tool-detail-content--collapsed")).toBe(true); - }); - - it("applies the viewer styling via the agent-log-viewer class", () => { - const entries = [makeEntry()]; - const { container } = render(); - const viewer = container.querySelector("[data-testid='agent-log-viewer']") as HTMLElement; - expect(viewer.classList.contains("agent-log-viewer")).toBe(true); - // Theme/layout styles come from CSS classes, not inline style attributes. - expect(viewer.style.fontFamily).toBe(""); - }); - - describe("agent badge deduplication", () => { - it("shows badge only on the first (oldest) of consecutive text entries from the same agent", () => { - const entries = [ - makeEntry({ text: "chunk 1", type: "text", agent: "executor" }), - makeEntry({ text: "chunk 2", type: "text", agent: "executor" }), - makeEntry({ text: "chunk 3", type: "text", agent: "executor" }), - ]; - const { container } = render(); - const badges = container.querySelectorAll(".agent-log-agent-badge"); - expect(badges).toHaveLength(1); - // In chronological order, the oldest (chunk 1) gets the badge - expect(badges[0].textContent).toBe("[executor]"); - }); - - it("shows badge on each agent transition in chronological order", () => { - const entries = [ - makeEntry({ text: "hello", type: "text", agent: "triage" }), - makeEntry({ text: "world", type: "text", agent: "triage" }), - makeEntry({ text: "starting", type: "text", agent: "executor" }), - makeEntry({ text: "done", type: "text", agent: "executor" }), - ]; - const { container } = render(); - const badges = container.querySelectorAll(".agent-log-agent-badge"); - expect(badges).toHaveLength(2); - expect(badges[0].textContent).toBe("[Plan]"); - expect(badges[1].textContent).toBe("[executor]"); - }); - - it("shows badge on text, tool, and text-after-tool (same agent, type change) in chronological order", () => { - const entries = [ - makeEntry({ text: "reading...", type: "text", agent: "executor" }), - makeEntry({ text: "Read", type: "tool", agent: "executor" }), - makeEntry({ text: "got it", type: "text", agent: "executor" }), - ]; - const { container } = render(); - const badges = container.querySelectorAll(".agent-log-agent-badge"); - // Chronological: reading... (text), Read (tool), got it (text) - // Badge on reading... (i=0), Read (always block-level), got it (type changed from tool) - expect(badges).toHaveLength(3); - }); - - it("shows badge only on the first (oldest) of consecutive thinking entries from the same agent", () => { - const entries = [ - makeEntry({ text: "hmm", type: "thinking", agent: "triage" }), - makeEntry({ text: "let me think", type: "thinking", agent: "triage" }), - makeEntry({ text: "ok", type: "thinking", agent: "triage" }), - ]; - const { container } = render(); - const badges = container.querySelectorAll(".agent-log-agent-badge"); - expect(badges).toHaveLength(1); - // In chronological order, the oldest (hmm) gets the badge - expect(badges[0].textContent).toBe("[Plan]"); - }); - - it("always shows badge on tool entries regardless of surrounding entries", () => { - const entries = [ - makeEntry({ text: "Bash", type: "tool", agent: "executor" }), - makeEntry({ text: "Read", type: "tool", agent: "executor" }), - makeEntry({ text: "Write", type: "tool", agent: "executor" }), - ]; - const { container } = render(); - const badges = container.querySelectorAll(".agent-log-agent-badge"); - expect(badges).toHaveLength(3); - }); - - it("always shows badge on tool_result and tool_error entries", () => { - const entries = [ - makeEntry({ text: "Bash", type: "tool", agent: "executor" }), - makeEntry({ text: "ok", type: "tool_result", agent: "executor" }), - makeEntry({ text: "Read", type: "tool", agent: "executor" }), - makeEntry({ text: "not found", type: "tool_error", agent: "executor" }), - ]; - const { container } = render(); - const badges = container.querySelectorAll(".agent-log-agent-badge"); - expect(badges).toHaveLength(4); - }); - - it("produces no badges when entries have no agent field", () => { - const entries = [ - makeEntry({ text: "legacy chunk 1", type: "text" }), - makeEntry({ text: "legacy chunk 2", type: "text" }), - ]; - const { container } = render(); - const badges = container.querySelectorAll(".agent-log-agent-badge"); - expect(badges).toHaveLength(0); - }); - }); - - describe("model info header", () => { - it("renders model info header with executor model when set", () => { - const entries = [makeEntry()]; - const { container } = render( - , - ); - const header = container.querySelector("[data-testid='agent-log-model-header']"); - expect(header).toBeTruthy(); - expect(container.querySelector('[data-provider="anthropic"]')).toBeTruthy(); - expect(header!.textContent).not.toContain("Executor:"); - - fireEvent.click(screen.getByTestId("agent-log-model-expand")); - expect(header!.textContent).toContain("Executor:"); - expect(header!.textContent).toContain("anthropic/claude-sonnet-4-5"); - }); - - it("renders 'Using default' when no executor model override is set", () => { - const entries = [makeEntry()]; - const { container } = render( - , - ); - const header = container.querySelector("[data-testid='agent-log-model-header']"); - expect(header).toBeTruthy(); - expect(header!.textContent).not.toContain("Using default"); - }); - - it("renders 'Using default' when executorModel is undefined", () => { - const entries = [makeEntry()]; - const { container } = render(); - const header = container.querySelector("[data-testid='agent-log-model-header']"); - expect(header).toBeTruthy(); - expect(header!.textContent).not.toContain("Using default"); - }); - - it("renders model info header with validator model when set", () => { - const entries = [makeEntry()]; - const { container } = render( - , - ); - const header = container.querySelector("[data-testid='agent-log-model-header']"); - expect(header).toBeTruthy(); - expect(container.querySelector('[data-provider="openai"]')).toBeTruthy(); - expect(header!.textContent).not.toContain("Reviewer:"); - - fireEvent.click(screen.getByTestId("agent-log-model-expand")); - expect(header!.textContent).toContain("Reviewer:"); - expect(header!.textContent).toContain("openai/gpt-4o"); - }); - - it("renders 'Using default' when no validator model override is set", () => { - const entries = [makeEntry()]; - const { container } = render( - , - ); - const header = container.querySelector("[data-testid='agent-log-model-header']"); - expect(header).toBeTruthy(); - expect(header!.textContent).not.toContain("Using default"); - }); - - it("renders both models when both are configured", () => { - const entries = [makeEntry()]; - const { container } = render( - , - ); - const header = container.querySelector("[data-testid='agent-log-model-header']"); - expect(header).toBeTruthy(); - expect(container.querySelector('[data-provider="anthropic"]')).toBeTruthy(); - expect(container.querySelector('[data-provider="openai"]')).toBeTruthy(); - expect(header!.textContent).not.toContain("anthropic/claude-opus-4"); - expect(header!.textContent).not.toContain("openai/gpt-4o"); - - fireEvent.click(screen.getByTestId("agent-log-model-expand")); - expect(header!.textContent).toContain("anthropic/claude-opus-4"); - expect(header!.textContent).toContain("openai/gpt-4o"); - }); - - it("renders header with 'Using default' for both models when both are null/undefined", () => { - const entries = [makeEntry()]; - const { container } = render(); - const header = container.querySelector("[data-testid='agent-log-model-header']"); - expect(header).toBeTruthy(); - expect(header!.textContent).not.toContain("Using default"); - }); - - it("shows 'Using default' when executorModel has only provider but no modelId", () => { - const entries = [makeEntry()]; - const { container } = render( - , - ); - const header = container.querySelector("[data-testid='agent-log-model-header']"); - expect(header).toBeTruthy(); - expect(header!.textContent).not.toContain("Using default"); - }); - - it("shows 'Using default' when executorModel has only modelId but no provider", () => { - const entries = [makeEntry()]; - const { container } = render( - , - ); - const header = container.querySelector("[data-testid='agent-log-model-header']"); - expect(header).toBeTruthy(); - expect(header!.textContent).not.toContain("Using default"); - }); - - it("renders model info header with planning model when set", () => { - const entries = [makeEntry()]; - const { container } = render( - , - ); - const header = container.querySelector("[data-testid='agent-log-model-header']"); - expect(header).toBeTruthy(); - expect(container.querySelector('[data-provider="anthropic"]')).toBeTruthy(); - expect(header!.textContent).not.toContain("Planning:"); - - fireEvent.click(screen.getByTestId("agent-log-model-expand")); - expect(header!.textContent).toContain("Planning:"); - expect(header!.textContent).toContain("anthropic/claude-opus-4"); - }); - - it("renders 'Using default' for planning when no planning model is set", () => { - const entries = [makeEntry()]; - const { container } = render( - , - ); - const header = container.querySelector("[data-testid='agent-log-model-header']"); - expect(header).toBeTruthy(); - expect(header!.textContent).not.toContain("Using default"); - }); - - it("renders 'Using default' for planning when planningModel is undefined", () => { - const entries = [makeEntry()]; - const { container } = render(); - const header = container.querySelector("[data-testid='agent-log-model-header']"); - expect(header).toBeTruthy(); - expect(header!.textContent).not.toContain("Using default"); - }); - - it("renders all three models when all are configured", () => { - const entries = [makeEntry()]; - const { container } = render( - , - ); - const header = container.querySelector("[data-testid='agent-log-model-header']"); - expect(header).toBeTruthy(); - expect(container.querySelector('[data-provider="anthropic"]')).toBeTruthy(); - expect(container.querySelector('[data-provider="openai"]')).toBeTruthy(); - expect(container.querySelector('[data-provider="google"]')).toBeTruthy(); - expect(header!.textContent).not.toContain("anthropic/claude-opus-4"); - expect(header!.textContent).not.toContain("openai/gpt-4o"); - expect(header!.textContent).not.toContain("google/gemini-pro"); - - fireEvent.click(screen.getByTestId("agent-log-model-expand")); - expect(header!.textContent).toContain("anthropic/claude-opus-4"); - expect(header!.textContent).toContain("openai/gpt-4o"); - expect(header!.textContent).toContain("google/gemini-pro"); - }); - - it("shows 'Using default' for planning when planningModel has only provider but no modelId", () => { - const entries = [makeEntry()]; - const { container } = render( - , - ); - const header = container.querySelector("[data-testid='agent-log-model-header']"); - expect(header).toBeTruthy(); - expect(header!.textContent).not.toContain("Using default"); - }); - }); - - describe("model header expand/collapse", () => { - it("shows only provider icons in collapsed state, hides model text", () => { - const entries = [makeEntry()]; - const { container } = render( - , - ); - - expect(container.querySelector('[data-provider="anthropic"]')).toBeTruthy(); - expect(screen.getByTestId("agent-log-model-expand")).toBeTruthy(); - expect(container.textContent).not.toContain("Executor:"); - expect(container.textContent).not.toContain("claude-sonnet-4-5"); - }); - - it("shows model details when expand button is clicked", () => { - const entries = [makeEntry()]; - const { container } = render( - , - ); - - fireEvent.click(screen.getByTestId("agent-log-model-expand")); - expect(container.textContent).toContain("Executor:"); - expect(container.textContent).toContain("anthropic/claude-sonnet-4-5"); - }); - - it("collapses model details when expand button is clicked again", () => { - const entries = [makeEntry()]; - const { container } = render( - , - ); - - const button = screen.getByTestId("agent-log-model-expand"); - fireEvent.click(button); - expect(container.textContent).toContain("Executor:"); - fireEvent.click(button); - expect(container.textContent).not.toContain("Executor:"); - }); - - it("shows no provider icons when no model overrides are set", () => { - const entries = [makeEntry()]; - const { container } = render(); - - expect(container.querySelector("[data-provider]")).toBeNull(); - }); - - it("has aria-expanded=false when collapsed and aria-expanded=true when expanded", () => { - const entries = [makeEntry()]; - render( - , - ); - - const button = screen.getByTestId("agent-log-model-expand"); - expect(button.getAttribute("aria-expanded")).toBe("false"); - fireEvent.click(button); - expect(button.getAttribute("aria-expanded")).toBe("true"); - }); - - it("renders multiple provider icons for multiple overrides", () => { - const entries = [makeEntry()]; - const { container } = render( - , - ); - - expect(container.querySelector('[data-provider="anthropic"]')).toBeTruthy(); - expect(container.querySelector('[data-provider="openai"]')).toBeTruthy(); - expect(container.querySelector('[data-provider="google"]')).toBeTruthy(); - }); - }); - - describe("timestamp display", () => { - it("renders no timestamps for entries without agent field", () => { - const entries = [ - makeEntry({ text: "legacy 1", type: "text" }), - makeEntry({ text: "legacy 2", type: "text" }), - ]; - const { container } = render(); - const timestamps = container.querySelectorAll(".agent-log-timestamp"); - expect(timestamps).toHaveLength(0); - }); - - it("renders relative timestamps for recent entries next to the badge", () => { - const recentTimestamp = new Date(Date.now() - 5 * 60 * 1000).toISOString(); - const entries = [ - makeEntry({ text: "hello", type: "text", agent: "executor", timestamp: recentTimestamp }), - ]; - const { container } = render(); - const timestamp = container.querySelector(".agent-log-timestamp"); - expect(timestamp).toBeTruthy(); - expect(timestamp!.textContent).toBe("5m ago"); - - const badge = container.querySelector(".agent-log-agent-badge") as HTMLElement; - expect(badge).toBeTruthy(); - expect(badge.parentElement?.querySelector(".agent-log-timestamp")).toBeTruthy(); - }); - - it("renders 'just now' for entries less than a minute old", () => { - const recentTimestamp = new Date(Date.now() - 30 * 1000).toISOString(); - const entries = [ - makeEntry({ text: "hello", type: "text", agent: "executor", timestamp: recentTimestamp }), - ]; - const { container } = render(); - const timestamp = container.querySelector(".agent-log-timestamp"); - expect(timestamp!.textContent).toBe("just now"); - }); - - it("renders hours ago for older entries", () => { - const olderTimestamp = new Date(Date.now() - 3 * 60 * 60 * 1000).toISOString(); - const entries = [ - makeEntry({ text: "hello", type: "text", agent: "executor", timestamp: olderTimestamp }), - ]; - const { container } = render(); - const timestamp = container.querySelector(".agent-log-timestamp"); - expect(timestamp!.textContent).toBe("3h ago"); - }); - - it("renders days ago for entries older than a day", () => { - const oldTimestamp = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString(); - const entries = [ - makeEntry({ text: "hello", type: "text", agent: "executor", timestamp: oldTimestamp }), - ]; - const { container } = render(); - const timestamp = container.querySelector(".agent-log-timestamp"); - expect(timestamp!.textContent).toBe("2d ago"); - }); - - it("renders locale date for entries older than 7 days", () => { - const veryOldTimestamp = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString(); - const entries = [ - makeEntry({ text: "hello", type: "text", agent: "executor", timestamp: veryOldTimestamp }), - ]; - const { container } = render(); - const timestamp = container.querySelector(".agent-log-timestamp"); - // Should be a locale date string, not a relative time - expect(timestamp!.textContent).not.toContain("ago"); - expect(timestamp!.textContent).not.toBe("just now"); - }); - - it("uses the timestamp class inside the badge row", () => { - const entries = [makeEntry({ text: "hello", type: "text", agent: "executor" })]; - const { container } = render(); - const badge = container.querySelector(".agent-log-agent-badge") as HTMLElement; - expect(badge).toBeTruthy(); - const timestamp = badge.parentElement?.querySelector(".agent-log-timestamp") as HTMLElement; - expect(timestamp).toBeTruthy(); - expect(timestamp.classList.contains("agent-log-timestamp")).toBe(true); - // Theme styles are class-based now, not inline. - expect(timestamp.style.fontSize).toBe(""); - expect(timestamp.style.opacity).toBe(""); - }); - - it("renders the agent badge as a sticky overlay on a full-width text block", () => { - const entries = [makeEntry({ text: "long executor output", type: "text", agent: "executor" })]; - const { container } = render(); - const block = container.querySelector(".agent-log-text") as HTMLElement; - const badgeRow = container.querySelector(".agent-log-badge-row") as HTMLElement; - - expect(block).toBeTruthy(); - expect(badgeRow).toBeTruthy(); - expect(getComputedStyle(block).width).toBe("100%"); - expect(getComputedStyle(badgeRow).position).toBe("sticky"); - expect(getComputedStyle(badgeRow).left).not.toBe(""); - expect(getComputedStyle(badgeRow).pointerEvents).toBe("none"); - }); - - it("includes timestamp in the badge container for tool entries", () => { - const entries = [makeEntry({ text: "Bash", type: "tool", agent: "executor" })]; - const { container } = render(); - const toolDiv = container.querySelector(".agent-log-tool"); - expect(toolDiv).toBeTruthy(); - const badge = toolDiv!.querySelector(".agent-log-agent-badge") as HTMLElement; - expect(badge).toBeTruthy(); - expect(badge.parentElement?.querySelector(".agent-log-timestamp")).toBeTruthy(); - }); - - it("includes timestamp in the badge container for tool_result entries", () => { - const entries = [makeEntry({ text: "ok", type: "tool_result", agent: "executor" })]; - const { container } = render(); - const resultDiv = container.querySelector(".agent-log-tool-result"); - expect(resultDiv).toBeTruthy(); - const badge = resultDiv!.querySelector(".agent-log-agent-badge") as HTMLElement; - expect(badge).toBeTruthy(); - expect(badge.parentElement?.querySelector(".agent-log-timestamp")).toBeTruthy(); - }); - - it("includes timestamp in the badge container for tool_error entries", () => { - const entries = [makeEntry({ text: "fail", type: "tool_error", agent: "executor" })]; - const { container } = render(); - const errorDiv = container.querySelector(".agent-log-tool-error"); - expect(errorDiv).toBeTruthy(); - const badge = errorDiv!.querySelector(".agent-log-agent-badge") as HTMLElement; - expect(badge).toBeTruthy(); - expect(badge.parentElement?.querySelector(".agent-log-timestamp")).toBeTruthy(); - }); - - it("includes timestamp in the badge container for thinking entries", () => { - const entries = [makeEntry({ text: "hmm", type: "thinking", agent: "executor" })]; - const { container } = render(); - const thinkingSpan = container.querySelector(".agent-log-thinking"); - expect(thinkingSpan).toBeTruthy(); - const badge = thinkingSpan!.querySelector(".agent-log-agent-badge") as HTMLElement; - expect(badge).toBeTruthy(); - expect(badge.parentElement?.querySelector(".agent-log-timestamp")).toBeTruthy(); - }); - - it("shows exactly one timestamp for consecutive text entries from the same agent", () => { - const entries = [ - makeEntry({ text: "chunk 1", type: "text", agent: "executor" }), - makeEntry({ text: "chunk 2", type: "text", agent: "executor" }), - ]; - const { container } = render(); - const timestamps = container.querySelectorAll(".agent-log-timestamp"); - expect(timestamps).toHaveLength(1); - }); - - it("renders timestamps at each agent transition", () => { - const entries = [ - makeEntry({ text: "triage output", type: "text", agent: "triage" }), - makeEntry({ text: "executor output", type: "text", agent: "executor" }), - makeEntry({ text: "review notes", type: "text", agent: "reviewer" }), - ]; - const { container } = render(); - const badges = Array.from(container.querySelectorAll(".agent-log-agent-badge")); - const timestamps = container.querySelectorAll(".agent-log-timestamp"); - - expect(badges).toHaveLength(3); - expect(timestamps).toHaveLength(3); - expect(badges.map((badge) => badge.textContent)).toEqual(["[Plan]", "[executor]", "[reviewer]"]); - }); - - it("badge container includes both badge text and timestamp text", () => { - const recentTimestamp = new Date(Date.now() - 5 * 60 * 1000).toISOString(); - const entries = [ - makeEntry({ text: "hello", type: "text", agent: "executor", timestamp: recentTimestamp }), - ]; - const { container } = render(); - const badge = container.querySelector(".agent-log-agent-badge") as HTMLElement; - expect(badge).toBeTruthy(); - - const badgeContainer = badge.parentElement as HTMLElement; - expect(badgeContainer.textContent).toContain("[executor]"); - expect(badgeContainer.textContent).toContain("5m ago"); - }); - }); - - describe("horizontal overflow prevention", () => { - it("uses the scroll container class for overflow-x handling", () => { - const longString = "A".repeat(300); - const entries = [makeEntry({ text: longString })]; - const { container } = render(); - const scrollContainer = getScrollContainer(container); - expect(scrollContainer.classList.contains("agent-log-viewer-scroll")).toBe(true); - expect(scrollContainer.style.overflowX).toBe(""); - }); - - it("uses the scroll container class for overflow-wrap handling", () => { - const entries = [makeEntry({ text: "x".repeat(250) })]; - const { container } = render(); - const scrollContainer = getScrollContainer(container); - expect(scrollContainer.classList.contains("agent-log-viewer-scroll")).toBe(true); - expect(scrollContainer.style.overflowWrap).toBe(""); - }); - - it("renders pre elements with overflow-x auto for internal scrolling", () => { - const longLine = "const x = " + "'a'.repeat(500)"; - const entries = [makeEntry({ text: "```\n" + longLine + "\n```" })]; - const { container } = render(); - const pre = container.querySelector("pre") as HTMLElement; - expect(pre).toBeTruthy(); - expect(pre.style.overflowX).toBe("auto"); - expect(pre.style.maxWidth).toBe("100%"); - }); - - it("applies model-header wrapping via class", () => { - const entries = [makeEntry()]; - const { container } = render(); - const header = container.querySelector("[data-testid='agent-log-model-header']") as HTMLElement; - expect(header.classList.contains("agent-log-model-header")).toBe(true); - expect(header.style.flexWrap).toBe(""); - }); - }); - - describe("full-height layout", () => { - it("does not have a fixed maxHeight constraint", () => { - const entries = [makeEntry()]; - const { container } = render(); - const viewer = container.querySelector("[data-testid='agent-log-viewer']") as HTMLElement; - // The viewer should NOT have a maxHeight of 500px (the old fixed constraint) - expect(viewer.style.maxHeight).not.toBe("500px"); - // maxHeight should be empty (unset) so the viewer can grow to fill available space - expect(viewer.style.maxHeight).toBe(""); - }); - - it("uses class-based overflow-y scrolling on the entries container", () => { - const entries = [makeEntry()]; - const { container } = render(); - const scrollContainer = getScrollContainer(container); - // Scrolling behavior is now defined in CSS. - expect(scrollContainer.classList.contains("agent-log-viewer-scroll")).toBe(true); - expect(scrollContainer.style.overflowY).toBe(""); - }); - - it("uses agent-log-viewer--streaming class when entries are present", () => { - const entries = [makeEntry()]; - const { container } = render(); - const viewer = container.querySelector("[data-testid='agent-log-viewer']") as HTMLElement; - expect(viewer.classList.contains("agent-log-viewer")).toBe(true); - expect(viewer.classList.contains("agent-log-viewer--streaming")).toBe(true); - }); - - it("does not use streaming class on loading state", () => { - const { container } = render(); - const viewer = container.querySelector("[data-testid='agent-log-viewer']") as HTMLElement; - expect(viewer.classList.contains("agent-log-viewer")).toBe(true); - expect(viewer.classList.contains("agent-log-viewer--streaming")).toBe(false); - }); - }); - - describe("sticky header layout", () => { - it("renders the model header as a sibling of the scroll container", () => { - const entries = [makeEntry()]; - const { container } = render(); - const viewer = container.querySelector("[data-testid='agent-log-viewer']") as HTMLElement; - const header = screen.getByTestId("agent-log-model-header"); - const scrollContainer = getScrollContainer(container); - - expect(header.parentElement).toBe(viewer); - expect(scrollContainer.parentElement).toBe(viewer); - expect(scrollContainer.contains(header)).toBe(false); - }); - - it("renders log entry rows inside the scroll container", () => { - const entries = [ - makeEntry({ type: "text", text: "hello" }), - makeEntry({ type: "tool", text: "Bash" }), - ]; - const { container } = render(); - const scrollContainer = getScrollContainer(container); - - expect(scrollContainer.querySelector(".agent-log-text")).toBeTruthy(); - expect(scrollContainer.querySelector(".agent-log-tool")).toBeTruthy(); - }); - - it("renders pagination summary and load-more controls inside the scroll container", () => { - const entries = [makeEntry({ text: "hello" })]; - const { container } = render( - {}} - />, - ); - const scrollContainer = getScrollContainer(container); - - expect(scrollContainer.querySelector("[data-testid='agent-log-summary']")).toBeTruthy(); - expect(scrollContainer.querySelector("[data-testid='agent-log-load-more']")).toBeTruthy(); - }); - - it("renders the return-to-live button inside the scroll container", () => { - const entries = [ - makeEntry({ text: "first", timestamp: "2026-01-01T00:00:00Z" }), - makeEntry({ text: "second", timestamp: "2026-01-01T00:00:01Z" }), - ]; - const { container } = render(); - const scrollContainer = getScrollContainer(container); - - Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 1000 }); - Object.defineProperty(scrollContainer, "clientHeight", { configurable: true, value: 200 }); - - scrollContainer.scrollTop = 300; - fireEvent.scroll(scrollContainer); - - const returnToLive = screen.getByTestId("agent-log-return-to-live"); - expect(returnToLive.parentElement).toBe(scrollContainer); - }); - }); - - describe("auto-scroll behavior", () => { - it("scrolls to bottom when streaming updates arrive and user is near the bottom", () => { - const initialEntries = [ - makeEntry({ text: "first", timestamp: "2026-01-01T00:00:00Z" }), - ]; - const streamedEntries = [ - ...initialEntries, - makeEntry({ text: "second", timestamp: "2026-01-01T00:00:01Z" }), - ]; - - const { rerender, container } = render(); - const viewer = getScrollContainer(container); - - let scrollHeight = 600; - Object.defineProperty(viewer, "scrollHeight", { - configurable: true, - get: () => scrollHeight, - }); - - viewer.scrollTop = 560; - rerender(); - - scrollHeight = 720; - rerender(); - - expect(viewer.scrollTop).toBe(720); - }); - - it("does not auto-scroll when streaming updates arrive and user is reading older output", () => { - const initialEntries = [ - makeEntry({ text: "first", timestamp: "2026-01-01T00:00:00Z" }), - ]; - const streamedEntries = [ - ...initialEntries, - makeEntry({ text: "second", timestamp: "2026-01-01T00:00:01Z" }), - ]; - - const { rerender, container } = render(); - const viewer = getScrollContainer(container); - - let scrollHeight = 1000; - Object.defineProperty(viewer, "scrollHeight", { - configurable: true, - get: () => scrollHeight, - }); - - viewer.scrollTop = 220; - rerender(); - - scrollHeight = 1120; - rerender(); - - expect(viewer.scrollTop).toBe(220); - }); - - it("keeps viewport anchored when older history is prepended", () => { - const initialEntries = [ - makeEntry({ text: "recent", timestamp: "2026-01-01T00:00:00Z" }), - ]; - const olderLoadedEntries = [ - makeEntry({ text: "older", timestamp: "2025-12-31T23:59:00Z" }), - ...initialEntries, - ]; - - const { rerender, container } = render(); - const viewer = getScrollContainer(container); - - let scrollHeight = 900; - Object.defineProperty(viewer, "scrollHeight", { - configurable: true, - get: () => scrollHeight, - }); - - viewer.scrollTop = 260; - rerender(); - - scrollHeight = 1030; - rerender(); - - // Anchored by delta (1030 - 900): 260 + 130 - expect(viewer.scrollTop).toBe(390); - }); - - it("shows return-to-live button when user scrolls away from bottom", () => { - const entries = [ - makeEntry({ text: "first", timestamp: "2026-01-01T00:00:00Z" }), - makeEntry({ text: "second", timestamp: "2026-01-01T00:00:01Z" }), - ]; - const { container } = render(); - const viewer = getScrollContainer(container); - - Object.defineProperty(viewer, "scrollHeight", { configurable: true, value: 1000 }); - Object.defineProperty(viewer, "clientHeight", { configurable: true, value: 200 }); - - viewer.scrollTop = 300; - fireEvent.scroll(viewer); - - expect(screen.getByTestId("agent-log-return-to-live")).toBeTruthy(); - }); - - it("hides return-to-live button when user is following live output", () => { - const entries = [ - makeEntry({ text: "first", timestamp: "2026-01-01T00:00:00Z" }), - makeEntry({ text: "second", timestamp: "2026-01-01T00:00:01Z" }), - ]; - const { container } = render(); - const viewer = getScrollContainer(container); - - Object.defineProperty(viewer, "scrollHeight", { configurable: true, value: 1000 }); - Object.defineProperty(viewer, "clientHeight", { configurable: true, value: 200 }); - - viewer.scrollTop = 760; - fireEvent.scroll(viewer); - - expect(screen.queryByTestId("agent-log-return-to-live")).toBeNull(); - }); - - it("returns to bottom and resumes following when return-to-live is clicked", () => { - const entries = [ - makeEntry({ text: "first", timestamp: "2026-01-01T00:00:00Z" }), - makeEntry({ text: "second", timestamp: "2026-01-01T00:00:01Z" }), - ]; - const { container } = render(); - const viewer = getScrollContainer(container); - - Object.defineProperty(viewer, "scrollHeight", { configurable: true, value: 1000 }); - Object.defineProperty(viewer, "clientHeight", { configurable: true, value: 200 }); - - viewer.scrollTop = 280; - fireEvent.scroll(viewer); - - const returnButton = screen.getByTestId("agent-log-return-to-live"); - fireEvent.click(returnButton); - - expect(viewer.scrollTop).toBe(1000); - expect(screen.queryByTestId("agent-log-return-to-live")).toBeNull(); - }); - - it("re-pins to bottom on resize while following", () => { - const resizeCallbacks: Array<() => void> = []; - const originalResizeObserver = globalThis.ResizeObserver; - - class ResizeObserverMock { - constructor(callback: ResizeObserverCallback) { - resizeCallbacks.push(() => callback([], this as unknown as ResizeObserver)); - } - - observe() {} - unobserve() {} - disconnect() {} - } - - Object.defineProperty(globalThis, "ResizeObserver", { - configurable: true, - value: ResizeObserverMock, - }); - - try { - const entries = [ - makeEntry({ text: "first", timestamp: "2026-01-01T00:00:00Z" }), - makeEntry({ text: "second", timestamp: "2026-01-01T00:00:01Z" }), - ]; - const { container } = render(); - const viewer = getScrollContainer(container); - - Object.defineProperty(viewer, "scrollHeight", { configurable: true, value: 1200 }); - Object.defineProperty(viewer, "clientHeight", { configurable: true, value: 200 }); - viewer.scrollTop = 980; - fireEvent.scroll(viewer); - - viewer.scrollTop = 640; - resizeCallbacks.forEach((callback) => callback()); - - expect(viewer.scrollTop).toBe(1200); - } finally { - if (originalResizeObserver) { - Object.defineProperty(globalThis, "ResizeObserver", { - configurable: true, - value: originalResizeObserver, - }); - } else { - delete (globalThis as { ResizeObserver?: typeof ResizeObserver }).ResizeObserver; - } - } - }); - }); - - describe("pagination placement", () => { - it("renders the load-more control above the first log entry", () => { - const entries = [ - makeEntry({ text: "oldest", timestamp: "2026-01-01T00:00:00Z" }), - makeEntry({ text: "newest", timestamp: "2026-01-01T00:00:01Z" }), - ]; - - const { container } = render( - {}} - />, - ); - - const loadMore = screen.getByTestId("agent-log-load-more"); - const firstRow = container.querySelector(".agent-log-text") as HTMLElement; - expect(firstRow).toBeTruthy(); - - expect(loadMore.compareDocumentPosition(firstRow) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); - }); - }); - - describe("long content preservation", () => { - it("renders very long text entries without truncation", () => { - const longText = "A".repeat(5000); - const entries = [makeEntry({ text: longText })]; - const { container } = render(); - const textSpans = container.querySelectorAll(".agent-log-text"); - expect(textSpans).toHaveLength(1); - expect(textSpans[0].textContent).toContain(longText); - }); - - it("renders very long detail text without truncation", () => { - const longDetail = "B".repeat(5000); - const entries = [makeEntry({ text: "Read", type: "tool", detail: longDetail })]; - const { container } = render(); - fireEvent.click(screen.getByTestId("tool-detail-toggle")); - const detail = container.querySelector(".agent-log-tool-detail"); - expect(detail).toBeTruthy(); - expect(detail!.textContent).toContain(longDetail); - expect(detail!.textContent!.length).toBe(5000); - }); - - it("renders multiline text content without truncation", () => { - const multilineText = [ - "## Analysis", - "", - "After reviewing the codebase:", - "", - "1. First issue found", - "2. Second issue found", - "", - "```typescript", - "const x = 1;", - "```", - "", - "Line " + "C".repeat(2000) + " end", - ].join("\n"); - const entries = [makeEntry({ text: multilineText })]; - const { container } = render(); - const textSpans = container.querySelectorAll(".agent-log-text"); - expect(textSpans).toHaveLength(1); - // The markdown-rendered content should still contain the essential parts - expect(textSpans[0].textContent).toContain("Analysis"); - expect(textSpans[0].textContent).toContain("First issue found"); - expect(textSpans[0].textContent).toContain("const x = 1"); - }); - - it("renders long tool_result detail without truncation", () => { - const longDetail = "D".repeat(5000); - const entries = [makeEntry({ text: "Bash", type: "tool_result", detail: longDetail })]; - const { container } = render(); - fireEvent.click(screen.getByTestId("tool-detail-toggle")); - const detail = container.querySelector(".agent-log-tool-detail"); - expect(detail).toBeTruthy(); - expect(detail!.textContent).toContain(longDetail); - }); - - it("renders long tool_error detail without truncation", () => { - const longDetail = "E".repeat(5000); - const entries = [makeEntry({ text: "Write", type: "tool_error", detail: longDetail })]; - const { container } = render(); - fireEvent.click(screen.getByTestId("tool-detail-toggle")); - const detail = container.querySelector(".agent-log-tool-detail"); - expect(detail).toBeTruthy(); - expect(detail!.textContent).toContain(longDetail); - }); - - it("preserves raw whitespace in tool detail blocks", () => { - const detailText = "stdout:\n line one\n indented line two\n"; - const entries = [makeEntry({ text: "Bash", type: "tool_result", detail: detailText })]; - const { container } = render(); - fireEvent.click(screen.getByTestId("tool-detail-toggle")); - const detail = container.querySelector(".agent-log-tool-detail") as HTMLElement; - expect(detail).toBeTruthy(); - expect(detail.tagName).toBe("PRE"); - expect(detail.textContent).toBe(detailText); - }); - }); - - describe("markdown rendering", () => { - it("renders plain text without markdown correctly", () => { - const entries = [ - makeEntry({ text: "Hello world, this is plain text." }), - ]; - const { container } = render(); - const textSpans = container.querySelectorAll(".agent-log-text"); - expect(textSpans).toHaveLength(1); - expect(textSpans[0].textContent).toContain("Hello world, this is plain text."); - }); - - it("renders text entries inside markdown-body in markdown mode", () => { - const entries = [ - makeEntry({ text: "Paragraph one\n\nParagraph two" }), - ]; - const { container } = render(); - const textRow = container.querySelector(".agent-log-text") as HTMLElement; - expect(textRow).toBeTruthy(); - - const proseContainer = textRow.querySelector(".markdown-body") as HTMLElement; - expect(proseContainer).toBeTruthy(); - expect(proseContainer.querySelectorAll("p")).toHaveLength(2); - }); - - it("renders thinking entries inside markdown-body in markdown mode", () => { - const entries = [ - makeEntry({ text: "Considering:\n\n- option A\n- option B", type: "thinking" }), - ]; - const { container } = render(); - const thinkingRow = container.querySelector(".agent-log-thinking") as HTMLElement; - expect(thinkingRow).toBeTruthy(); - - const proseContainer = thinkingRow.querySelector(".markdown-body") as HTMLElement; - expect(proseContainer).toBeTruthy(); - expect(proseContainer.querySelector("ul")).toBeTruthy(); - }); - - it("renders inline markdown elements (bold, italic, inline code)", () => { - const entries = [ - makeEntry({ text: "This is **bold** and *italic* with `inline code`." }), - ]; - const { container } = render(); - const textSpans = container.querySelectorAll(".agent-log-text"); - expect(textSpans).toHaveLength(1); - // Check that the markdown elements are rendered - const strong = textSpans[0].querySelector("strong"); - expect(strong).toBeTruthy(); - expect(strong!.textContent).toBe("bold"); - const em = textSpans[0].querySelector("em"); - expect(em).toBeTruthy(); - expect(em!.textContent).toBe("italic"); - const code = textSpans[0].querySelector("code"); - expect(code).toBeTruthy(); - expect(code!.textContent).toBe("inline code"); - }); - - it("renders file links inside inline code with the code wrapper preserved", () => { - const openFile = vi.fn(); - const entries = [ - makeEntry({ text: "Check `packages/engine/src/scheduler.ts:7` now." }), - ]; - const { container } = render( - - - , - ); - - const fileLink = screen.getByRole("button", { name: "packages/engine/src/scheduler.ts:7" }); - const code = fileLink.closest("code"); - expect(code).toBeTruthy(); - expect(code?.querySelector("button.file-path-link")).toBe(fileLink); - - fireEvent.click(fileLink); - expect(openFile).toHaveBeenCalledWith("packages/engine/src/scheduler.ts", { line: 7, col: undefined }); - expect(container.querySelectorAll("code button.file-path-link")).toHaveLength(1); - }); - - it("renders code blocks with GFM support", () => { - const entries = [ - makeEntry({ text: "```typescript\nconst x = 1;\n```" }), - ]; - const { container } = render(); - const textSpans = container.querySelectorAll(".agent-log-text"); - expect(textSpans).toHaveLength(1); - // Check that code block is rendered - const pre = textSpans[0].querySelector("pre"); - expect(pre).toBeTruthy(); - const code = pre!.querySelector("code"); - expect(code).toBeTruthy(); - expect(code!.textContent).toContain("const x = 1"); - }); - - it("renders GFM task lists", () => { - const entries = [ - makeEntry({ text: "- [x] Completed task\n- [ ] Pending task" }), - ]; - const { container } = render(); - const textSpans = container.querySelectorAll(".agent-log-text"); - expect(textSpans).toHaveLength(1); - // Check that task list is rendered - const ul = textSpans[0].querySelector("ul"); - expect(ul).toBeTruthy(); - const taskListItems = ul!.querySelectorAll("li"); - expect(taskListItems).toHaveLength(2); - // Check checkboxes - const checkboxes = ul!.querySelectorAll('input[type="checkbox"]'); - expect(checkboxes).toHaveLength(2); - expect((checkboxes[0] as HTMLInputElement).checked).toBe(true); - expect((checkboxes[1] as HTMLInputElement).checked).toBe(false); - }); - - it("renders blockquotes", () => { - const entries = [ - makeEntry({ text: "> This is a blockquote\n> with multiple lines" }), - ]; - const { container } = render(); - const textSpans = container.querySelectorAll(".agent-log-text"); - expect(textSpans).toHaveLength(1); - // Check that blockquote is rendered - const blockquote = textSpans[0].querySelector("blockquote"); - expect(blockquote).toBeTruthy(); - expect(blockquote!.textContent).toContain("This is a blockquote"); - }); - - it("renders markdown in thinking entries", () => { - const entries = [ - makeEntry({ text: "Let me think about **this problem**...", type: "thinking" }), - ]; - const { container } = render(); - const thinkingSpans = container.querySelectorAll(".agent-log-thinking"); - expect(thinkingSpans).toHaveLength(1); - const strong = thinkingSpans[0].querySelector("strong"); - expect(strong).toBeTruthy(); - expect(strong!.textContent).toBe("this problem"); - }); - - it("renders mixed content with markdown and plain text", () => { - const entries = [ - makeEntry({ text: "The code:\n\n```js\nconsole.log('hello');\n```\n\nworks!" }), - ]; - const { container } = render(); - const textSpans = container.querySelectorAll(".agent-log-text"); - expect(textSpans).toHaveLength(1); - const pre = textSpans[0].querySelector("pre"); - expect(pre).toBeTruthy(); - // Plain text before and after should be preserved - expect(textSpans[0].textContent).toContain("The code:"); - expect(textSpans[0].textContent).toContain("works!"); - }); - }); - - describe("markdown render toggle", () => { - it("renders the toggle button in the model info header", () => { - const entries = [makeEntry()]; - const { container } = render(); - const toggle = container.querySelector("[data-testid='agent-log-mode-toggle']"); - expect(toggle).toBeTruthy(); - expect(toggle!.textContent).toBe("Markdown"); - }); - - it("defaults to markdown mode", () => { - const entries = [makeEntry({ text: "**bold** text" })]; - const { container } = render(); - // In markdown mode, bold should be rendered as - const textSpans = container.querySelectorAll(".agent-log-text"); - const strong = textSpans[0].querySelector("strong"); - expect(strong).toBeTruthy(); - expect(strong!.textContent).toBe("bold"); - }); - - it("has correct aria attributes on the toggle", () => { - const entries = [makeEntry()]; - const { container } = render(); - const toggle = container.querySelector("[data-testid='agent-log-mode-toggle']") as HTMLButtonElement; - expect(toggle).toBeTruthy(); - expect(toggle.getAttribute("aria-pressed")).toBe("true"); - expect(toggle.getAttribute("aria-label")).toBe("Switch to plain text mode"); - }); - - it("FN-3847: uses accent text color for pressed markdown/tools toggles", () => { - window.localStorage.setItem("fn-agent-log-markdown", "true"); - window.localStorage.setItem("fn-agent-log-tool-output", "true"); - const entries = [makeEntry({ text: "hello" })]; - const { container } = render(); - - const markdownToggle = container.querySelector("[data-testid='agent-log-mode-toggle']") as HTMLButtonElement; - const toolsToggle = container.querySelector("[data-testid='agent-log-tool-output-toggle']") as HTMLButtonElement; - const fullscreenToggle = container.querySelector("[data-testid='agent-log-fullscreen-toggle']") as HTMLButtonElement; - - expect(markdownToggle.getAttribute("aria-pressed")).toBe("true"); - expect(toolsToggle.getAttribute("aria-pressed")).toBe("true"); - - const markdownColor = getComputedStyle(markdownToggle).color; - const toolsColor = getComputedStyle(toolsToggle).color; - const unpressedColor = getComputedStyle(fullscreenToggle).color; - - // Contract: unpressed toggles keep the shared muted button color, pressed toggles switch to accent foreground. - expect(markdownColor.length).toBeGreaterThan(0); - expect(toolsColor.length).toBeGreaterThan(0); - expect(markdownColor).toBe(toolsColor); - expect(markdownColor).not.toBe(unpressedColor); - }); - - it("switches to plain text mode when clicked", () => { - const entries = [makeEntry({ text: "**bold** and *italic*" })]; - const { container } = render(); - const toggle = container.querySelector("[data-testid='agent-log-mode-toggle']") as HTMLButtonElement; - - // Markdown mode starts with prose container + rendered markdown - const markdownModeTextRow = container.querySelector(".agent-log-text") as HTMLElement; - expect(markdownModeTextRow.querySelector(".markdown-body")).toBeTruthy(); - expect(markdownModeTextRow.querySelector("strong")?.textContent).toBe("bold"); - - // Click to switch to plain text mode - fireEvent.click(toggle); - - // Button should update - expect(toggle.textContent).toBe("Plain"); - expect(toggle.getAttribute("aria-pressed")).toBe("false"); - expect(toggle.getAttribute("aria-label")).toBe("Switch to markdown mode"); - - // Text should now show raw markdown syntax literally - const textSpans = container.querySelectorAll(".agent-log-text"); - expect(textSpans).toHaveLength(1); - expect(textSpans[0].textContent).toContain("**bold** and *italic*"); - const plainBlock = textSpans[0].querySelector(".agent-log-plain-block") as HTMLElement; - expect(plainBlock).toBeTruthy(); - // Plain mode should remove markdown rendering/prose container - expect(textSpans[0].querySelector(".markdown-body")).toBeNull(); - expect(textSpans[0].querySelector("strong")).toBeNull(); - expect(textSpans[0].querySelector("em")).toBeNull(); - }); - - it("concatenates grouped text into a single markdown render", () => { - const entries = [ - makeEntry({ text: "**bold", type: "text", agent: "executor" }), - makeEntry({ text: "** text", type: "text", agent: "executor" }), - ]; - const { container } = render(); - - const textRows = container.querySelectorAll(".agent-log-text"); - expect(textRows).toHaveLength(1); - expect(textRows[0].querySelectorAll(".markdown-body")).toHaveLength(1); - const strong = textRows[0].querySelector("strong"); - expect(strong).toBeTruthy(); - expect(strong!.textContent).toBe("bold"); - }); - - it("joins grouped chunks inline in plain text mode", () => { - const entries = [ - makeEntry({ text: "hello", type: "text", agent: "executor" }), - makeEntry({ text: " world", type: "text", agent: "executor" }), - ]; - const { container } = render(); - const toggle = container.querySelector("[data-testid='agent-log-mode-toggle']") as HTMLButtonElement; - - fireEvent.click(toggle); - - const textRows = container.querySelectorAll(".agent-log-text"); - expect(textRows).toHaveLength(1); - expect(textRows[0].querySelector(".markdown-body")).toBeNull(); - expect(textRows[0].textContent).toContain("hello world"); - }); - - it("toggles back to markdown mode from plain text", () => { - const entries = [makeEntry({ text: "**bold** text" })]; - const { container } = render(); - const toggle = container.querySelector("[data-testid='agent-log-mode-toggle']") as HTMLButtonElement; - - // Switch to plain text - fireEvent.click(toggle); - expect(toggle.textContent).toBe("Plain"); - - // Switch back to markdown - fireEvent.click(toggle); - expect(toggle.textContent).toBe("Markdown"); - - // Markdown elements should be present again - const textSpans = container.querySelectorAll(".agent-log-text"); - const strong = textSpans[0].querySelector("strong"); - expect(strong).toBeTruthy(); - expect(strong!.textContent).toBe("bold"); - }); - - it("preserves line breaks in plain text mode for thinking entries", () => { - const entries = [makeEntry({ text: "line1\nline2\nline3", type: "thinking", agent: "executor" })]; - const { container } = render(); - const toggle = container.querySelector("[data-testid='agent-log-mode-toggle']") as HTMLButtonElement; - - fireEvent.click(toggle); - - const plainThinking = container.querySelector(".agent-log-thinking .agent-log-plain-block") as HTMLElement; - expect(plainThinking).toBeTruthy(); - expect(plainThinking.textContent).toContain("line1\nline2\nline3"); - }); - - it("shows raw markdown syntax literally in plain text mode for text entries", () => { - const entries = [ - makeEntry({ text: "## Heading\n\n- item 1\n- item 2\n\n`code` and **bold**" }), - ]; - const { container } = render(); - const toggle = container.querySelector("[data-testid='agent-log-mode-toggle']") as HTMLButtonElement; - - // Switch to plain text - fireEvent.click(toggle); - - const textSpans = container.querySelectorAll(".agent-log-text"); - expect(textSpans).toHaveLength(1); - // Raw markdown syntax should appear literally - expect(textSpans[0].textContent).toContain("## Heading"); - expect(textSpans[0].textContent).toContain("- item 1"); - expect(textSpans[0].textContent).toContain("`code`"); - expect(textSpans[0].textContent).toContain("**bold**"); - // No rendered markdown elements - expect(textSpans[0].querySelector("h2")).toBeNull(); - expect(textSpans[0].querySelector("ul")).toBeNull(); - expect(textSpans[0].querySelector("code")).toBeNull(); - expect(textSpans[0].querySelector("strong")).toBeNull(); - }); - - it("respects toggle for thinking entries", () => { - const entries = [ - makeEntry({ text: "Thinking about **this**", type: "thinking" }), - ]; - const { container } = render(); - const toggle = container.querySelector("[data-testid='agent-log-mode-toggle']") as HTMLButtonElement; - - // In markdown mode, bold is rendered - const thinkingSpans = container.querySelectorAll(".agent-log-thinking"); - expect(thinkingSpans[0].querySelector("strong")).toBeTruthy(); - - // Switch to plain text - fireEvent.click(toggle); - - const thinkingSpansUpdated = container.querySelectorAll(".agent-log-thinking"); - expect(thinkingSpansUpdated[0].textContent).toContain("Thinking about **this**"); - expect(thinkingSpansUpdated[0].querySelector("strong")).toBeNull(); - }); - - it("does not affect tool entries in either mode", () => { - const entries = [ - makeEntry({ text: "Read", type: "tool" }), - makeEntry({ text: "done", type: "tool_result" }), - makeEntry({ text: "fail", type: "tool_error" }), - ]; - const { container } = render(); - const toggle = container.querySelector("[data-testid='agent-log-mode-toggle']") as HTMLButtonElement; - - // Tool entries in markdown mode - expect(container.querySelector(".agent-log-tool")!.textContent).toContain("Read"); - expect(container.querySelector(".agent-log-tool-result")!.textContent).toContain("done"); - expect(container.querySelector(".agent-log-tool-error")!.textContent).toContain("fail"); - - // Switch to plain text - tool entries should be unchanged - fireEvent.click(toggle); - - expect(container.querySelector(".agent-log-tool")!.textContent).toContain("Read"); - expect(container.querySelector(".agent-log-tool-result")!.textContent).toContain("done"); - expect(container.querySelector(".agent-log-tool-error")!.textContent).toContain("fail"); - }); - - it("safely renders HTML tags as text in plain text mode (no XSS)", () => { - const entries = [ - makeEntry({ text: ' and bold' }), - ]; - const { container } = render(); - const toggle = container.querySelector("[data-testid='agent-log-mode-toggle']") as HTMLButtonElement; - - // Switch to plain text - fireEvent.click(toggle); - - const textSpans = container.querySelectorAll(".agent-log-text"); - // The text content should contain the literal HTML tags - expect(textSpans[0].textContent).toContain(''); - expect(textSpans[0].textContent).toContain("bold"); - // No actual script or bold HTML elements should be rendered - expect(textSpans[0].querySelector("script")).toBeNull(); - expect(textSpans[0].querySelector("b")).toBeNull(); - }); - - it("safely renders HTML in markdown mode via react-markdown sanitization", () => { - const entries = [ - makeEntry({ text: "**safe** text here" }), - ]; - const { container } = render(); - // In markdown mode, react-markdown sanitizes HTML (no script execution) - const textSpans = container.querySelectorAll(".agent-log-text"); - // Markdown formatting should work - const strong = textSpans[0].querySelector("strong"); - expect(strong).toBeTruthy(); - expect(strong!.textContent).toBe("safe"); - // No script elements are rendered for any HTML content in markdown - expect(textSpans[0].querySelector("script")).toBeNull(); - }); - }); - - describe("tool output toggle", () => { - it("renders the tool output toggle defaulting to On", () => { - const entries = [makeEntry()]; - const { container } = render(); - const toggle = container.querySelector("[data-testid='agent-log-tool-output-toggle']") as HTMLButtonElement; - expect(toggle).toBeTruthy(); - expect(toggle.textContent).toBe("Tools: On"); - expect(toggle.getAttribute("aria-pressed")).toBe("true"); - }); - - it("hides tool entries when toggled off and shows them again when toggled back on", () => { - const entries = [ - makeEntry({ text: "before tool", type: "text", agent: "executor" }), - makeEntry({ text: "Read", type: "tool", agent: "executor" }), - makeEntry({ text: "done", type: "tool_result", agent: "executor" }), - makeEntry({ text: "fail", type: "tool_error", agent: "executor" }), - makeEntry({ text: "after tool", type: "text", agent: "executor" }), - ]; - const { container } = render(); - const toggle = container.querySelector("[data-testid='agent-log-tool-output-toggle']") as HTMLButtonElement; - - expect(container.querySelector(".agent-log-tool")).toBeTruthy(); - expect(container.querySelector(".agent-log-tool-result")).toBeTruthy(); - expect(container.querySelector(".agent-log-tool-error")).toBeTruthy(); - - fireEvent.click(toggle); - expect(toggle.textContent).toBe("Tools: Off"); - expect(toggle.getAttribute("aria-pressed")).toBe("false"); - - expect(container.querySelector(".agent-log-tool")).toBeNull(); - expect(container.querySelector(".agent-log-tool-result")).toBeNull(); - expect(container.querySelector(".agent-log-tool-error")).toBeNull(); - const textRows = container.querySelectorAll(".agent-log-text"); - const combined = Array.from(textRows).map((r) => r.textContent).join(" "); - expect(combined).toContain("before tool"); - expect(combined).toContain("after tool"); - - fireEvent.click(toggle); - expect(toggle.textContent).toBe("Tools: On"); - expect(container.querySelector(".agent-log-tool")).toBeTruthy(); - expect(container.querySelector(".agent-log-tool-result")).toBeTruthy(); - expect(container.querySelector(".agent-log-tool-error")).toBeTruthy(); - }); - - it("keeps the latest non-tool message visible as its own row when tools are hidden", () => { - const entries = [ - makeEntry({ text: "Starting plan", type: "text", agent: "executor", timestamp: "2026-01-01T00:00:00Z" }), - makeEntry({ text: "read file", type: "tool", agent: "executor", timestamp: "2026-01-01T00:00:01Z" }), - makeEntry({ text: "Final answer", type: "text", agent: "executor", timestamp: "2026-01-01T00:00:02Z" }), - ]; - const { container } = render(); - const toggle = container.querySelector("[data-testid='agent-log-tool-output-toggle']") as HTMLButtonElement; - - fireEvent.click(toggle); - - const textRows = container.querySelectorAll(".agent-log-text"); - expect(textRows).toHaveLength(2); - expect(textRows[0].textContent).toContain("Starting plan"); - expect(textRows[1].textContent).toContain("Final answer"); - expect(container.querySelectorAll(".agent-log-agent-badge")).toHaveLength(2); - expect(container.querySelectorAll(".agent-log-timestamp")).toHaveLength(2); - }); - - it("does not render any tool log entries when off (only agent text)", () => { - const entries = [ - makeEntry({ text: "Read", type: "tool", agent: "executor", detail: "some/path" }), - makeEntry({ text: "thinking out loud", type: "thinking", agent: "executor" }), - ]; - const { container } = render(); - const toggle = container.querySelector("[data-testid='agent-log-tool-output-toggle']") as HTMLButtonElement; - fireEvent.click(toggle); - - expect(container.querySelector(".agent-log-tool")).toBeNull(); - expect(container.querySelector("[data-testid='tool-detail-toggle']")).toBeNull(); - expect(container.querySelector(".agent-log-thinking")).toBeTruthy(); - }); - - it("reflects hidden tool entries in the pagination summary", () => { - const entries = [ - makeEntry({ text: "hi", type: "text" }), - makeEntry({ text: "Read", type: "tool" }), - makeEntry({ text: "done", type: "tool_result" }), - ]; - const { container } = render( - , - ); - const toggle = container.querySelector("[data-testid='agent-log-tool-output-toggle']") as HTMLButtonElement; - fireEvent.click(toggle); - - const summary = container.querySelector("[data-testid='agent-log-summary']") as HTMLElement; - expect(summary).toBeTruthy(); - expect(summary.textContent).toContain("Showing 1 of 3 entries"); - expect(summary.textContent).toContain("2 tool entries hidden"); - }); - }); - - describe("toggle persistence across remounts", () => { - it("persists the markdown toggle state in localStorage", () => { - const entries = [makeEntry()]; - const first = render(); - const toggle = first.container.querySelector( - "[data-testid='agent-log-mode-toggle']", - ) as HTMLButtonElement; - fireEvent.click(toggle); - expect(window.localStorage.getItem("fn-agent-log-markdown")).toBe("false"); - first.unmount(); - - const second = render(); - const restoredToggle = second.container.querySelector( - "[data-testid='agent-log-mode-toggle']", - ) as HTMLButtonElement; - expect(restoredToggle.textContent).toBe("Plain"); - expect(restoredToggle.getAttribute("aria-pressed")).toBe("false"); - }); - - it("persists the tool output toggle state in localStorage", () => { - const entries = [ - makeEntry({ text: "Read", type: "tool" }), - makeEntry({ text: "hi", type: "text" }), - ]; - const first = render(); - const toggle = first.container.querySelector( - "[data-testid='agent-log-tool-output-toggle']", - ) as HTMLButtonElement; - fireEvent.click(toggle); - expect(window.localStorage.getItem("fn-agent-log-tool-output")).toBe("false"); - first.unmount(); - - const second = render(); - const restoredToggle = second.container.querySelector( - "[data-testid='agent-log-tool-output-toggle']", - ) as HTMLButtonElement; - expect(restoredToggle.textContent).toBe("Tools: Off"); - expect(second.container.querySelector(".agent-log-tool")).toBeNull(); - }); - - it("uses default true values when no preference is stored", () => { - const entries = [makeEntry({ text: "Read", type: "tool" })]; - const { container } = render(); - const markdown = container.querySelector( - "[data-testid='agent-log-mode-toggle']", - ) as HTMLButtonElement; - const tools = container.querySelector( - "[data-testid='agent-log-tool-output-toggle']", - ) as HTMLButtonElement; - expect(markdown.textContent).toBe("Markdown"); - expect(tools.textContent).toBe("Tools: On"); - }); - }); - - describe("fullscreen toggle", () => { - it("applies matching min dimensions to markdown and fullscreen header toggles", () => { - const entries = [makeEntry()]; - const { container } = render(); - const markdownToggle = container.querySelector( - "[data-testid='agent-log-mode-toggle']", - ) as HTMLButtonElement; - const fullscreenToggle = container.querySelector( - "[data-testid='agent-log-fullscreen-toggle']", - ) as HTMLButtonElement; - - const markdownStyle = getComputedStyle(markdownToggle); - const fullscreenStyle = getComputedStyle(fullscreenToggle); - - expect(markdownStyle.minWidth).toBe(fullscreenStyle.minWidth); - expect(markdownStyle.minHeight).toBe(fullscreenStyle.minHeight); - expect(markdownStyle.minWidth).not.toBe("0px"); - expect(markdownStyle.minHeight).not.toBe("0px"); - }); - - it("adds visible gap spacing between header toggle buttons", () => { - const entries = [makeEntry()]; - const { container } = render(); - const toggleGroup = container.querySelector(".agent-log-model-header-toggle") as HTMLElement; - const toggleGroupStyle = getComputedStyle(toggleGroup); - - expect(toggleGroupStyle.gap).not.toBe(""); - expect(toggleGroupStyle.gap).not.toBe("normal"); - }); - - it("renders the fullscreen toggle button in the model info header", () => { - const entries = [makeEntry()]; - const { container } = render(); - const toggle = container.querySelector("[data-testid='agent-log-fullscreen-toggle']"); - expect(toggle).toBeTruthy(); - }); - - it("has correct aria attributes on the fullscreen toggle", () => { - const entries = [makeEntry()]; - const { container } = render(); - const toggle = container.querySelector("[data-testid='agent-log-fullscreen-toggle']") as HTMLButtonElement; - expect(toggle).toBeTruthy(); - expect(toggle.getAttribute("aria-label")).toBe("Expand agent log to full screen"); - expect(toggle.getAttribute("title")).toBe("Expand agent log to full screen"); - }); - - it("adds fullscreen class when toggle is clicked", () => { - const entries = [makeEntry()]; - const { container } = render(); - const viewer = container.querySelector("[data-testid='agent-log-viewer']") as HTMLElement; - const toggle = container.querySelector("[data-testid='agent-log-fullscreen-toggle']") as HTMLButtonElement; - - // Initially not fullscreen - expect(viewer.classList.contains("agent-log-viewer--fullscreen")).toBe(false); - - // Click to enter fullscreen - fireEvent.click(toggle); - - // Should have fullscreen class - expect(viewer.classList.contains("agent-log-viewer--fullscreen")).toBe(true); - }); - - it("removes fullscreen class when toggle is clicked while in fullscreen", () => { - const entries = [makeEntry()]; - const { container } = render(); - const viewer = container.querySelector("[data-testid='agent-log-viewer']") as HTMLElement; - const toggle = container.querySelector("[data-testid='agent-log-fullscreen-toggle']") as HTMLButtonElement; - - // Enter fullscreen - fireEvent.click(toggle); - expect(viewer.classList.contains("agent-log-viewer--fullscreen")).toBe(true); - - // Exit fullscreen - fireEvent.click(toggle); - expect(viewer.classList.contains("agent-log-viewer--fullscreen")).toBe(false); - }); - - it("updates aria label when toggling fullscreen", () => { - const entries = [makeEntry()]; - const { container } = render(); - const toggle = container.querySelector("[data-testid='agent-log-fullscreen-toggle']") as HTMLButtonElement; - - // Initially shows expand label - expect(toggle.getAttribute("aria-label")).toBe("Expand agent log to full screen"); - - // Enter fullscreen - fireEvent.click(toggle); - expect(toggle.getAttribute("aria-label")).toBe("Exit full screen"); - - // Exit fullscreen - fireEvent.click(toggle); - expect(toggle.getAttribute("aria-label")).toBe("Expand agent log to full screen"); - }); - - it("exits fullscreen when Escape key is pressed", () => { - const entries = [makeEntry()]; - const { container } = render(); - const viewer = container.querySelector("[data-testid='agent-log-viewer']") as HTMLElement; - const toggle = container.querySelector("[data-testid='agent-log-fullscreen-toggle']") as HTMLButtonElement; - - // Enter fullscreen - fireEvent.click(toggle); - expect(viewer.classList.contains("agent-log-viewer--fullscreen")).toBe(true); - - // Press Escape to exit - fireEvent.keyDown(document, { key: "Escape" }); - - expect(viewer.classList.contains("agent-log-viewer--fullscreen")).toBe(false); - }); - - it("does nothing when Escape key is pressed while not in fullscreen", () => { - const entries = [makeEntry()]; - const { container } = render(); - const viewer = container.querySelector("[data-testid='agent-log-viewer']") as HTMLElement; - const toggle = container.querySelector("[data-testid='agent-log-fullscreen-toggle']") as HTMLButtonElement; - - // Initially not fullscreen - expect(viewer.classList.contains("agent-log-viewer--fullscreen")).toBe(false); - - // Press Escape - should do nothing - fireEvent.keyDown(document, { key: "Escape" }); - - expect(viewer.classList.contains("agent-log-viewer--fullscreen")).toBe(false); - - // Toggle should still work normally - fireEvent.click(toggle); - expect(viewer.classList.contains("agent-log-viewer--fullscreen")).toBe(true); - }); - - it("only responds to Escape key when in fullscreen mode", () => { - const entries = [makeEntry()]; - const { container, unmount } = render(); - const viewer = container.querySelector("[data-testid='agent-log-viewer']") as HTMLElement; - const toggle = container.querySelector("[data-testid='agent-log-fullscreen-toggle']") as HTMLButtonElement; - - // Press Escape when not fullscreen - no effect - fireEvent.keyDown(document, { key: "Escape" }); - expect(viewer.classList.contains("agent-log-viewer--fullscreen")).toBe(false); - - // Enter fullscreen - fireEvent.click(toggle); - expect(viewer.classList.contains("agent-log-viewer--fullscreen")).toBe(true); - - // Clean up to remove the keydown listener - unmount(); - - // Verify the listener was removed (no errors should occur when Escape is pressed after unmount) - }); - }); -}); diff --git a/scripts/line-count-baseline.json b/scripts/line-count-baseline.json index cf2f03d1f6..757d155af6 100644 --- a/scripts/line-count-baseline.json +++ b/scripts/line-count-baseline.json @@ -34,7 +34,6 @@ "packages/dashboard/app/components/TaskDetailModal.tsx": 4636, "packages/dashboard/app/components/TerminalModal.tsx": 2313, "packages/dashboard/app/components/WorkflowNodeEditor.tsx": 4868, - "packages/dashboard/app/components/__tests__/AgentLogViewer.test.tsx": 2010, "packages/dashboard/app/components/__tests__/AgentsView.test.tsx": 2817, "packages/dashboard/app/components/__tests__/App.test.tsx": 4437, "packages/dashboard/app/components/__tests__/ChatView.test.tsx": 5822, From 45727f14592ecd0865bd1a22904b27b4b26d59d2 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Thu, 25 Jun 2026 19:57:49 -0700 Subject: [PATCH 03/50] FN-7019: fix Command Center range filtering Command Center analytics now honor picker presets and open-ended date bounds.\n\n- Serialize All time with an explicit upper bound and preserve one-sided custom/preset query params.\n- Resolve server analytics ranges as open windows for from-only and to-only requests instead of defaulting them away.\n- Cover picker query serialization and range-consuming Command Center endpoints with regression tests.\n- Document the restored picker contract and add a patch changeset.\n\nFiles changed:\n .changeset/fn-7019-command-center-range.md | 7 ++\n docs/dashboard-guide.md | 3 +-\n .../components/command-center/DateRangePicker.tsx | 12 ++-\n .../command-center/areas/__tests__/areas.test.tsx | 54 ++++++++++++-\n .../components/command-center/areas/areaShared.ts | 8 +-\n .../register-command-center-routes.test.ts | 92 +++++++++++++++++++++-\n .../src/routes/register-command-center-routes.ts | 28 ++++---\n 7 files changed, 183 insertions(+), 21 deletions(-) Fusion-Task-Id: FN-7019 Fusion-Task-Lineage: 327f8f45-8ad8-4103-8cc4-efcf2af6a73a --- .changeset/fn-7019-command-center-range.md | 7 ++ docs/dashboard-guide.md | 3 +- .../command-center/DateRangePicker.tsx | 12 ++- .../areas/__tests__/areas.test.tsx | 54 ++++++++++- .../command-center/areas/areaShared.ts | 8 +- .../register-command-center-routes.test.ts | 92 ++++++++++++++++++- .../routes/register-command-center-routes.ts | 28 +++--- 7 files changed, 183 insertions(+), 21 deletions(-) create mode 100644 .changeset/fn-7019-command-center-range.md diff --git a/.changeset/fn-7019-command-center-range.md b/.changeset/fn-7019-command-center-range.md new file mode 100644 index 0000000000..2d7250a7b8 --- /dev/null +++ b/.changeset/fn-7019-command-center-range.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Command Center date-range presets now correctly filter charts. +category: fix +dev: Honors open-ended Command Center analytics bounds and serializes All time explicitly. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 1946c032c6..1cbeee9cc1 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -827,7 +827,8 @@ Navigation: - Deep link: `?view=command-center` Features: -- Global date-range picker in the header scopes the analytics tabs; **Mission Control** remains live rather than historical. +- Global date-range picker in the header scopes the analytics tabs; **Last 24h**, **Last 7 days**, **Last 30 days**, **All time**, and custom/open-ended ranges each request their selected analytics window. **Mission Control** remains live rather than historical. + - **Overview controls dashboard** sits at the top of the Overview landing surface on desktop and mobile. It includes AI engine stop/start backed by `globalPause`, live scheduler status from executor stats, range sliders for `maxConcurrent`, `maxTriageConcurrent`, and `maxWorktrees` that persist through `/api/settings`, and a compact theme dropdown with the same color-chip swatches and Shadcn variant list as Settings → Appearance. These controls reuse existing APIs and App-level theme setters; they do not add a new backend route or second theme owner. - **Overview** summarizes token usage/cost, autonomy, active nodes, sessions, agent runs, tasks done, model breadth, and real open signals, and includes the SDLC throughput funnel for the selected range at the bottom of the Overview content in loading, error, empty, and populated states. Its token total and Live activity snapshot token metric refresh on a bounded live cadence and animate number changes while preserving reduced-motion preferences. The sessions card uses the selected-range `ActivityAnalytics.sessions` value already loaded for the overview. The Live activity snapshot also shows the current board-state count for tasks in progress, independent of the selected analytics date range. Overview includes a graph-rich software-factory snapshot with the existing tokens-by-model bar, tool-category bar, real recharts token-share pie, and the daily activity multi-series line chart placed before the daily activity sparkline/trend so the richer line graph sits higher in the chart grid. These reuse the already-loaded tokens, tools, activity, and signals analytics; the signals count comes from `/api/command-center/signals` and renders unavailable (`—`) while the incidents-backed response is loading or unavailable. The chart reveal/glow accents are decorative and disabled when reduced-motion preferences are active. The SDLC completion rate is shown as a radial gauge and is calculated as cohort conversion from in-range triage entrants, so the rate is capped at 100% even when older tasks finish during the range. diff --git a/packages/dashboard/app/components/command-center/DateRangePicker.tsx b/packages/dashboard/app/components/command-center/DateRangePicker.tsx index 126e15528a..f0c4f001b6 100644 --- a/packages/dashboard/app/components/command-center/DateRangePicker.tsx +++ b/packages/dashboard/app/components/command-center/DateRangePicker.tsx @@ -4,9 +4,9 @@ import { Calendar } from "lucide-react"; import "./DateRangePicker.css"; export interface DateRange { - /** ISO date string (YYYY-MM-DD) or null for an open lower bound. */ + /** ISO date string/timestamp or null for an open lower bound. */ from: string | null; - /** ISO date string (YYYY-MM-DD) or null for an open upper bound (now). */ + /** ISO date string/timestamp or null for an open upper bound (now). */ to: string | null; /** Identifier for the active preset, or "custom". */ preset: string; @@ -35,8 +35,12 @@ export function defaultPresets(t: (key: string, fallback: string) => string): Da } export function rangeFromPreset(preset: DateRangePreset): DateRange { + /* + FNXC:CommandCenter 2026-06-25-00:00: + FN-7019 requires picker presets to serialize windows the server can distinguish. Bounded presets keep an open upper bound (`to: null`) so the server resolves `[from, now]`; All time must carry the selection timestamp as an explicit upper bound so it resolves `[epoch, selected-now]` instead of collapsing into the no-param default window. + */ if (preset.days === null) { - return { from: null, to: null, preset: preset.id }; + return { from: null, to: new Date(Date.now()).toISOString(), preset: preset.id }; } const from = new Date(Date.now() - preset.days * 86_400_000); return { from: from.toISOString().slice(0, 10), to: null, preset: preset.id }; @@ -151,7 +155,7 @@ export function DateRangePicker({ value, onChange, presets }: DateRangePickerPro {t("commandCenter.range.to", "To")} applyCustom(value.from, e.target.value || null)} data-testid="cc-date-range-to" /> diff --git a/packages/dashboard/app/components/command-center/areas/__tests__/areas.test.tsx b/packages/dashboard/app/components/command-center/areas/__tests__/areas.test.tsx index ab05135af8..a4328fcf03 100644 --- a/packages/dashboard/app/components/command-center/areas/__tests__/areas.test.tsx +++ b/packages/dashboard/app/components/command-center/areas/__tests__/areas.test.tsx @@ -57,7 +57,8 @@ import { ActivityArea } from "../ActivityArea"; import { EcosystemArea } from "../EcosystemArea"; import { useAnalyticsArea } from "../useAnalyticsArea"; import { ConfirmDialogProvider } from "../../../../hooks/useConfirm"; -import type { DateRange } from "../DateRangePicker"; +import { rangeQuery } from "../areaShared"; +import { defaultPresets, rangeFromPreset, type DateRange } from "../../DateRangePicker"; const range7d: DateRange = { from: "2026-06-08", to: null, preset: "7d" }; const customRange = (from: string, to: string): DateRange => ({ from, to, preset: "custom" }); @@ -363,6 +364,28 @@ function expectSvgLineFillsBoxAndKeepsRoundMarkers(testId: string, label: string expect(pointPairs.at(-1)?.[0]).toBe(viewBoxWidth - 3); } +describe("rangeQuery / rangeFromPreset", () => { + it("serializes every default preset into a distinct server-resolvable query", () => { + vi.useFakeTimers({ now: new Date("2026-06-15T12:00:00.000Z") }); + const presets = defaultPresets((_key, fallback) => fallback); + const queries = Object.fromEntries(presets.map((preset) => [preset.id, rangeQuery(rangeFromPreset(preset))])); + + expect(queries).toEqual({ + "24h": "?from=2026-06-14", + "7d": "?from=2026-06-08", + "30d": "?from=2026-05-16", + all: "?to=2026-06-15T12%3A00%3A00.000Z", + }); + expect(new Set(Object.values(queries)).size).toBe(presets.length); + }); + + it("preserves custom and open-ended custom ranges without collapsing them", () => { + expect(rangeQuery(customRange("2026-06-01", "2026-06-10"))).toBe("?from=2026-06-01&to=2026-06-10"); + expect(rangeQuery({ from: "2026-06-01", to: null, preset: "custom" })).toBe("?from=2026-06-01"); + expect(rangeQuery({ from: null, to: "2026-06-10", preset: "custom" })).toBe("?to=2026-06-10"); + }); +}); + describe("useAnalyticsArea", () => { it("polls only when pollMs is provided and clears the interval on unmount", async () => { vi.useFakeTimers(); @@ -409,6 +432,35 @@ describe("useAnalyticsArea", () => { expect(apiMock).toHaveBeenCalledTimes(1); }); + it("refetches with distinct request keys for each default preset", async () => { + vi.useFakeTimers({ now: new Date("2026-06-15T12:00:00.000Z") }); + apiMock.mockResolvedValue({ ok: true }); + const presets = defaultPresets((_key, fallback) => fallback); + const ranges = presets.map(rangeFromPreset); + + const { rerender } = renderHook( + ({ range }) => useAnalyticsArea<{ ok: boolean }>("/command-center/tokens", range), + { initialProps: { range: ranges[0] as DateRange } }, + ); + + await act(async () => { + await Promise.resolve(); + }); + for (const range of ranges.slice(1)) { + rerender({ range }); + await act(async () => { + await Promise.resolve(); + }); + } + + expect(apiMock.mock.calls.map(([path]) => path)).toEqual([ + "/command-center/tokens?from=2026-06-14", + "/command-center/tokens?from=2026-06-08", + "/command-center/tokens?from=2026-05-16", + "/command-center/tokens?to=2026-06-15T12%3A00%3A00.000Z", + ]); + }); + it("does not fetch or schedule polling for inverted custom ranges", async () => { vi.useFakeTimers(); diff --git a/packages/dashboard/app/components/command-center/areas/areaShared.ts b/packages/dashboard/app/components/command-center/areas/areaShared.ts index e2c2bd214b..e8f0cc309b 100644 --- a/packages/dashboard/app/components/command-center/areas/areaShared.ts +++ b/packages/dashboard/app/components/command-center/areas/areaShared.ts @@ -3,12 +3,16 @@ import type { DateRange } from "../DateRangePicker"; /* FNXC:CommandCenter 2026-06-16-09:42: Shared Command Center area helpers (PR #1683): date-range query building and count formatting reused across the analytics areas so range-to-query and unavailable-vs-zero rendering stay consistent. + +FNXC:CommandCenter 2026-06-25-00:00: +FN-7019 defines null date bounds as open analytics windows, not as a request to default. `rangeQuery` preserves every non-null bound so bounded presets refetch with `from=...` and All time refetches with the picker's explicit `to=selected-now` upper bound. */ /** * Build the `?from=&to=` query string for an analytics endpoint from a - * {@link DateRange}. Open bounds (null) are omitted so the server applies its - * documented default window. The picker already rejects `from > to` + * {@link DateRange}. Open bounds (null) are omitted because the server resolves + * one-sided requests as open windows; a range with no usable bounds remains the + * documented programmatic default. The picker already rejects `from > to` * client-side, but we guard here too so a programmatic caller cannot send an * inverted range. */ diff --git a/packages/dashboard/src/__tests__/register-command-center-routes.test.ts b/packages/dashboard/src/__tests__/register-command-center-routes.test.ts index 0494217358..d61b9c7414 100644 --- a/packages/dashboard/src/__tests__/register-command-center-routes.test.ts +++ b/packages/dashboard/src/__tests__/register-command-center-routes.test.ts @@ -253,6 +253,7 @@ describe("register-command-center-routes", () => { }); afterEach(() => { + vi.useRealTimers(); vi.restoreAllMocks(); mockInvalidateAllGlobalSettingsCaches.mockClear(); dbA.close(); @@ -524,6 +525,61 @@ describe("register-command-center-routes", () => { expect(signals.body).toHaveProperty("bySeverity"); }); + it("honors picker-shaped from-only ranges for tokens, activity, and productivity", async () => { + vi.useFakeTimers({ now: new Date("2026-04-01T00:00:00.000Z") }); + seedAgentRun(dbA, { id: "run-open-bound", agentId: "agent-open", startedAt: "2026-03-02T00:00:00.000Z", status: "completed" }); + seedCompletedTaskDuration(dbA, { id: "FN-open-duration", cumulativeActiveMs: 45_000, completedAt: "2026-03-03T00:00:00.000Z" }); + + const pickerRange = "from=2026-02-01T00%3A00%3A00.000Z"; + const expectedTo = "2026-04-01T00:00:00.000Z"; + const tokens = await request(app, "GET", `/api/command-center/tokens?${pickerRange}&projectId=proj-a`); + const activity = await request(app, "GET", `/api/command-center/activity?${pickerRange}&projectId=proj-a`); + const productivity = await request(app, "GET", `/api/command-center/productivity?${pickerRange}&projectId=proj-a`); + + expect(tokens.status).toBe(200); + expect(tokens.body).toMatchObject({ from: "2026-02-01T00:00:00.000Z", to: expectedTo }); + expect((tokens.body as { totals: { totalTokens: number } }).totals.totalTokens).toBe(200); + expect(activity.body).toMatchObject({ from: "2026-02-01T00:00:00.000Z", to: expectedTo }); + expect((activity.body as { agentRuns: { total: number } }).agentRuns.total).toBe(1); + expect(productivity.body).toMatchObject({ from: "2026-02-01T00:00:00.000Z", to: expectedTo }); + expect((productivity.body as { taskDuration: { completedTasks: number } }).taskDuration.completedTasks).toBe(1); + + const defaultTokens = await request(app, "GET", "/api/command-center/tokens?projectId=proj-a"); + const defaultActivity = await request(app, "GET", "/api/command-center/activity?projectId=proj-a"); + const defaultProductivity = await request(app, "GET", "/api/command-center/productivity?projectId=proj-a"); + expect(defaultTokens.body).toMatchObject({ from: "2026-03-25T00:00:00.000Z", to: expectedTo }); + expect((defaultTokens.body as { totals: { totalTokens: number } }).totals.totalTokens).toBe(0); + expect((defaultActivity.body as { agentRuns: { total: number } }).agentRuns.total).toBe(0); + expect((defaultProductivity.body as { taskDuration: { completedTasks: number } }).taskDuration.completedTasks).toBe(0); + }); + + it("applies from-only resolved bounds on every range-consuming analytics endpoint", async () => { + vi.useFakeTimers({ now: new Date("2026-04-01T00:00:00.000Z") }); + const endpoints = [ + "tokens", + "tools", + "activity", + "productivity", + "team", + "github", + "signals", + "plugin-activations", + ]; + + for (const endpoint of endpoints) { + const res = await request( + app, + "GET", + `/api/command-center/${endpoint}?from=2026-02-01T00%3A00%3A00.000Z&projectId=proj-a`, + ); + expect(res.status, endpoint).toBe(200); + expect(res.body, endpoint).toMatchObject({ + from: "2026-02-01T00:00:00.000Z", + to: "2026-04-01T00:00:00.000Z", + }); + } + }); + it("runs the productivity LOC backfill route as a dry-run by default and respects writes", async () => { const backfill = vi.fn(async (options?: { dryRun?: boolean }) => ({ scannedRows: 3, @@ -889,9 +945,41 @@ describe("resolveRange / resolveGroupBy / resolveTokenGranularity (param parsing expect(r.defaulted).toBe(true); }); - it("defaults when a bound is unparseable", () => { - const r = resolveRange({ from: "garbage", to: "2026-06-10T00:00:00.000Z" }, NOW); + it("honors a from-only bound as the symptom regression anchor", () => { + const r = resolveRange({ from: "2026-06-01T00:00:00.000Z" }, NOW); + expect(r.defaulted).toBe(false); + expect(r.from).toBe("2026-06-01T00:00:00.000Z"); + expect(r.to).toBe(new Date(NOW).toISOString()); + }); + + it("honors a to-only bound as an all-history window through that date", () => { + const r = resolveRange({ to: "2026-06-10T00:00:00.000Z" }, NOW); + expect(r.defaulted).toBe(false); + expect(r.from).toBe(new Date(0).toISOString()); + expect(r.to).toBe("2026-06-10T00:00:00.000Z"); + }); + + it("uses the remaining valid bound when the other bound is unparseable", () => { + const toOnly = resolveRange({ from: "garbage", to: "2026-06-10T00:00:00.000Z" }, NOW); + expect(toOnly).toEqual({ + from: new Date(0).toISOString(), + to: "2026-06-10T00:00:00.000Z", + defaulted: false, + }); + + const fromOnly = resolveRange({ from: "2026-06-01T00:00:00.000Z", to: "garbage" }, NOW); + expect(fromOnly).toEqual({ + from: "2026-06-01T00:00:00.000Z", + to: new Date(NOW).toISOString(), + defaulted: false, + }); + }); + + it("defaults only when neither bound is usable", () => { + const r = resolveRange({ from: "garbage", to: "also-bad" }, NOW); expect(r.defaulted).toBe(true); + expect(r.to).toBe(new Date(NOW).toISOString()); + expect(r.from).toBe(new Date(NOW - DEFAULT_WINDOW_DAYS * 24 * 60 * 60 * 1000).toISOString()); }); it("accepts known groupBy values and ignores unknown ones", () => { diff --git a/packages/dashboard/src/routes/register-command-center-routes.ts b/packages/dashboard/src/routes/register-command-center-routes.ts index 52c2ee711e..cc5370de86 100644 --- a/packages/dashboard/src/routes/register-command-center-routes.ts +++ b/packages/dashboard/src/routes/register-command-center-routes.ts @@ -86,9 +86,13 @@ function isValidIso(value: string): boolean { /** * Resolve `from`/`to` query params into an always-valid ISO range. * - * Both bounds must be present, parseable, and ordered (`from <= to`); otherwise - * the documented default window (last {@link DEFAULT_WINDOW_DAYS} days ending - * now) is used and `defaulted` is true. `now` is injectable for tests. + * FNXC:CommandCenter 2026-06-25-00:00: + * FN-7019 fixes the picker/server contract: the date picker omits null bounds, + * so a from-only request means `[from, now]` and a to-only request means + * `[epoch, to]`. Only a truly empty/invalid range or an ordered-range violation + * may fall back to the documented default window; otherwise presets collapse to + * last-7-days and Command Center charts do not change when operators select a + * different range. `now` is injectable for tests. */ export function resolveRange( query: Request["query"], @@ -96,15 +100,17 @@ export function resolveRange( ): ResolvedRange { const rawFrom = typeof query.from === "string" ? query.from : undefined; const rawTo = typeof query.to === "string" ? query.to : undefined; + const fromMs = rawFrom !== undefined && isValidIso(rawFrom) ? Date.parse(rawFrom) : undefined; + const toMs = rawTo !== undefined && isValidIso(rawTo) ? Date.parse(rawTo) : undefined; - if ( - rawFrom !== undefined && - rawTo !== undefined && - isValidIso(rawFrom) && - isValidIso(rawTo) && - Date.parse(rawFrom) <= Date.parse(rawTo) - ) { - return { from: rawFrom, to: rawTo, defaulted: false }; + if (fromMs !== undefined && toMs !== undefined) { + if (fromMs <= toMs) { + return { from: rawFrom as string, to: rawTo as string, defaulted: false }; + } + } else if (fromMs !== undefined) { + return { from: rawFrom as string, to: new Date(now).toISOString(), defaulted: false }; + } else if (toMs !== undefined) { + return { from: new Date(0).toISOString(), to: rawTo as string, defaulted: false }; } const to = new Date(now).toISOString(); From 0ae44992dfbef67700902419e998976b7c842725 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Thu, 25 Jun 2026 20:03:53 -0700 Subject: [PATCH 04/50] FN-7030: route graph tasks to shared pop-out Route dependency Graph task opens through the shared movable task pop-out.\n\n- Send dependency-graph plugin task opens and rendered graph task cards to popOutTaskDetail.\n- Preserve fixed modal behavior for non-graph plugin dashboard views.\n- Document the Graph behavior and add regression coverage for desktop, mobile, and pop-out deduping.\n- Add a patch changeset for the published CLI package.\n\nFiles changed:\n .changeset/fn-7030-graph-task-popout.md | 7 +\n docs/dashboard-guide.md | 4 +-\n .../app/components/dashboard/MainContent.tsx | 15 +-\n .../__tests__/MainContent.graph-popout.test.tsx | 274 +++++++++++++++++++++\n 4 files changed, 296 insertions(+), 4 deletions(-) Fusion-Task-Id: FN-7030 Fusion-Task-Lineage: 83008d81-218e-49ce-9aff-cb6b51d84dba --- .changeset/fn-7030-graph-task-popout.md | 7 + docs/dashboard-guide.md | 4 +- .../app/components/dashboard/MainContent.tsx | 15 +- .../MainContent.graph-popout.test.tsx | 274 ++++++++++++++++++ 4 files changed, 296 insertions(+), 4 deletions(-) create mode 100644 .changeset/fn-7030-graph-task-popout.md create mode 100644 packages/dashboard/app/components/dashboard/__tests__/MainContent.graph-popout.test.tsx diff --git a/.changeset/fn-7030-graph-task-popout.md b/.changeset/fn-7030-graph-task-popout.md new file mode 100644 index 0000000000..305858d32a --- /dev/null +++ b/.changeset/fn-7030-graph-task-popout.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Open dependency Graph tasks in the shared movable task pop-out. +category: fix +dev: Routes graph plugin task-open callbacks through MainContent popOutTaskDetail while preserving non-graph plugin modal behavior. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 1cbeee9cc1..a95a85b62b 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -181,8 +181,8 @@ Behavior: - Pan limits are zoom-aware and based on full graph extents (including negative auto-layout origins), so zoomed-in views can still pan to every rendered node instead of getting trapped by fixed viewport-only bounds - Dependency graph nodes reuse the same `TaskCard` UI as board/list views, so status badges, progress/steps, mission badges, retry/archive controls, and active-task glow stay visually consistent - Active graph nodes also add a dedicated top status indicator bar and current-step row highlighting so in-progress execution state stays visible even when zoomed out -- Clicking a graph card opens task details via the host detail handler (`onOpenDetail`, with `onOpenTaskDetail` fallback), while clicking the same card again or empty canvas clears selection -- On touch devices, single-tap is reserved for pan/drag gestures, so double-tapping a node opens its task detail modal; this does not change selection state. +- Clicking a graph card opens task details in the shared movable/resizable task pop-out via the host detail handler (`onOpenDetail`, with `onOpenTaskDetail` fallback), while clicking the same card again or empty canvas clears selection. +- On touch devices, single-tap is reserved for pan/drag gestures, so double-tapping a node opens the same shared task pop-out; this does not change selection state. - Hovering or selecting a node highlights its full upstream and downstream dependency chain; highlighted nodes and connecting edges are emphasized while non-chain nodes are dimmed, and highlight clears when hover/selection is removed - Nodes support manual drag repositioning with a 4px movement threshold to separate click from drag, using pointer capture and zoom-aware delta scaling for reliable tracking - Custom node positions persist per project in browser localStorage (`kb:${projectId}:fusion-plugin-dependency-graph:positions`) across refresh/project switches, and **Fit to graph** clears saved positions and restores auto-layout diff --git a/packages/dashboard/app/components/dashboard/MainContent.tsx b/packages/dashboard/app/components/dashboard/MainContent.tsx index 8ea6715dc1..43233d1e2e 100644 --- a/packages/dashboard/app/components/dashboard/MainContent.tsx +++ b/packages/dashboard/app/components/dashboard/MainContent.tsx @@ -254,6 +254,17 @@ export function MainContent({ const pluginContextTasks = isDependencyGraphView ? filterTasksByGraphWorkflowSelection(pluginTasks, currentProject?.id, graphWorkflowSelection) : pluginTasks; + /* + FNXC:GraphTaskPopout 2026-06-25-12:00: + Dependency-graph task opens must share the movable, resizable FloatingWindow pop-out used by Board/List pop-out and artifact cards. Keep non-graph plugin views on the fixed task-detail modal so plugin contracts outside the Graph view do not change. + */ + const openPluginTaskDetail = (task: Task | TaskDetail, initialTab?: DetailTaskTab) => { + if (isDependencyGraphView) { + popOutTaskDetail(task); + return; + } + openDetailTask(task, initialTab); + }; return ( {isDependencyGraphView ? ( @@ -271,13 +282,13 @@ export function MainContent({ tasks: pluginContextTasks, workflowSteps, subscribePluginEvents, - openTaskDetail: (task: Task | TaskDetail, initialTab?: DetailTaskTab) => openDetailTask(task, initialTab), + openTaskDetail: openPluginTaskDetail, openFile: openFileInBrowser, renderTaskCard: (task: Task | TaskDetail) => ( openDetailTask(value)} + onOpenDetail={openPluginTaskDetail} addToast={addToast} workflowStepNameLookup={workflowStepNameLookup} disableDrag={true} diff --git a/packages/dashboard/app/components/dashboard/__tests__/MainContent.graph-popout.test.tsx b/packages/dashboard/app/components/dashboard/__tests__/MainContent.graph-popout.test.tsx new file mode 100644 index 0000000000..437ba42bf3 --- /dev/null +++ b/packages/dashboard/app/components/dashboard/__tests__/MainContent.graph-popout.test.tsx @@ -0,0 +1,274 @@ +import { lazy } from "react"; +import { act, render, renderHook, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import type { Task, TaskDetail } from "@fusion/core"; +import { MainContent } from "../MainContent"; +import type { MainContentProps } from "../types"; +import { usePoppedOutTasks } from "../../../hooks/usePoppedOutTasks"; +import type { PluginDashboardViewContext } from "../../../plugins/types"; + +const hostContexts: PluginDashboardViewContext[] = []; + +vi.mock("../../../plugins/PluginDashboardViewHost", () => ({ + PluginDashboardViewHost: ({ taskView, context }: { taskView: string; context?: PluginDashboardViewContext }) => { + if (context) hostContexts.push(context); + const task = context?.tasks[0]; + return ( +
+ +
{task && context?.renderTaskCard?.(task)}
+
+ ); + }, +})); + +vi.mock("../../TaskCard", () => ({ + TaskCard: ({ task, onOpenDetail }: { task: Task | TaskDetail; onOpenDetail: (task: Task | TaskDetail) => void }) => ( + + ), +})); + +vi.mock("../../GraphWorkflowSwitcherSlot", () => ({ + GraphWorkflowSwitcherSlot: () =>
, + filterTasksByGraphWorkflowSelection: (tasks: Task[]) => tasks, +})); + +const graphTask = { + id: "FN-GRAPH", + title: "Graph task", + description: "Graph task description", + column: "todo", + status: "todo", + dependencies: [], + createdAt: new Date(0).toISOString(), + updatedAt: new Date(0).toISOString(), +} as unknown as Task; + +const otherTask = { + ...graphTask, + id: "FN-OTHER", + title: "Other graph task", +} as unknown as Task; + +const LazyStub = lazy(async () => ({ default: () => null })); + +function mainContentProps(overrides: Partial = {}): MainContentProps { + return { + showBackendConnectionErrorPage: false, + projectsError: null, + t: ((key: string, fallback?: string) => fallback ?? key) as MainContentProps["t"], + retryingProjects: false, + handleRetryProjects: vi.fn(), + shellApi: null, + taskView: "graph", + modalManager: { + closeSettings: vi.fn(), + settingsInitialSection: undefined, + openWorkflowEditor: vi.fn(), + } as unknown as MainContentProps["modalManager"], + handleChangeTaskView: vi.fn(), + addToast: vi.fn(), + currentProject: { id: "project-1", name: "Project 1" } as MainContentProps["currentProject"], + themeMode: "system", + setThemeMode: vi.fn(), + colorTheme: "default", + setColorTheme: vi.fn(), + dashboardFontScalePct: 100, + setDashboardFontScalePct: vi.fn(), + shadcnCustomColors: {}, + setShadcnCustomColors: vi.fn(), + resolvedThemeMode: "light", + setQuickChatButtonModeImmediate: vi.fn(), + reopenOnboardingWithNav: vi.fn(), + viewMode: "project", + projects: [], + projectsLoading: false, + handleSelectProject: vi.fn(), + handleAddProject: vi.fn(), + handlePauseProject: vi.fn(), + handleResumeProject: vi.fn(), + handleRemoveProject: vi.fn(), + nodes: [], + graphPluginTaskView: "plugin:fusion-plugin-dependency-graph:graph", + graphWorkflowSelection: null, + setGraphWorkflowSelection: vi.fn(), + isRemote: false, + remoteData: { tasks: [] } as unknown as MainContentProps["remoteData"], + tasks: [graphTask], + workflowSteps: [], + subscribePluginEvents: vi.fn(() => vi.fn()), + openDetailTask: vi.fn(), + openFileInBrowser: vi.fn(), + workflowStepNameLookup: new Map(), + prAuthAvailable: false, + autoMerge: true, + settingsLoaded: true, + skillsEnabled: true, + experimentalFeatures: {}, + setQuickChatOpen: vi.fn(), + setMailboxUnreadCount: vi.fn(), + setMissionTargetId: vi.fn(), + setMissionResumeSessionId: vi.fn(), + setMilestoneSliceResumeSessionId: vi.fn(), + missionResumeSessionId: undefined, + missionTargetId: undefined, + milestoneSliceResumeSessionId: undefined, + setGoalAnchorId: vi.fn(), + goalAnchorId: undefined, + agentsEnabled: true, + agentOnboardingEnabled: false, + handleOpenTaskLogs: vi.fn(), + popOutTaskDetail: vi.fn(), + selectedPrId: undefined, + insightsEnabled: true, + handleInsightTaskCreate: vi.fn(), + researchEnabled: true, + openSettingsWithNav: vi.fn(), + researchReadinessVersion: 0, + evalsEnabled: true, + memoryEnabled: true, + goalsEnabled: true, + handleOpenMission: vi.fn(), + todosEnabled: true, + openPlanningWithInitialPlanWithNav: vi.fn(), + ingestCreatedTasks: vi.fn(), + nodesEnabled: true, + openWorkflowEditorWithNav: vi.fn(), + handlePlanningTaskCreated: vi.fn(), + handlePlanningTasksCreated: vi.fn(), + handleGitHubImport: vi.fn(), + devServerEnabled: true, + mainPanelDetailTask: null, + filteredBoardTasks: [], + maxConcurrent: 2, + moveTask: vi.fn(), + pauseTask: vi.fn(), + openTaskDetailInMainPanel: vi.fn(), + openGroupModalWithNav: vi.fn(), + handleBoardQuickCreate: vi.fn(), + openNewTaskWithNav: vi.fn(), + subtaskBreakdownEnabled: true, + openSubtaskBreakdownWithNav: vi.fn(), + toggleAutoMerge: vi.fn(), + globalPaused: false, + updateTask: vi.fn(), + retryTask: vi.fn(), + archiveTask: vi.fn(), + unarchiveTask: vi.fn(), + deleteTask: vi.fn(), + archiveAllDone: vi.fn(), + loadArchivedTasks: vi.fn(), + searchQuery: "", + availableModels: [], + favoriteProviders: [], + favoriteModels: [], + handleOpenDetailWithTab: vi.fn(), + handleToggleFavorite: vi.fn(), + handleToggleModelFavorite: vi.fn(), + taskStuckTimeoutMs: undefined, + staleHighFanoutBlockerAgeThresholdMs: 0, + lastFetchTimeMs: undefined, + openCreateWorkflowWithNav: vi.fn(), + sidebarActive: false, + isMobile: false, + mainPanelDetailInitialTab: "chat", + closeTaskDetailMainPanel: vi.fn(), + setMainPanelDetailTask: vi.fn(), + mergeTask: vi.fn(), + resetTask: vi.fn(), + duplicateTask: vi.fn(), + unpauseTask: vi.fn(), + capacityRiskBannerEnabled: false, + capacityRiskDismissed: false, + capacityRiskSignal: { level: "low", reasons: [] } as unknown as MainContentProps["capacityRiskSignal"], + handleDismissCapacityRisk: vi.fn(), + AgentsView: LazyStub as MainContentProps["AgentsView"], + ChatView: LazyStub as MainContentProps["ChatView"], + CommandCenter: LazyStub as MainContentProps["CommandCenter"], + DevServerView: LazyStub as MainContentProps["DevServerView"], + DocumentsView: LazyStub as MainContentProps["DocumentsView"], + EvalsView: LazyStub as MainContentProps["EvalsView"], + GoalsView: LazyStub as MainContentProps["GoalsView"], + InsightsView: LazyStub as MainContentProps["InsightsView"], + MemoryView: LazyStub as MainContentProps["MemoryView"], + PullRequestView: LazyStub as MainContentProps["PullRequestView"], + ResearchView: LazyStub as MainContentProps["ResearchView"], + SecretsView: LazyStub as MainContentProps["SecretsView"], + SkillsView: LazyStub as MainContentProps["SkillsView"], + TodoView: LazyStub as MainContentProps["TodoView"], + _AutomationsView: LazyStub as MainContentProps["_AutomationsView"], + _ImportTasksView: LazyStub as MainContentProps["_ImportTasksView"], + _SettingsView: LazyStub as MainContentProps["_SettingsView"], + _WorkflowEditorView: LazyStub as MainContentProps["_WorkflowEditorView"], + ...overrides, + }; +} + +describe("MainContent graph task pop-out wiring", () => { + it("routes dependency-graph bridge and rendered task-card opens to the shared pop-out", () => { + hostContexts.length = 0; + const openDetailTask = vi.fn(); + const popOutTaskDetail = vi.fn(); + + render(); + + expect(screen.getByTestId("graph-workflow-switcher")).toBeInTheDocument(); + screen.getByText("Open from plugin bridge").click(); + expect(popOutTaskDetail).toHaveBeenCalledWith(graphTask); + expect(openDetailTask).not.toHaveBeenCalled(); + + screen.getByText("Open rendered task card").click(); + expect(popOutTaskDetail).toHaveBeenCalledTimes(2); + expect(popOutTaskDetail).toHaveBeenLastCalledWith(graphTask); + expect(openDetailTask).not.toHaveBeenCalled(); + }); + + it("keeps non-graph plugin views on the fixed task-detail modal path", () => { + hostContexts.length = 0; + const openDetailTask = vi.fn(); + const popOutTaskDetail = vi.fn(); + + render( + , + ); + + screen.getByText("Open from plugin bridge").click(); + expect(openDetailTask).toHaveBeenCalledWith(graphTask, "logs"); + expect(popOutTaskDetail).not.toHaveBeenCalled(); + + screen.getByText("Open rendered task card").click(); + expect(openDetailTask).toHaveBeenCalledTimes(2); + expect(openDetailTask).toHaveBeenLastCalledWith(graphTask, undefined); + expect(popOutTaskDetail).not.toHaveBeenCalled(); + }); + + it("uses the same graph pop-out path when rendered for mobile", () => { + const openDetailTask = vi.fn(); + const popOutTaskDetail = vi.fn(); + + render(); + + screen.getByText("Open from plugin bridge").click(); + expect(popOutTaskDetail).toHaveBeenCalledWith(graphTask); + expect(openDetailTask).not.toHaveBeenCalled(); + }); + + it("dedupes repeat pop-outs by task id while allowing distinct task windows", () => { + const { result } = renderHook(() => usePoppedOutTasks()); + + act(() => result.current.popOut(graphTask)); + act(() => result.current.popOut(graphTask)); + expect(result.current.tasks).toHaveLength(1); + expect(result.current.tasks[0]?.id).toBe("FN-GRAPH"); + + act(() => result.current.popOut(otherTask)); + expect(result.current.tasks.map((task) => task.id)).toEqual(["FN-GRAPH", "FN-OTHER"]); + }); +}); From c0958bfd4f8c6def8dffdfc477a9b7d4d3af5c30 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Thu, 25 Jun 2026 20:12:20 -0700 Subject: [PATCH 05/50] FN-7029: split AI merge prompts and worktree helpers Split the clean-room AI merger into smaller focused modules while preserving its public behavior. - Extract prompt builders and review verdict parsing into merger-ai-prompts. - Extract AI merge worktree lifecycle and cleanup helpers into merger-ai-worktree. - Re-export the extracted APIs from merger-ai and cover prompt/verdict behavior with tests. - Remove the merger-ai line-count baseline now that the file is under the guardrail. Files changed: .../engine/src/__tests__/merger-ai-prompts.test.ts | 86 ++++ packages/engine/src/merger-ai-prompts.ts | 312 ++++++++++++ packages/engine/src/merger-ai-worktree.ts | 287 +++++++++++ packages/engine/src/merger-ai.ts | 555 ++------------------- scripts/line-count-baseline.json | 1 - 5 files changed, 723 insertions(+), 518 deletions(-) Fusion-Task-Id: FN-7029 Fusion-Task-Lineage: 59adc31f-7386-4008-b74f-8fb9bbae078a --- .../src/__tests__/merger-ai-prompts.test.ts | 86 +++ packages/engine/src/merger-ai-prompts.ts | 312 ++++++++++ packages/engine/src/merger-ai-worktree.ts | 287 +++++++++ packages/engine/src/merger-ai.ts | 555 ++---------------- scripts/line-count-baseline.json | 1 - 5 files changed, 723 insertions(+), 518 deletions(-) create mode 100644 packages/engine/src/__tests__/merger-ai-prompts.test.ts create mode 100644 packages/engine/src/merger-ai-prompts.ts create mode 100644 packages/engine/src/merger-ai-worktree.ts diff --git a/packages/engine/src/__tests__/merger-ai-prompts.test.ts b/packages/engine/src/__tests__/merger-ai-prompts.test.ts new file mode 100644 index 0000000000..d2eb0cf312 --- /dev/null +++ b/packages/engine/src/__tests__/merger-ai-prompts.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from "vitest"; + +import { + REVIEW_VERDICT_MARKER, + buildMergeSystemPrompt, + buildReviewSystemPrompt, + parseReviewVerdict, +} from "../merger-ai.js"; + +describe("merger-ai prompt/verdict re-exports", () => { + it("fails safe to a blocking reject for empty reviewer output", () => { + expect(parseReviewVerdict("")).toEqual({ + verdict: "reject", + reasons: ["reviewer produced no output"], + severity: "blocking", + }); + }); + + it("fails safe to a blocking reject for garbled reviewer output", () => { + expect(parseReviewVerdict("looks good, ship it")).toEqual({ + verdict: "reject", + reasons: [ + `reviewer did not emit a "${REVIEW_VERDICT_MARKER} approve|reject" line`, + ], + severity: "blocking", + }); + }); + + it("treats a reject without explicit severity as blocking", () => { + expect( + parseReviewVerdict( + `${REVIEW_VERDICT_MARKER} reject\n- dropped a conflict hunk` + ) + ).toEqual({ + verdict: "reject", + reasons: ["dropped a conflict hunk"], + severity: "blocking", + }); + }); + + it("honors explicit advisory severity and excludes severity from reasons", () => { + expect( + parseReviewVerdict( + `${REVIEW_VERDICT_MARKER} reject\nSEVERITY: advisory\n- commit message is vague` + ) + ).toEqual({ + verdict: "reject", + reasons: ["commit message is vague"], + severity: "advisory", + }); + }); + + it("parses the approve line", () => { + expect( + parseReviewVerdict(`All reviewed.\n${REVIEW_VERDICT_MARKER} approve`) + ).toEqual({ + verdict: "approve", + reasons: [], + }); + }); + + it("extracts inline and bulleted reject reasons", () => { + expect( + parseReviewVerdict( + `${REVIEW_VERDICT_MARKER} reject: lost generated types\nSEVERITY: blocking\n1. dropped api.ts\n- skipped docs update` + ) + ).toEqual({ + verdict: "reject", + reasons: [ + "lost generated types", + "dropped api.ts", + "skipped docs update", + ], + severity: "blocking", + }); + }); + + it("keeps non-negotiable clean-room and verdict-marker prompt content", () => { + expect(buildMergeSystemPrompt()).toContain("## AI merge — clean room"); + expect(buildMergeSystemPrompt()).toContain( + "Finish with exactly ONE new commit" + ); + expect(buildReviewSystemPrompt()).toContain(REVIEW_VERDICT_MARKER); + expect(buildReviewSystemPrompt()).toContain("Do NOT edit, stage, commit"); + }); +}); diff --git a/packages/engine/src/merger-ai-prompts.ts b/packages/engine/src/merger-ai-prompts.ts new file mode 100644 index 0000000000..9e755d80ee --- /dev/null +++ b/packages/engine/src/merger-ai-prompts.ts @@ -0,0 +1,312 @@ +/* +FNXC:MergerAiSplit 2026-06-25-00:00: +FN-7029 extracts the AI-merge prompt builders and review verdict parser from merger-ai.ts so the sole FN-5633 clean-room merge path stays under the 2000-line guardrail without changing prompts, verdict parsing, or the public merger-ai.js import surface. +*/ +import { + resolveAgentPrompt, + type AgentPromptsConfig, + type TaskComment, +} from "@fusion/core"; + +import { buildUserCommentsPromptSection } from "./agent-user-comments.js"; + +// --------------------------------------------------------------------------- +// Pure helpers (unit-tested) +// --------------------------------------------------------------------------- + +export type AiMergeReviewSeverity = "blocking" | "advisory"; + +export interface AiMergeReviewVerdict { + verdict: "approve" | "reject"; + reasons: string[]; + severity?: AiMergeReviewSeverity; +} + +export const REVIEW_VERDICT_MARKER = "REVIEW_VERDICT:"; +const VERDICT_LINE_RE = /REVIEW_VERDICT:\s*(approve|reject)\b/i; +const SEVERITY_LINE_RE = /SEVERITY:\s*(blocking|advisory)\b/i; + +/** + * Parse the reviewer's free-form output. Fail-safe: no/garbled output, or a + * rejection with no explicit severity, is treated as a BLOCKING reject — an + * ambiguous reviewer can never wave wrong code through, nor silently downgrade + * to advisory. + */ +export function parseReviewVerdict( + agentText: string | null | undefined +): AiMergeReviewVerdict { + const text = (agentText ?? "").trim(); + if (!text) + return { + verdict: "reject", + reasons: ["reviewer produced no output"], + severity: "blocking", + }; + + const lines = text.split(/\r?\n/); + let verdictLineIndex = -1; + let decision: "approve" | "reject" | null = null; + for (let i = lines.length - 1; i >= 0; i--) { + const m = lines[i].match(VERDICT_LINE_RE); + if (m) { + decision = m[1].toLowerCase() as "approve" | "reject"; + verdictLineIndex = i; + break; + } + } + if (!decision) { + return { + verdict: "reject", + reasons: [ + `reviewer did not emit a "${REVIEW_VERDICT_MARKER} approve|reject" line`, + ], + severity: "blocking", + }; + } + if (decision === "approve") return { verdict: "approve", reasons: [] }; + + const severity: AiMergeReviewSeverity = SEVERITY_LINE_RE.test(text) + ? (text.match(SEVERITY_LINE_RE)![1].toLowerCase() as AiMergeReviewSeverity) + : "blocking"; + return { + verdict: "reject", + reasons: extractRejectReasons(lines, verdictLineIndex), + severity, + }; +} + +function extractRejectReasons( + lines: string[], + verdictLineIndex: number +): string[] { + const reasons: string[] = []; + const inline = lines[verdictLineIndex] + .replace(VERDICT_LINE_RE, "") + .replace(/^[\s:–—-]+/, "") + .trim(); + if (inline) reasons.push(inline); + for (let i = verdictLineIndex + 1; i < lines.length; i++) { + if (SEVERITY_LINE_RE.test(lines[i])) continue; + const cleaned = lines[i].replace(/^\s*(?:[-*•]|\d+[.)])\s+/, "").trim(); + if (cleaned) reasons.push(cleaned); + } + if (reasons.length === 0) + reasons.push("reviewer rejected the merge without a stated reason"); + return reasons; +} + +export function buildMergeSystemPrompt( + agentPrompts?: AgentPromptsConfig +): string { + // Base persona is the editable "merger" agent prompt (Settings → Prompts); + // the non-negotiable clean-room / verification / commit-trailer rules below + // are always appended so a custom prompt can't drop them. + const base = resolveAgentPrompt("merger", agentPrompts).trim(); + return [ + base, + base ? "" : undefined, + "## AI merge — clean room", + "You are on a CLEAN, detached checkout at the integration branch's current", + "tip. Land the task branch's work as a single commit.", + "", + "Constraints:", + " - Resolve every conflict in favor of the task branch's intent; never drop", + " the task's changes to make a conflict go away.", + " - Do not make edits unrelated to reconciling the two branches.", + " - Do NOT push, force-push, or run `git update-ref` / `git reset --hard`", + " on any other branch. Only commit on this detached HEAD.", + " - Finish with exactly ONE new commit on HEAD containing the task's work.", + "", + "Verify before committing:", + " - After resolving the merge, run the project's checks — tests, type-check,", + " and lint (discover them from the project config / package.json scripts,", + " e.g. test / typecheck / lint / build).", + " - FIX any NEW failure the merge or conflict resolution introduced (a check", + " that passed on the task branch or the integration tip but fails on the", + " merged tree). You do not need to fix failures that were already broken on", + " the integration branch beforehand, but never commit a merge that adds new", + " test, type-check, or lint failures.", + "", + "Commit message:", + " - The subject line must CONCISELY SUMMARIZE the squashed changes in", + ' imperative mood (e.g. "add X", "fix Y") based on the actual diff — do', + " not just restate the task title.", + " - The commit BODY must include:", + " 1) one short narrative summary line,", + " 2) a bullet list of key changes, and", + " 3) a `Files changed:` section populated from `git diff --stat`.", + " - Include the task-id prefix and the trailer lines EXACTLY as given in the", + " task instructions (they associate the commit with the board task).", + ] + .filter((l) => l !== undefined) + .join("\n"); +} + +export function buildMergePrompt(input: { + taskId: string; + branch: string; + integrationBranch: string; + tipSha: string; + /** Task title — a HINT for the summary, not the literal subject. */ + taskTitle?: string; + /** Whether to prefix the subject with the task id. */ + includeTaskId: boolean; + /** Required trailers to append (board association). */ + trailers: string[]; + correctiveReasons?: string[]; + userComments?: TaskComment[]; +}): string { + const subjectShape = input.includeTaskId + ? `"${input.taskId}: "` + : `""`; + const trailerArgs = input.trailers + .map((t) => ` -m ${JSON.stringify(t)}`) + .join(""); + const lines = [ + `Merge branch "${input.branch}" into "${ + input.integrationBranch + }" (HEAD is detached at ${short(input.tipSha)}).`, + "", + "Steps:", + ` 1. Run: git merge --squash ${input.branch}`, + " 2. If there are conflicts, resolve them (favor the task's intent), then `git add` the resolved files.", + " 3. Build a merge body from the staged squash diff:", + " - one short narrative summary line", + " - bullet list of key changes", + " - `Files changed:` + the output of `git diff --stat`", + " 4. Commit the staged result as a SINGLE commit whose subject summarizes the", + ` actual changes${ + input.taskTitle + ? ` (task title hint: ${JSON.stringify(input.taskTitle)})` + : "" + }, including the body above and required trailers:`, + ` git commit -m ${subjectShape} -m ""${trailerArgs}`, + " Keep the trailer line(s) verbatim — they link the commit to the board task.", + " 5. Verify `git log --oneline ${tip}..HEAD` shows exactly one new commit and `git status` is clean.".replace( + "${tip}", + short(input.tipSha) + ), + "", + "If `git merge --squash` reports the branch is already up to date (nothing to", + "merge), do nothing and leave HEAD unchanged.", + ]; + const userCommentsSection = buildUserCommentsPromptSection( + input.userComments ?? [] + ); + if (userCommentsSection) { + lines.push("", userCommentsSection); + } + if (input.correctiveReasons && input.correctiveReasons.length > 0) { + lines.push( + "", + "A prior attempt was REJECTED by review. Redo the merge from the clean tip", + "and address each of these problems:", + ...input.correctiveReasons.map((r) => ` - ${r}`) + ); + } + return lines.join("\n"); +} + +export function buildReviewSystemPrompt(): string { + return [ + "You are an adversarial, read-only merge reviewer. Do NOT edit, stage, commit,", + "or run any mutating git command. Audit the squash commit that is about to be", + "merged into the integration branch and decide whether it is safe to land.", + "", + "Investigate with read-only commands (git show, git diff, git log, cat, grep).", + "Judge on four axes:", + " 1. Completeness — does the squash contain ALL of the task branch's intended", + " changes? Flag any hunk silently dropped during conflict resolution.", + " 2. No collateral — does it touch only files within the task's footprint?", + " 3. Conflict soundness — were conflicts resolved coherently (both sides'", + " intent preserved), not by blindly discarding one side?", + " 4. Commit message — read `git show`'s message: the subject must concisely", + " and ACCURATELY summarize the actual changes (not vague, not a mere", + " restatement of the task title, not misleading). A poor/inaccurate", + " message is an ADVISORY concern (it should be rewritten on retry, but", + " must not block the merge).", + "", + "Bias toward rejection when uncertain.", + "", + `End with a single decision line: "${REVIEW_VERDICT_MARKER} approve" or`, + `"${REVIEW_VERDICT_MARKER} reject". When rejecting, add a "SEVERITY:" line:`, + " - SEVERITY: blocking — a correctness problem (dropped/lost task changes,", + " incomplete squash, or a conflict resolution that discards intent). The", + " merge must NOT land if this is unfixable.", + " - SEVERITY: advisory — a quality/style concern that does not risk", + " correctness; acceptable to land if unresolved.", + "Then list each concrete reason as a bullet.", + ].join("\n"); +} + +export function buildReviewPrompt(input: { + taskId: string; + branch: string; + integrationBranch: string; + tipSha: string; + squashSha: string; + diffStat: string; + priorReasons?: string[]; + userComments?: TaskComment[]; +}): string { + const lines = [ + `Review the squash merge for task ${input.taskId} (branch ${input.branch} → ${input.integrationBranch}).`, + "", + `Integration tip: ${short(input.tipSha)}`, + `Squash commit: ${short(input.squashSha)}`, + "", + "Inspect with:", + ` git show ${input.squashSha}`, + ` git diff ${input.tipSha}..${input.squashSha}`, + "", + "Files changed (git diff --stat):", + input.diffStat.trim() || "(none reported)", + ]; + const userCommentsSection = buildUserCommentsPromptSection( + input.userComments ?? [] + ); + if (userCommentsSection) { + lines.push("", userCommentsSection); + } + if (input.priorReasons && input.priorReasons.length > 0) { + lines.push( + "", + "A prior pass rejected an earlier attempt for these reasons — confirm they", + "are now resolved:", + ...input.priorReasons.map((r) => ` - ${r}`) + ); + } + return lines.join("\n"); +} + +export function buildStashResolveSystemPrompt(): string { + return [ + "You are resolving a conflict between the user's restored local working-tree", + "edits and the freshly-merged integration branch. The user's uncommitted work", + "was stashed, the checkout fast-forwarded to the new tip, and re-applying the", + "stash produced conflicts.", + "", + "Resolve every conflict marker so BOTH sides are preserved: keep the user's", + "local intent AND the upstream changes that just landed. Stage each resolved", + "file with `git add`.", + "", + "Do NOT commit, stash, reset, checkout a different branch, or run update-ref.", + "Leave the resolved changes in the working tree as the user's uncommitted edits.", + ].join("\n"); +} + +export function buildStashResolvePrompt(conflictedFiles: string[]): string { + return [ + "Re-applying your stashed local changes onto the updated branch conflicted.", + "", + "Conflicted files:", + ...conflictedFiles.map((f) => ` - ${f}`), + "", + "Resolve each file's conflict markers (preserve both the local edits and the", + "upstream changes), then `git add` it. Do not commit.", + ].join("\n"); +} + +function short(sha: string): string { + return /^[0-9a-f]{7,40}$/i.test(sha) ? sha.slice(0, 8) : sha; +} diff --git a/packages/engine/src/merger-ai-worktree.ts b/packages/engine/src/merger-ai-worktree.ts new file mode 100644 index 0000000000..2d0fc73a8f --- /dev/null +++ b/packages/engine/src/merger-ai-worktree.ts @@ -0,0 +1,287 @@ +/* +FNXC:MergerAiSplit 2026-06-25-00:00: +FN-7029 extracts AI-merge worktree lifecycle helpers from merger-ai.ts so the sole FN-5633 clean-room merge path stays under the 2000-line guardrail without changing cleanup semantics or public merger-ai.js exports. + +FNXC:MergerAiSplit 2026-06-25-00:00: +Keep importing MIN_TEMP_WORKTREE_REAP_AGE_MS from self-healing.js here. Do not reverse the dependency: self-healing owns the stale-temp age policy and merger-ai-worktree only consumes it for pre-merge pruning, preserving the established self-healing import-cycle constraint. +*/ +import { execFile } from "node:child_process"; +import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, rmSync, statSync } from "node:fs"; +import { rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { isAbsolute, join, relative } from "node:path"; +import { promisify } from "node:util"; +import type { Settings } from "@fusion/core"; + +import { activeSessionRegistry } from "./active-session-registry.js"; +import type { RunAuditor } from "./run-audit.js"; +import { MIN_TEMP_WORKTREE_REAP_AGE_MS } from "./self-healing.js"; +import { resolveAiMergeRootPath, resolveLegacyAiMergeRootPath } from "./worktree-paths.js"; + +const execFileAsync = promisify(execFile); + +async function git(args: string[], cwd: string, opts: { timeout?: number } = {}): Promise { + const { stdout } = await execFileAsync("git", args, { + cwd, + encoding: "utf-8", + timeout: opts.timeout ?? 120_000, + maxBuffer: 16 * 1024 * 1024, + }); + return stdout.trim(); +} + +function getErrorMessage(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} + +function getErrorStringProperty(err: unknown, key: "stderr" | "code"): string | undefined { + if (!err || typeof err !== "object" || !(key in err)) return undefined; + const value = (err as Record)[key]; + return typeof value === "string" && value.trim() ? value : undefined; +} + +function describeCleanupError(err: unknown): string { + const stderr = getErrorStringProperty(err, "stderr"); + const message = getErrorMessage(err); + return stderr ? `${message}: ${stderr.trim()}` : message; +} + +export function isBenignAbsentWorktreeError(err: unknown): boolean { + const code = getErrorStringProperty(err, "code"); + if (code === "ENOENT") return true; + const description = describeCleanupError(err); + return /is not a working tree|No such file or directory|spawn\s+.*\bENOENT\b/i.test(description); +} + +function ensureAiMergeRootIgnored(projectRootDir: string, settings?: Settings): void { + const excludePath = join(projectRootDir, ".git", "info", "exclude"); + if (!existsSync(excludePath)) return; + try { + const current = readFileSync(excludePath, "utf-8"); + const legacyAiMergeRoot = resolveLegacyAiMergeRootPath(projectRootDir); + const legacyRelativeAiMergeRoot = relative(projectRootDir, legacyAiMergeRoot); + const entries = [`${legacyRelativeAiMergeRoot.replaceAll("\\", "/")}/`]; + const aiMergeRoot = resolveAiMergeRootPath(projectRootDir, settings); + const relativeAiMergeRoot = relative(projectRootDir, aiMergeRoot); + if (relativeAiMergeRoot && !relativeAiMergeRoot.startsWith("..") && !isAbsolute(relativeAiMergeRoot)) { + entries.push(`${relativeAiMergeRoot.replaceAll("\\", "/")}/`); + } + + const missing = entries.filter((entry) => !current.split(/\r?\n/).includes(entry)); + if (missing.length > 0) { + appendFileSync(excludePath, `${current.endsWith("\n") ? "" : "\n"}${missing.join("\n")}\n`); + } + } catch { + // Best effort only: cleanup still removes the root contents, and existing + // projects generally ignore .fusion already. + } +} + +export function resolveAiMergeRoot(projectRootDir: string, settings?: Settings): string { + const root = resolveAiMergeRootPath(projectRootDir, settings); + mkdirSync(root, { recursive: true }); + ensureAiMergeRootIgnored(projectRootDir, settings); + return root; +} + +function getAiMergeTempSearchRoots(projectRootDir: string, settings?: Settings): string[] { + const roots = [resolveAiMergeRoot(projectRootDir, settings), resolveLegacyAiMergeRootPath(projectRootDir), tmpdir()]; + const testWorkerRoot = process.env.FUSION_TEST_WORKER_ROOT; + if (testWorkerRoot) { + try { + for (const entry of readdirSync(testWorkerRoot)) { + if (entry.startsWith("redir-")) roots.push(join(testWorkerRoot, entry)); + } + } catch { + // Best effort for the test harness' bounded temp-dir redirection root. + } + } + return Array.from(new Set(roots)); +} + +export async function pruneExistingAiMergeWorktrees( + taskId: string, + projectRootDir: string, + audit: RunAuditor, + log: (message: string) => Promise, + settings?: Settings, +): Promise { + const prefix = `fusion-ai-merge-${taskId.toLowerCase()}-`; + const tempRoots = getAiMergeTempSearchRoots(projectRootDir, settings); + + let pruned = 0; + let cleanupAttempted = false; + for (const tempRoot of tempRoots) { + let entries: string[]; + try { + entries = readdirSync(tempRoot).filter((entry) => entry.startsWith(prefix)); + } catch (err: unknown) { + /* + FNXC:AiMerge 2026-06-24-23:10: + An absent ai-merge search root is the NORMAL case, not an error: the clean-room directory + (e.g. `/.fusion/ai-merge`) is created lazily only when an AI-merge worktree is made, so a + workspace sub-repo that has never been AI-merged has no such dir. ENOENT therefore means + "nothing to prune" — skip it silently rather than emitting an alarming warning on every merge. + Only non-ENOENT failures are surfaced, and only a non-ENOENT failure on the system tmpdir + (which always exists) remains fatal. + */ + if ((err as NodeJS.ErrnoException)?.code === "ENOENT") continue; + await log(`AI merge pre-merge prune: failed to read ${tempRoot}: ${getErrorMessage(err)}`); + if (tempRoot === tmpdir()) throw err; + continue; + } + + for (const entry of entries) { + const candidatePath = join(tempRoot, entry); + let canonicalPath = candidatePath; + try { + canonicalPath = realpathSync(candidatePath); + } catch { + canonicalPath = candidatePath; + } + + if (activeSessionRegistry.isPathActive(canonicalPath) || activeSessionRegistry.isPathActive(candidatePath)) { + await log(`AI merge pre-merge prune: skipping active worktree ${canonicalPath}`); + continue; + } + + try { + const stat = statSync(canonicalPath); + const ageMs = Date.now() - stat.mtimeMs; + if (ageMs < MIN_TEMP_WORKTREE_REAP_AGE_MS) { + await log(`AI merge pre-merge prune: skipping too-new worktree ${canonicalPath} (age ${Math.max(0, Math.round(ageMs))}ms)`); + continue; + } + } catch (err: unknown) { + await log(`AI merge pre-merge prune: failed to stat ${canonicalPath}: ${getErrorMessage(err)} — skipping candidate`); + continue; + } + + let alreadyAbsent = false; + try { + cleanupAttempted = true; + await execFileAsync("git", ["worktree", "remove", "--force", canonicalPath], { + cwd: projectRootDir, + timeout: 30_000, + }); + } catch (err: unknown) { + if (isBenignAbsentWorktreeError(err)) { + alreadyAbsent = true; + await log(`AI merge pre-merge prune: worktree ${canonicalPath} was already absent/de-registered; treating cleanup as idempotent`); + } else { + await log(`AI merge pre-merge prune: git worktree remove failed for ${canonicalPath}: ${describeCleanupError(err)} — falling back to filesystem removal`); + } + } + + try { + cleanupAttempted = true; + rmSync(canonicalPath, { recursive: true, force: true }); + await audit.git({ type: "merge:ai-worktree-cleanup", target: canonicalPath, metadata: { taskId, mergeRoot: canonicalPath, phase: "pre-merge-prune", success: true, ...(alreadyAbsent ? { alreadyAbsent: true, idempotent: true } : {}) } }); + pruned++; + } catch (err: unknown) { + if (isBenignAbsentWorktreeError(err)) { + await log(`AI merge pre-merge prune: worktree ${canonicalPath} was already absent during filesystem cleanup; treating cleanup as idempotent`); + await audit.git({ type: "merge:ai-worktree-cleanup", target: canonicalPath, metadata: { taskId, mergeRoot: canonicalPath, phase: "pre-merge-prune", success: true, alreadyAbsent: true, idempotent: true } }); + pruned++; + continue; + } + const error = getErrorMessage(err); + const code = getErrorStringProperty(err, "code"); + await log(`AI merge pre-merge prune: filesystem rm failed for ${canonicalPath}${code ? ` (${code})` : ""}: ${error}`); + await audit.git({ type: "merge:ai-worktree-cleanup", target: canonicalPath, metadata: { taskId, mergeRoot: canonicalPath, phase: "pre-merge-prune", success: false, error, ...(code ? { code } : {}) } }); + } + } + } + + if (cleanupAttempted) { + try { + await execFileAsync("git", ["worktree", "prune"], { cwd: projectRootDir, timeout: 30_000 }); + } catch (err: unknown) { + await log(`AI merge pre-merge prune: git worktree prune failed: ${describeCleanupError(err)}`); + } + } + + return pruned; +} + +export async function cleanupAiMergeWorktree(input: { + taskId: string; + mergeRoot: string; + projectRootDir: string; + worktreeAdded: boolean; + audit: RunAuditor; + log: (message: string) => Promise; + gitRunner?: typeof git; + rmRunner?: typeof rm; +}): Promise { + const { taskId, mergeRoot, projectRootDir, worktreeAdded, audit, log, gitRunner = git, rmRunner = rm } = input; + let canonicalRoot = mergeRoot; + try { + canonicalRoot = realpathSync(mergeRoot); + } catch { + canonicalRoot = mergeRoot; + } + const removalTargets = canonicalRoot === mergeRoot ? [mergeRoot] : [canonicalRoot, mergeRoot]; + const cleanupMetadata = { taskId, mergeRoot: canonicalRoot, requestedMergeRoot: mergeRoot }; + let alreadyAbsent = false; + + if (worktreeAdded) { + if (!existsSync(canonicalRoot) && !existsSync(mergeRoot)) { + alreadyAbsent = true; + await log(`AI merge cleanup: worktree ${canonicalRoot} was already absent before git removal; treating cleanup as idempotent`); + await audit.git({ type: "merge:ai-worktree-cleanup", target: canonicalRoot, metadata: { ...cleanupMetadata, phase: "git-remove", success: true, alreadyAbsent: true, idempotent: true, code: "ENOENT" } }); + } else { + try { + await gitRunner(["worktree", "remove", "--force", canonicalRoot], projectRootDir); + await audit.git({ type: "merge:ai-worktree-cleanup", target: canonicalRoot, metadata: { ...cleanupMetadata, phase: "git-remove", success: true } }); + } catch (err: unknown) { + const error = describeCleanupError(err); + const code = getErrorStringProperty(err, "code"); + if (isBenignAbsentWorktreeError(err)) { + alreadyAbsent = true; + await log(`AI merge cleanup: worktree ${canonicalRoot} was already absent/de-registered during git removal; treating cleanup as idempotent`); + await audit.git({ type: "merge:ai-worktree-cleanup", target: canonicalRoot, metadata: { ...cleanupMetadata, phase: "git-remove", success: true, alreadyAbsent: true, idempotent: true, error, ...(code ? { code } : {}) } }); + } else { + await log(`AI merge cleanup: git worktree remove failed for ${canonicalRoot}${code ? ` (${code})` : ""}: ${error}`); + await audit.git({ type: "merge:ai-worktree-cleanup", target: canonicalRoot, metadata: { ...cleanupMetadata, phase: "git-remove", success: false, error, ...(code ? { code } : {}) } }); + } + } + } + } + + let removedFromFilesystem = false; + for (const target of removalTargets) { + try { + await rmRunner(target, { recursive: true, force: true }); + await audit.git({ type: "merge:ai-worktree-cleanup", target, metadata: { ...cleanupMetadata, phase: "fs-rm", path: target, success: true, ...(alreadyAbsent ? { alreadyAbsent: true, idempotent: true } : {}) } }); + removedFromFilesystem = true; + break; + } catch (err: unknown) { + const error = getErrorMessage(err); + const code = getErrorStringProperty(err, "code"); + if (isBenignAbsentWorktreeError(err)) { + await log(`AI merge cleanup: worktree ${target} was already absent during filesystem cleanup; treating cleanup as idempotent`); + await audit.git({ type: "merge:ai-worktree-cleanup", target, metadata: { ...cleanupMetadata, phase: "fs-rm", path: target, success: true, alreadyAbsent: true, idempotent: true, error, ...(code ? { code } : {}) } }); + removedFromFilesystem = true; + break; + } + await log(`AI merge cleanup: filesystem rm failed for ${target}${code ? ` (${code})` : ""}: ${error}`); + await audit.git({ type: "merge:ai-worktree-cleanup", target, metadata: { ...cleanupMetadata, phase: "fs-rm", path: target, success: false, error, ...(code ? { code } : {}) } }); + } + } + + if (!removedFromFilesystem) { + await log(`AI merge cleanup: filesystem cleanup did not remove ${canonicalRoot}; continuing to prune worktree metadata`); + } + + try { + await gitRunner(["worktree", "prune"], projectRootDir, { timeout: 30_000 }); + await audit.git({ type: "merge:ai-worktree-cleanup", target: canonicalRoot, metadata: { ...cleanupMetadata, phase: "git-prune", success: true } }); + } catch (err: unknown) { + const error = describeCleanupError(err); + const code = getErrorStringProperty(err, "code"); + await log(`AI merge cleanup: git worktree prune failed after removing ${canonicalRoot}${code ? ` (${code})` : ""}: ${error}`); + await audit.git({ type: "merge:ai-worktree-cleanup", target: canonicalRoot, metadata: { ...cleanupMetadata, phase: "git-prune", success: false, error, ...(code ? { code } : {}) } }); + } + +} diff --git a/packages/engine/src/merger-ai.ts b/packages/engine/src/merger-ai.ts index 2c9bd2ade1..7f73ed1497 100644 --- a/packages/engine/src/merger-ai.ts +++ b/packages/engine/src/merger-ai.ts @@ -37,29 +37,25 @@ */ import { execFile } from "node:child_process"; import { promisify } from "node:util"; -import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, rmSync, statSync } from "node:fs"; -import { mkdtemp, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { isAbsolute, join, relative } from "node:path"; +import { realpathSync } from "node:fs"; +import { mkdtemp } from "node:fs/promises"; +import { join } from "node:path"; import { assertNotWorkspaceTaskMerge, buildTaskLineageTrailer, evaluateNoCommitsNoOpFinalize, getPrimaryPrInfo, getTaskMergeBlocker, - resolveAgentPrompt, resolvePersistAgentThinkingLog, resolveTaskMergeTarget, resolveValidatorSettingsModel, - type AgentPromptsConfig, type MergeDetails, type MergeResult, type Settings, type Task, - type TaskComment, type TaskStore, } from "@fusion/core"; -import { buildUserCommentsPromptSection, selectUserCommentsForAgentContext } from "./agent-user-comments.js"; +import { selectUserCommentsForAgentContext } from "./agent-user-comments.js"; import { resolveTaskWorkingBranch } from "./worktree-names.js"; import { resolveIntegrationBranch } from "./integration-branch.js"; import { advanceIntegrationBranchRef } from "./merger-ref-update-advance.js"; @@ -74,16 +70,28 @@ import { createLogger } from "./logger.js"; import { captureSingleCommitLandedMetadata, type MergerOptions } from "./merger.js"; import { installWorktreeDependencies } from "./merge-dependency-sync.js"; import { activeSessionRegistry } from "./active-session-registry.js"; -import { MIN_TEMP_WORKTREE_REAP_AGE_MS } from "./self-healing.js"; -import { resolveAiMergeRootPath, resolveLegacyAiMergeRootPath } from "./worktree-paths.js"; /* FNXC:Workspace 2026-06-22-14:10 (Phase D review G — cycle dissolved): `isRepoLanded` + `FUSION_TASK_ID_TRAILER_KEY` moved to the dependency-free `workspace-land-predicate` module so self-healing can import the predicate without re-entering the self-healing ↔ merger-ai -import cycle (merger-ai already imports `MIN_TEMP_WORKTREE_REAP_AGE_MS` from self-healing). +import cycle (merger-ai-worktree imports `MIN_TEMP_WORKTREE_REAP_AGE_MS` from self-healing). */ import { isRepoLanded, FUSION_TASK_ID_TRAILER_KEY } from "./workspace-land-predicate.js"; import { finalizeProvenAutoMergeTask } from "./auto-merge-finalization.js"; +import { + cleanupAiMergeWorktree, + pruneExistingAiMergeWorktrees, + resolveAiMergeRoot, +} from "./merger-ai-worktree.js"; +import { + buildMergePrompt, + buildMergeSystemPrompt, + buildReviewPrompt, + buildReviewSystemPrompt, + buildStashResolvePrompt, + buildStashResolveSystemPrompt, + parseReviewVerdict, +} from "./merger-ai-prompts.js"; const execFileAsync = promisify(execFile); const aiMergeLog = createLogger("merger-ai"); @@ -113,257 +121,16 @@ function getErrorMessage(err: unknown): string { return err instanceof Error ? err.message : String(err); } -function getErrorStringProperty(err: unknown, key: "stderr" | "code"): string | undefined { - if (!err || typeof err !== "object" || !(key in err)) return undefined; - const value = (err as Record)[key]; - return typeof value === "string" && value.trim() ? value : undefined; +function short(sha: string): string { + return /^[0-9a-f]{7,40}$/i.test(sha) ? sha.slice(0, 8) : sha; } -function describeCleanupError(err: unknown): string { - const stderr = getErrorStringProperty(err, "stderr"); - const message = getErrorMessage(err); - return stderr ? `${message}: ${stderr.trim()}` : message; -} - -export function isBenignAbsentWorktreeError(err: unknown): boolean { - const code = getErrorStringProperty(err, "code"); - if (code === "ENOENT") return true; - const description = describeCleanupError(err); - return /is not a working tree|No such file or directory|spawn\s+.*\bENOENT\b/i.test(description); -} - -function ensureAiMergeRootIgnored(projectRootDir: string, settings?: Settings): void { - const excludePath = join(projectRootDir, ".git", "info", "exclude"); - if (!existsSync(excludePath)) return; - try { - const current = readFileSync(excludePath, "utf-8"); - const legacyAiMergeRoot = resolveLegacyAiMergeRootPath(projectRootDir); - const legacyRelativeAiMergeRoot = relative(projectRootDir, legacyAiMergeRoot); - const entries = [`${legacyRelativeAiMergeRoot.replaceAll("\\", "/")}/`]; - const aiMergeRoot = resolveAiMergeRootPath(projectRootDir, settings); - const relativeAiMergeRoot = relative(projectRootDir, aiMergeRoot); - if (relativeAiMergeRoot && !relativeAiMergeRoot.startsWith("..") && !isAbsolute(relativeAiMergeRoot)) { - entries.push(`${relativeAiMergeRoot.replaceAll("\\", "/")}/`); - } - - const missing = entries.filter((entry) => !current.split(/\r?\n/).includes(entry)); - if (missing.length > 0) { - appendFileSync(excludePath, `${current.endsWith("\n") ? "" : "\n"}${missing.join("\n")}\n`); - } - } catch { - // Best effort only: cleanup still removes the root contents, and existing - // projects generally ignore .fusion already. - } -} - -export function resolveAiMergeRoot(projectRootDir: string, settings?: Settings): string { - const root = resolveAiMergeRootPath(projectRootDir, settings); - mkdirSync(root, { recursive: true }); - ensureAiMergeRootIgnored(projectRootDir, settings); - return root; -} - -function getAiMergeTempSearchRoots(projectRootDir: string, settings?: Settings): string[] { - const roots = [resolveAiMergeRoot(projectRootDir, settings), resolveLegacyAiMergeRootPath(projectRootDir), tmpdir()]; - const testWorkerRoot = process.env.FUSION_TEST_WORKER_ROOT; - if (testWorkerRoot) { - try { - for (const entry of readdirSync(testWorkerRoot)) { - if (entry.startsWith("redir-")) roots.push(join(testWorkerRoot, entry)); - } - } catch { - // Best effort for the test harness' bounded temp-dir redirection root. - } - } - return Array.from(new Set(roots)); -} - -export async function pruneExistingAiMergeWorktrees( - taskId: string, - projectRootDir: string, - audit: RunAuditor, - log: (message: string) => Promise, - settings?: Settings, -): Promise { - const prefix = `fusion-ai-merge-${taskId.toLowerCase()}-`; - const tempRoots = getAiMergeTempSearchRoots(projectRootDir, settings); - - let pruned = 0; - let cleanupAttempted = false; - for (const tempRoot of tempRoots) { - let entries: string[]; - try { - entries = readdirSync(tempRoot).filter((entry) => entry.startsWith(prefix)); - } catch (err: unknown) { - /* - FNXC:AiMerge 2026-06-24-23:10: - An absent ai-merge search root is the NORMAL case, not an error: the clean-room directory - (e.g. `/.fusion/ai-merge`) is created lazily only when an AI-merge worktree is made, so a - workspace sub-repo that has never been AI-merged has no such dir. ENOENT therefore means - "nothing to prune" — skip it silently rather than emitting an alarming warning on every merge. - Only non-ENOENT failures are surfaced, and only a non-ENOENT failure on the system tmpdir - (which always exists) remains fatal. - */ - if ((err as NodeJS.ErrnoException)?.code === "ENOENT") continue; - await log(`AI merge pre-merge prune: failed to read ${tempRoot}: ${getErrorMessage(err)}`); - if (tempRoot === tmpdir()) throw err; - continue; - } - - for (const entry of entries) { - const candidatePath = join(tempRoot, entry); - let canonicalPath = candidatePath; - try { - canonicalPath = realpathSync(candidatePath); - } catch { - canonicalPath = candidatePath; - } - - if (activeSessionRegistry.isPathActive(canonicalPath) || activeSessionRegistry.isPathActive(candidatePath)) { - await log(`AI merge pre-merge prune: skipping active worktree ${canonicalPath}`); - continue; - } - - try { - const stat = statSync(canonicalPath); - const ageMs = Date.now() - stat.mtimeMs; - if (ageMs < MIN_TEMP_WORKTREE_REAP_AGE_MS) { - await log(`AI merge pre-merge prune: skipping too-new worktree ${canonicalPath} (age ${Math.max(0, Math.round(ageMs))}ms)`); - continue; - } - } catch (err: unknown) { - await log(`AI merge pre-merge prune: failed to stat ${canonicalPath}: ${getErrorMessage(err)} — skipping candidate`); - continue; - } - - let alreadyAbsent = false; - try { - cleanupAttempted = true; - await execFileAsync("git", ["worktree", "remove", "--force", canonicalPath], { - cwd: projectRootDir, - timeout: 30_000, - }); - } catch (err: unknown) { - if (isBenignAbsentWorktreeError(err)) { - alreadyAbsent = true; - await log(`AI merge pre-merge prune: worktree ${canonicalPath} was already absent/de-registered; treating cleanup as idempotent`); - } else { - await log(`AI merge pre-merge prune: git worktree remove failed for ${canonicalPath}: ${describeCleanupError(err)} — falling back to filesystem removal`); - } - } - - try { - cleanupAttempted = true; - rmSync(canonicalPath, { recursive: true, force: true }); - await audit.git({ type: "merge:ai-worktree-cleanup", target: canonicalPath, metadata: { taskId, mergeRoot: canonicalPath, phase: "pre-merge-prune", success: true, ...(alreadyAbsent ? { alreadyAbsent: true, idempotent: true } : {}) } }); - pruned++; - } catch (err: unknown) { - if (isBenignAbsentWorktreeError(err)) { - await log(`AI merge pre-merge prune: worktree ${canonicalPath} was already absent during filesystem cleanup; treating cleanup as idempotent`); - await audit.git({ type: "merge:ai-worktree-cleanup", target: canonicalPath, metadata: { taskId, mergeRoot: canonicalPath, phase: "pre-merge-prune", success: true, alreadyAbsent: true, idempotent: true } }); - pruned++; - continue; - } - const error = getErrorMessage(err); - const code = getErrorStringProperty(err, "code"); - await log(`AI merge pre-merge prune: filesystem rm failed for ${canonicalPath}${code ? ` (${code})` : ""}: ${error}`); - await audit.git({ type: "merge:ai-worktree-cleanup", target: canonicalPath, metadata: { taskId, mergeRoot: canonicalPath, phase: "pre-merge-prune", success: false, error, ...(code ? { code } : {}) } }); - } - } - } - - if (cleanupAttempted) { - try { - await execFileAsync("git", ["worktree", "prune"], { cwd: projectRootDir, timeout: 30_000 }); - } catch (err: unknown) { - await log(`AI merge pre-merge prune: git worktree prune failed: ${describeCleanupError(err)}`); - } - } - - return pruned; -} - -export async function cleanupAiMergeWorktree(input: { - taskId: string; - mergeRoot: string; - projectRootDir: string; - worktreeAdded: boolean; - audit: RunAuditor; - log: (message: string) => Promise; - gitRunner?: typeof git; - rmRunner?: typeof rm; -}): Promise { - const { taskId, mergeRoot, projectRootDir, worktreeAdded, audit, log, gitRunner = git, rmRunner = rm } = input; - let canonicalRoot = mergeRoot; - try { - canonicalRoot = realpathSync(mergeRoot); - } catch { - canonicalRoot = mergeRoot; - } - const removalTargets = canonicalRoot === mergeRoot ? [mergeRoot] : [canonicalRoot, mergeRoot]; - const cleanupMetadata = { taskId, mergeRoot: canonicalRoot, requestedMergeRoot: mergeRoot }; - let alreadyAbsent = false; - - if (worktreeAdded) { - if (!existsSync(canonicalRoot) && !existsSync(mergeRoot)) { - alreadyAbsent = true; - await log(`AI merge cleanup: worktree ${canonicalRoot} was already absent before git removal; treating cleanup as idempotent`); - await audit.git({ type: "merge:ai-worktree-cleanup", target: canonicalRoot, metadata: { ...cleanupMetadata, phase: "git-remove", success: true, alreadyAbsent: true, idempotent: true, code: "ENOENT" } }); - } else { - try { - await gitRunner(["worktree", "remove", "--force", canonicalRoot], projectRootDir); - await audit.git({ type: "merge:ai-worktree-cleanup", target: canonicalRoot, metadata: { ...cleanupMetadata, phase: "git-remove", success: true } }); - } catch (err: unknown) { - const error = describeCleanupError(err); - const code = getErrorStringProperty(err, "code"); - if (isBenignAbsentWorktreeError(err)) { - alreadyAbsent = true; - await log(`AI merge cleanup: worktree ${canonicalRoot} was already absent/de-registered during git removal; treating cleanup as idempotent`); - await audit.git({ type: "merge:ai-worktree-cleanup", target: canonicalRoot, metadata: { ...cleanupMetadata, phase: "git-remove", success: true, alreadyAbsent: true, idempotent: true, error, ...(code ? { code } : {}) } }); - } else { - await log(`AI merge cleanup: git worktree remove failed for ${canonicalRoot}${code ? ` (${code})` : ""}: ${error}`); - await audit.git({ type: "merge:ai-worktree-cleanup", target: canonicalRoot, metadata: { ...cleanupMetadata, phase: "git-remove", success: false, error, ...(code ? { code } : {}) } }); - } - } - } - } - - let removedFromFilesystem = false; - for (const target of removalTargets) { - try { - await rmRunner(target, { recursive: true, force: true }); - await audit.git({ type: "merge:ai-worktree-cleanup", target, metadata: { ...cleanupMetadata, phase: "fs-rm", path: target, success: true, ...(alreadyAbsent ? { alreadyAbsent: true, idempotent: true } : {}) } }); - removedFromFilesystem = true; - break; - } catch (err: unknown) { - const error = getErrorMessage(err); - const code = getErrorStringProperty(err, "code"); - if (isBenignAbsentWorktreeError(err)) { - await log(`AI merge cleanup: worktree ${target} was already absent during filesystem cleanup; treating cleanup as idempotent`); - await audit.git({ type: "merge:ai-worktree-cleanup", target, metadata: { ...cleanupMetadata, phase: "fs-rm", path: target, success: true, alreadyAbsent: true, idempotent: true, error, ...(code ? { code } : {}) } }); - removedFromFilesystem = true; - break; - } - await log(`AI merge cleanup: filesystem rm failed for ${target}${code ? ` (${code})` : ""}: ${error}`); - await audit.git({ type: "merge:ai-worktree-cleanup", target, metadata: { ...cleanupMetadata, phase: "fs-rm", path: target, success: false, error, ...(code ? { code } : {}) } }); - } - } - - if (!removedFromFilesystem) { - await log(`AI merge cleanup: filesystem cleanup did not remove ${canonicalRoot}; continuing to prune worktree metadata`); - } - - try { - await gitRunner(["worktree", "prune"], projectRootDir, { timeout: 30_000 }); - await audit.git({ type: "merge:ai-worktree-cleanup", target: canonicalRoot, metadata: { ...cleanupMetadata, phase: "git-prune", success: true } }); - } catch (err: unknown) { - const error = describeCleanupError(err); - const code = getErrorStringProperty(err, "code"); - await log(`AI merge cleanup: git worktree prune failed after removing ${canonicalRoot}${code ? ` (${code})` : ""}: ${error}`); - await audit.git({ type: "merge:ai-worktree-cleanup", target: canonicalRoot, metadata: { ...cleanupMetadata, phase: "git-prune", success: false, error, ...(code ? { code } : {}) } }); - } - -} +export { + cleanupAiMergeWorktree, + isBenignAbsentWorktreeError, + pruneExistingAiMergeWorktrees, + resolveAiMergeRoot, +} from "./merger-ai-worktree.js"; /** Trailers that associate the squash commit with its board task: the * `Fusion-Task-Id` trailer plus the canonical lineage trailer when available. @@ -412,263 +179,17 @@ async function ensureCommitTaskMetadata( // Pure helpers (unit-tested) // --------------------------------------------------------------------------- -export type AiMergeReviewSeverity = "blocking" | "advisory"; - -export interface AiMergeReviewVerdict { - verdict: "approve" | "reject"; - reasons: string[]; - severity?: AiMergeReviewSeverity; -} - -export const REVIEW_VERDICT_MARKER = "REVIEW_VERDICT:"; -const VERDICT_LINE_RE = /REVIEW_VERDICT:\s*(approve|reject)\b/i; -const SEVERITY_LINE_RE = /SEVERITY:\s*(blocking|advisory)\b/i; - -/** - * Parse the reviewer's free-form output. Fail-safe: no/garbled output, or a - * rejection with no explicit severity, is treated as a BLOCKING reject — an - * ambiguous reviewer can never wave wrong code through, nor silently downgrade - * to advisory. - */ -export function parseReviewVerdict(agentText: string | null | undefined): AiMergeReviewVerdict { - const text = (agentText ?? "").trim(); - if (!text) return { verdict: "reject", reasons: ["reviewer produced no output"], severity: "blocking" }; - - const lines = text.split(/\r?\n/); - let verdictLineIndex = -1; - let decision: "approve" | "reject" | null = null; - for (let i = lines.length - 1; i >= 0; i--) { - const m = lines[i].match(VERDICT_LINE_RE); - if (m) { - decision = m[1].toLowerCase() as "approve" | "reject"; - verdictLineIndex = i; - break; - } - } - if (!decision) { - return { - verdict: "reject", - reasons: [`reviewer did not emit a "${REVIEW_VERDICT_MARKER} approve|reject" line`], - severity: "blocking", - }; - } - if (decision === "approve") return { verdict: "approve", reasons: [] }; - - const severity: AiMergeReviewSeverity = SEVERITY_LINE_RE.test(text) - ? (text.match(SEVERITY_LINE_RE)![1].toLowerCase() as AiMergeReviewSeverity) - : "blocking"; - return { verdict: "reject", reasons: extractRejectReasons(lines, verdictLineIndex), severity }; -} - -function extractRejectReasons(lines: string[], verdictLineIndex: number): string[] { - const reasons: string[] = []; - const inline = lines[verdictLineIndex].replace(VERDICT_LINE_RE, "").replace(/^[\s:–—-]+/, "").trim(); - if (inline) reasons.push(inline); - for (let i = verdictLineIndex + 1; i < lines.length; i++) { - if (SEVERITY_LINE_RE.test(lines[i])) continue; - const cleaned = lines[i].replace(/^\s*(?:[-*•]|\d+[.)])\s+/, "").trim(); - if (cleaned) reasons.push(cleaned); - } - if (reasons.length === 0) reasons.push("reviewer rejected the merge without a stated reason"); - return reasons; -} - -export function buildMergeSystemPrompt(agentPrompts?: AgentPromptsConfig): string { - // Base persona is the editable "merger" agent prompt (Settings → Prompts); - // the non-negotiable clean-room / verification / commit-trailer rules below - // are always appended so a custom prompt can't drop them. - const base = resolveAgentPrompt("merger", agentPrompts).trim(); - return [ - base, - base ? "" : undefined, - "## AI merge — clean room", - "You are on a CLEAN, detached checkout at the integration branch's current", - "tip. Land the task branch's work as a single commit.", - "", - "Constraints:", - " - Resolve every conflict in favor of the task branch's intent; never drop", - " the task's changes to make a conflict go away.", - " - Do not make edits unrelated to reconciling the two branches.", - " - Do NOT push, force-push, or run `git update-ref` / `git reset --hard`", - " on any other branch. Only commit on this detached HEAD.", - " - Finish with exactly ONE new commit on HEAD containing the task's work.", - "", - "Verify before committing:", - " - After resolving the merge, run the project's checks — tests, type-check,", - " and lint (discover them from the project config / package.json scripts,", - " e.g. test / typecheck / lint / build).", - " - FIX any NEW failure the merge or conflict resolution introduced (a check", - " that passed on the task branch or the integration tip but fails on the", - " merged tree). You do not need to fix failures that were already broken on", - " the integration branch beforehand, but never commit a merge that adds new", - " test, type-check, or lint failures.", - "", - "Commit message:", - " - The subject line must CONCISELY SUMMARIZE the squashed changes in", - " imperative mood (e.g. \"add X\", \"fix Y\") based on the actual diff — do", - " not just restate the task title.", - " - The commit BODY must include:", - " 1) one short narrative summary line,", - " 2) a bullet list of key changes, and", - " 3) a `Files changed:` section populated from `git diff --stat`.", - " - Include the task-id prefix and the trailer lines EXACTLY as given in the", - " task instructions (they associate the commit with the board task).", - ].filter((l) => l !== undefined).join("\n"); -} - -export function buildMergePrompt(input: { - taskId: string; - branch: string; - integrationBranch: string; - tipSha: string; - /** Task title — a HINT for the summary, not the literal subject. */ - taskTitle?: string; - /** Whether to prefix the subject with the task id. */ - includeTaskId: boolean; - /** Required trailers to append (board association). */ - trailers: string[]; - correctiveReasons?: string[]; - userComments?: TaskComment[]; -}): string { - const subjectShape = input.includeTaskId - ? `"${input.taskId}: "` - : `""`; - const trailerArgs = input.trailers.map((t) => ` -m ${JSON.stringify(t)}`).join(""); - const lines = [ - `Merge branch "${input.branch}" into "${input.integrationBranch}" (HEAD is detached at ${short(input.tipSha)}).`, - "", - "Steps:", - ` 1. Run: git merge --squash ${input.branch}`, - " 2. If there are conflicts, resolve them (favor the task's intent), then `git add` the resolved files.", - " 3. Build a merge body from the staged squash diff:", - " - one short narrative summary line", - " - bullet list of key changes", - " - `Files changed:` + the output of `git diff --stat`", - " 4. Commit the staged result as a SINGLE commit whose subject summarizes the", - ` actual changes${input.taskTitle ? ` (task title hint: ${JSON.stringify(input.taskTitle)})` : ""}, including the body above and required trailers:`, - ` git commit -m ${subjectShape} -m ""${trailerArgs}`, - " Keep the trailer line(s) verbatim — they link the commit to the board task.", - " 5. Verify `git log --oneline ${tip}..HEAD` shows exactly one new commit and `git status` is clean.".replace("${tip}", short(input.tipSha)), - "", - "If `git merge --squash` reports the branch is already up to date (nothing to", - "merge), do nothing and leave HEAD unchanged.", - ]; - const userCommentsSection = buildUserCommentsPromptSection(input.userComments ?? []); - if (userCommentsSection) { - lines.push("", userCommentsSection); - } - if (input.correctiveReasons && input.correctiveReasons.length > 0) { - lines.push( - "", - "A prior attempt was REJECTED by review. Redo the merge from the clean tip", - "and address each of these problems:", - ...input.correctiveReasons.map((r) => ` - ${r}`), - ); - } - return lines.join("\n"); -} - -export function buildReviewSystemPrompt(): string { - return [ - "You are an adversarial, read-only merge reviewer. Do NOT edit, stage, commit,", - "or run any mutating git command. Audit the squash commit that is about to be", - "merged into the integration branch and decide whether it is safe to land.", - "", - "Investigate with read-only commands (git show, git diff, git log, cat, grep).", - "Judge on four axes:", - " 1. Completeness — does the squash contain ALL of the task branch's intended", - " changes? Flag any hunk silently dropped during conflict resolution.", - " 2. No collateral — does it touch only files within the task's footprint?", - " 3. Conflict soundness — were conflicts resolved coherently (both sides'", - " intent preserved), not by blindly discarding one side?", - " 4. Commit message — read `git show`'s message: the subject must concisely", - " and ACCURATELY summarize the actual changes (not vague, not a mere", - " restatement of the task title, not misleading). A poor/inaccurate", - " message is an ADVISORY concern (it should be rewritten on retry, but", - " must not block the merge).", - "", - "Bias toward rejection when uncertain.", - "", - `End with a single decision line: "${REVIEW_VERDICT_MARKER} approve" or`, - `"${REVIEW_VERDICT_MARKER} reject". When rejecting, add a "SEVERITY:" line:`, - " - SEVERITY: blocking — a correctness problem (dropped/lost task changes,", - " incomplete squash, or a conflict resolution that discards intent). The", - " merge must NOT land if this is unfixable.", - " - SEVERITY: advisory — a quality/style concern that does not risk", - " correctness; acceptable to land if unresolved.", - "Then list each concrete reason as a bullet.", - ].join("\n"); -} - -export function buildReviewPrompt(input: { - taskId: string; - branch: string; - integrationBranch: string; - tipSha: string; - squashSha: string; - diffStat: string; - priorReasons?: string[]; - userComments?: TaskComment[]; -}): string { - const lines = [ - `Review the squash merge for task ${input.taskId} (branch ${input.branch} → ${input.integrationBranch}).`, - "", - `Integration tip: ${short(input.tipSha)}`, - `Squash commit: ${short(input.squashSha)}`, - "", - "Inspect with:", - ` git show ${input.squashSha}`, - ` git diff ${input.tipSha}..${input.squashSha}`, - "", - "Files changed (git diff --stat):", - input.diffStat.trim() || "(none reported)", - ]; - const userCommentsSection = buildUserCommentsPromptSection(input.userComments ?? []); - if (userCommentsSection) { - lines.push("", userCommentsSection); - } - if (input.priorReasons && input.priorReasons.length > 0) { - lines.push( - "", - "A prior pass rejected an earlier attempt for these reasons — confirm they", - "are now resolved:", - ...input.priorReasons.map((r) => ` - ${r}`), - ); - } - return lines.join("\n"); -} - -export function buildStashResolveSystemPrompt(): string { - return [ - "You are resolving a conflict between the user's restored local working-tree", - "edits and the freshly-merged integration branch. The user's uncommitted work", - "was stashed, the checkout fast-forwarded to the new tip, and re-applying the", - "stash produced conflicts.", - "", - "Resolve every conflict marker so BOTH sides are preserved: keep the user's", - "local intent AND the upstream changes that just landed. Stage each resolved", - "file with `git add`.", - "", - "Do NOT commit, stash, reset, checkout a different branch, or run update-ref.", - "Leave the resolved changes in the working tree as the user's uncommitted edits.", - ].join("\n"); -} - -export function buildStashResolvePrompt(conflictedFiles: string[]): string { - return [ - "Re-applying your stashed local changes onto the updated branch conflicted.", - "", - "Conflicted files:", - ...conflictedFiles.map((f) => ` - ${f}`), - "", - "Resolve each file's conflict markers (preserve both the local edits and the", - "upstream changes), then `git add` it. Do not commit.", - ].join("\n"); -} - -function short(sha: string): string { - return /^[0-9a-f]{7,40}$/i.test(sha) ? sha.slice(0, 8) : sha; -} +export { + REVIEW_VERDICT_MARKER, + buildMergePrompt, + buildMergeSystemPrompt, + buildReviewPrompt, + buildReviewSystemPrompt, + buildStashResolvePrompt, + buildStashResolveSystemPrompt, + parseReviewVerdict, +} from "./merger-ai-prompts.js"; +export type { AiMergeReviewSeverity, AiMergeReviewVerdict } from "./merger-ai-prompts.js"; // --------------------------------------------------------------------------- // Errors diff --git a/scripts/line-count-baseline.json b/scripts/line-count-baseline.json index 757d155af6..07537c5e15 100644 --- a/scripts/line-count-baseline.json +++ b/scripts/line-count-baseline.json @@ -92,7 +92,6 @@ "packages/engine/src/agent-heartbeat.ts": 4660, "packages/engine/src/agent-tools.ts": 3986, "packages/engine/src/executor.ts": 16743, - "packages/engine/src/merger-ai.ts": 2050, "packages/engine/src/merger.ts": 12886, "packages/engine/src/pi.ts": 2507, "packages/engine/src/project-engine.ts": 4030, From 8297762eebb648e79ce22e1e7c7af02376a9bd06 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Thu, 25 Jun 2026 20:17:56 -0700 Subject: [PATCH 06/50] Address PR review feedback (#1780) - P2: filter directly-changed test files to paths that still exist on disk (existingChangedTestFilesInPackage) so deleted/renamed .test paths from `git diff` never reach `vitest run` positionally; all-deletions diff falls into the delegate-to-gate path. - P1: make heavy-package delegation gate-coverage-aware (GATE_COVERED_MEMORY_ENVELOPE_PACKAGES). Engine delegation keeps the accurate "curated engine-core subset ran above" note; dashboard delegation now warns that the gate runs no dashboard tests and names the CI full-suite backstop, so the coverage gap is loud instead of a silent false-green. - +4 regression tests (115/115). Co-Authored-By: Claude Opus 4.8 (1M context) --- scripts/__tests__/test-changed.test.mjs | 67 +++++++++++++++++++++ scripts/test-changed.mjs | 77 ++++++++++++++++++++++--- 2 files changed, 135 insertions(+), 9 deletions(-) diff --git a/scripts/__tests__/test-changed.test.mjs b/scripts/__tests__/test-changed.test.mjs index 3b08603696..62e85c4c0f 100644 --- a/scripts/__tests__/test-changed.test.mjs +++ b/scripts/__tests__/test-changed.test.mjs @@ -45,6 +45,8 @@ import { partitionScopedAffectedPackages, isTestFilePath, changedSourceFilesAffectingPackage, + existingChangedTestFilesInPackage, + GATE_COVERED_MEMORY_ENVELOPE_PACKAGES, } from "../test-changed.mjs"; import { deriveBudgetMs } from "../lib/run-vitest-watchdog.mjs"; @@ -1867,3 +1869,68 @@ test("changedSourceFilesAffectingPackage: out-of-graph and irrelevant paths stay [], ); }); + +// FNXC:TestInfrastructure 2026-06-26-09:15: `git diff --name-only` lists deleted / +// renamed-away `.test` paths; those must NOT reach the positional `vitest run ` +// call (they would fail the bounded lane on a missing file). Regression for the P2 +// deletion case: filter the directly-changed test files to ones still on disk, and +// an all-deletions diff must yield [] so the caller delegates to the gate. +test("existingChangedTestFilesInPackage: keeps live in-package test files, drops deleted ones", () => { + const tmp = mkdtempSync(path.join(tmpdir(), "fusion-changed-tests-")); + try { + const liveRel = "packages/engine/src/__tests__/live.test.ts"; + const deletedRel = "packages/engine/src/__tests__/deleted.test.ts"; + mkdirSync(path.join(tmp, "packages/engine/src/__tests__"), { recursive: true }); + writeFileSync(path.join(tmp, liveRel), "// live\n"); + // deletedRel intentionally NOT written to disk (simulates a removed test) + assert.deepEqual( + existingChangedTestFilesInPackage([deletedRel, liveRel], "packages/engine", { projectRoot: tmp }), + [liveRel], + ); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("existingChangedTestFilesInPackage: all-deletions diff yields empty (delegate-to-gate path)", () => { + const tmp = mkdtempSync(path.join(tmpdir(), "fusion-changed-tests-")); + try { + // No test files written: every changed test path was a deletion. + assert.deepEqual( + existingChangedTestFilesInPackage( + ["packages/engine/src/__tests__/gone-a.test.ts", "packages/engine/src/__tests__/gone-b.test.ts"], + "packages/engine", + { projectRoot: tmp }, + ), + [], + ); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("existingChangedTestFilesInPackage: excludes non-test and out-of-package paths", () => { + const tmp = mkdtempSync(path.join(tmpdir(), "fusion-changed-tests-")); + try { + const inPkgSource = "packages/engine/src/self-healing.ts"; + const otherPkgTest = "packages/dashboard/src/__tests__/x.test.ts"; + mkdirSync(path.join(tmp, "packages/engine/src"), { recursive: true }); + mkdirSync(path.join(tmp, "packages/dashboard/src/__tests__"), { recursive: true }); + writeFileSync(path.join(tmp, inPkgSource), "// src\n"); + writeFileSync(path.join(tmp, otherPkgTest), "// other\n"); + assert.deepEqual( + existingChangedTestFilesInPackage([inPkgSource, otherPkgTest], "packages/engine", { projectRoot: tmp }), + [], + ); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +// FNXC:TestInfrastructure 2026-06-26-09:15: the merge gate re-covers a delegated +// engine lane (curated engine-core subset) but runs NO dashboard tests. Lock that +// asymmetry so the delegation messaging never overclaims dashboard gate coverage. +test("GATE_COVERED_MEMORY_ENVELOPE_PACKAGES: engine covered, dashboard not", () => { + assert.equal(GATE_COVERED_MEMORY_ENVELOPE_PACKAGES.has(ENGINE_SCOPED_AFFECTED_PACKAGE), true); + assert.equal(GATE_COVERED_MEMORY_ENVELOPE_PACKAGES.has(DASHBOARD_SCOPED_AFFECTED_PACKAGE), false); +}); diff --git a/scripts/test-changed.mjs b/scripts/test-changed.mjs index 141d7007c6..027dd83ec6 100644 --- a/scripts/test-changed.mjs +++ b/scripts/test-changed.mjs @@ -1337,6 +1337,19 @@ export const SCOPED_AFFECTED_MEMORY_ENVELOPES = Object.freeze({ }), }); +/* +FNXC:TestInfrastructure 2026-06-26-09:15: +Which heavy memory-envelope packages the merge gate (`pnpm test:gate`) genuinely +re-covers when the wide-fan-out guard delegates their cross-cutting coverage. +The gate runs `@fusion/engine test:core` (a curated engine-core allow-list) plus +the CI-shape test — it runs NO `@fusion/dashboard` tests. So a delegated engine +lane still gets a real (curated subset) safety net, but a delegated dashboard +lane gets ZERO gate coverage and would be a silent false-green. Treat dashboard +delegation as a loud "not covered by the gate; CI full-suite.yml is the backstop" +warning instead of a reassuring "delegated to the gate" message. +*/ +export const GATE_COVERED_MEMORY_ENVELOPE_PACKAGES = Object.freeze(new Set([ENGINE_SCOPED_AFFECTED_PACKAGE])); + export function prependNodeOption(currentOptions, option) { return [option, currentOptions || ""].join(" ").trim(); } @@ -1399,6 +1412,35 @@ export function isTestFilePath(file) { return /\.(test|spec)\.[cm]?[jt]sx?$/.test(file); } +/* +FNXC:TestInfrastructure 2026-06-26-09:15: +`changedFiles` comes from `git diff --name-only`, which lists DELETED and +renamed-away `.test`/`.spec` paths alongside live ones. Those paths no longer +exist on disk, but the wide-fan-out guard passes its picks positionally to +`vitest run ` (via `path.relative`). A removed test path reaching Vitest +makes the bounded changed lane fail on a file that is gone instead of treating +the deletion as the no-test-left case. Filter the directly-changed test files +to paths that still EXIST on disk; if every changed test in the package was a +deletion the result is empty, and the caller must then take the same +delegate-to-the-gate path as the "no changed test files" case rather than +handing Vitest an empty/garbage positional set. +*/ +/** + * Directly-changed, still-on-disk test files inside a package directory. + * @param {string[]|null|undefined} changedFiles repo-relative diff paths + * @param {string} pkgDir repo-relative package dir (e.g. "packages/engine") + * @param {{ projectRoot?: string }} [opts] + * @returns {string[]} + */ +export function existingChangedTestFilesInPackage(changedFiles, pkgDir, { projectRoot = rootDir } = {}) { + return (changedFiles ?? []).filter( + (file) => + isTestFilePath(file) && + (file === pkgDir || file.startsWith(`${pkgDir}/`)) && + existsSync(path.join(projectRoot, file)), + ); +} + /* FNXC:TestInfrastructure 2026-06-25-14:30: Why this guard exists (root cause of "pnpm test takes >15min and gets killed"): @@ -1692,21 +1734,38 @@ export async function main(argv = process.argv.slice(2)) { }); if (wideSource.length > 0) { const pkgDir = packageDirByName.get(pkg) ?? `packages/${pkg.replace(/^@[^/]+\//, "")}`; - explicitChangedTestFiles = (changedFiles ?? []).filter( - (file) => isTestFilePath(file) && (file === pkgDir || file.startsWith(`${pkgDir}/`)), - ); + // Filter to test files that still EXIST on disk: `git diff --name-only` + // includes deleted/renamed-away `.test` paths, and a removed path passed + // positionally to `vitest run` (line ~1720) would fail the bounded lane + // on a file that no longer exists. An all-deletions diff yields an empty + // list, which falls into the same delegate-to-gate `continue` below as + // the no-changed-tests case (FNXC:TestInfrastructure 2026-06-26-09:15). + explicitChangedTestFiles = existingChangedTestFilesInPackage(changedFiles, pkgDir); notFullyTestedPackages.add(pkg); + // FNXC:TestInfrastructure 2026-06-26-09:15: the gate re-covers a delegated + // engine lane (curated engine-core subset) but runs NO dashboard tests, so + // a delegated dashboard lane is uncovered. Don't claim "delegated to the + // gate" for packages the gate doesn't run — warn loudly and name the real + // backstop (CI full-suite.yml / `pnpm test:full`) so the gap is visible. + const gateCovered = GATE_COVERED_MEMORY_ENVELOPE_PACKAGES.has(pkg); + const wideSourceDesc = `${wideSource[0]}${wideSource.length > 1 ? `, +${wideSource.length - 1} more` : ""}`; + const delegationNote = gateCovered + ? "delegating wider `vitest --changed` coverage to the merge-gate suite (curated engine-core subset ran above)." + : `the merge gate does NOT run ${pkg} tests, so this wider coverage is NOT re-run here; ` + + "CI full-suite.yml (non-blocking, on push to main) is the backstop. Run `pnpm test:full` for the full sweep."; if (explicitChangedTestFiles.length === 0) { - console.log( - `[test-changed] ${pkg}: a changed non-test source file (${wideSource[0]}${wideSource.length > 1 ? `, +${wideSource.length - 1} more` : ""}) ` + + const log = gateCovered ? console.log : console.warn; + log( + `[test-changed] ${pkg}: a changed non-test source file (${wideSourceDesc}) ` + "would fan `vitest --changed` out to ~the full suite at this heavy 1-worker lane; " + - "delegating cross-cutting coverage to the merge-gate suite (ran above). Run `pnpm test:full` for the full sweep.", + `no directly-changed ${pkg} test file to run, so ${delegationNote}`, ); continue; } - console.log( - `[test-changed] ${pkg}: changed non-test source detected; running ONLY the ${explicitChangedTestFiles.length} directly-changed test file(s) ` + - "and delegating wider `vitest --changed` coverage to the merge-gate suite (ran above).", + const log = gateCovered ? console.log : console.warn; + log( + `[test-changed] ${pkg}: changed non-test source detected; running ONLY the ${explicitChangedTestFiles.length} directly-changed test file(s); ` + + delegationNote, ); } } From 4a4e3892540f2e84a38b13e9c28265d3d23da69b Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Thu, 25 Jun 2026 20:20:33 -0700 Subject: [PATCH 07/50] FN-7032: add signed Android release artifacts Add secret-gated signed Android release packaging while preserving the unsigned fallback. - Build signed Android release APK and AAB artifacts when keystore secrets are configured. - Verify signed APKs, generate checksums for APK/AAB outputs, and include AABs in release aggregation. - Document Android signing secrets, sideload verification, fallback artifacts, and Play upload scope. - Cover the release and rehearsal workflow wiring with CLI and desktop workflow tests. Files changed: .github/workflows/release.yml | 94 ++++++++++++++++++---- .github/workflows/test-release.yml | 94 ++++++++++++++++++---- MOBILE.md | 18 ++++- RELEASING.md | 34 ++++++-- packages/cli/src/__tests__/ci-workflow.test.ts | 24 ++++++ packages/desktop/README.md | 2 +- .../desktop/src/__tests__/release-workflow.test.ts | 24 +++++- 7 files changed, 251 insertions(+), 39 deletions(-) Fusion-Task-Id: FN-7032 Fusion-Task-Lineage: 0334c026-384e-4531-a2b2-f8a0b5bb7ff0 --- .github/workflows/release.yml | 94 ++++++++++++++++--- .github/workflows/test-release.yml | 94 ++++++++++++++++--- MOBILE.md | 18 +++- RELEASING.md | 34 +++++-- .../cli/src/__tests__/ci-workflow.test.ts | 24 +++++ packages/desktop/README.md | 2 +- .../src/__tests__/release-workflow.test.ts | 24 ++++- 7 files changed, 251 insertions(+), 39 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8dd63c157a..3c1d69f153 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -359,16 +359,28 @@ jobs: packages/desktop/dist-electron/latest-linux.yml - # ── Build Android APK artifact ─────────────────────────────────────── + # ── Build Android APK/AAB artifacts ────────────────────────────────── # FNXC:Release 2026-06-25-12:00: # Android release assets used to be limited to the manual mobile workflow's # short-lived CI artifacts. Tagged binary releases now build the Capacitor # Android shell in this workflow so the public GitHub Release includes a # stable APK and checksum beside desktop and CLI binaries. + # FNXC:Release 2026-06-25-18:10: + # Android signing is optional and secret-gated on ANDROID_KEYSTORE_BASE64, + # ANDROID_KEYSTORE_PASSWORD, ANDROID_KEY_ALIAS, and ANDROID_KEY_PASSWORD. + # The Capacitor Android project is gitignored/regenerated, so CI injects + # signing with android.injected.signing.* Gradle properties instead of + # committing native build.gradle edits. When the keystore is absent, keep the + # FN-7014 unsigned debug APK fallback; Play Store upload remains out of scope + # and is tracked separately from sideload release artifacts. build-android: - name: Build Android APK + name: Build Android APK/AAB runs-on: ubuntu-latest timeout-minutes: 30 + # Job-level env mirrors the desktop signing pattern: step `if:` conditions + # can inspect env values, but cannot read secrets.* directly. + env: + ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} steps: - name: Checkout @@ -407,18 +419,67 @@ jobs: fi pnpm --filter @fusion/mobile cap sync android - - name: Build Android APK + - name: Decode Android signing keystore + if: ${{ env.ANDROID_KEYSTORE_BASE64 != '' }} + run: | + printf '%s' "$ANDROID_KEYSTORE_BASE64" | base64 --decode > "$RUNNER_TEMP/fusion-release.keystore" + + - name: Build signed Android release APK and AAB + if: ${{ env.ANDROID_KEYSTORE_BASE64 != '' }} + env: + ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} + ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} + ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} + run: | + cd packages/mobile/android + chmod +x gradlew + ./gradlew assembleRelease bundleRelease \ + -Pandroid.injected.signing.store.file="$RUNNER_TEMP/fusion-release.keystore" \ + -Pandroid.injected.signing.store.password="$ANDROID_KEYSTORE_PASSWORD" \ + -Pandroid.injected.signing.key.alias="$ANDROID_KEY_ALIAS" \ + -Pandroid.injected.signing.key.password="$ANDROID_KEY_PASSWORD" + + - name: Normalize signed Android release assets + if: ${{ env.ANDROID_KEYSTORE_BASE64 != '' }} + run: | + APK="packages/mobile/android/app/build/outputs/apk/release/app-release.apk" + AAB="packages/mobile/android/app/build/outputs/bundle/release/app-release.aab" + if [ ! -f "$APK" ]; then + echo "::error::Expected signed Android APK missing at $APK" >&2 + exit 1 + fi + if [ ! -f "$AAB" ]; then + echo "::error::Expected signed Android AAB missing at $AAB" >&2 + exit 1 + fi + mkdir -p packages/mobile/dist + cp "$APK" packages/mobile/dist/fusion-android-release.apk + cp "$AAB" packages/mobile/dist/fusion-android-release.aab + + - name: Verify signed Android APK signature + if: ${{ env.ANDROID_KEYSTORE_BASE64 != '' }} + run: | + APK="packages/mobile/dist/fusion-android-release.apk" + APKSIGNER="" + if [ -n "${ANDROID_SDK_ROOT:-}" ] && [ -d "$ANDROID_SDK_ROOT/build-tools" ]; then + APKSIGNER=$(find "$ANDROID_SDK_ROOT/build-tools" -maxdepth 2 -type f -name apksigner | sort -V | tail -n 1 || true) + fi + if [ -n "$APKSIGNER" ]; then + "$APKSIGNER" verify --verbose "$APK" + else + jarsigner -verify -strict "$APK" + fi + + - name: Build unsigned Android debug APK + if: ${{ env.ANDROID_KEYSTORE_BASE64 == '' }} run: | cd packages/mobile/android chmod +x gradlew ./gradlew assembleDebug - - name: Normalize Android APK asset + - name: Normalize unsigned Android APK asset + if: ${{ env.ANDROID_KEYSTORE_BASE64 == '' }} run: | - # FNXC:Release 2026-06-25-12:00: - # Ship the secret-free debug APK because this repo has no Android - # signing keystore configured; signed release APK/AAB distribution is a - # separate product task, not a binary-release plumbing prerequisite. APK="packages/mobile/android/app/build/outputs/apk/debug/app-debug.apk" if [ ! -f "$APK" ]; then echo "::error::Expected Android APK missing at $APK" >&2 @@ -427,18 +488,23 @@ jobs: mkdir -p packages/mobile/dist cp "$APK" packages/mobile/dist/fusion-android.apk - - name: Generate Android APK checksum + - name: Generate Android artifact checksums run: | cd packages/mobile/dist - sha256sum fusion-android.apk > fusion-android.apk.sha256 + for file in fusion-android*.apk fusion-android-release.aab; do + [ -f "$file" ] || continue + sha256sum "$file" > "$file.sha256" + done - - name: Upload Android APK artifact + - name: Upload Android artifacts uses: actions/upload-artifact@v4 with: name: fusion-android-apk path: | - packages/mobile/dist/fusion-android.apk - packages/mobile/dist/fusion-android.apk.sha256 + packages/mobile/dist/fusion-android*.apk + packages/mobile/dist/fusion-android*.apk.sha256 + packages/mobile/dist/fusion-android-release.aab + packages/mobile/dist/fusion-android-release.aab.sha256 # ── Create GitHub Release ───────────────────────────────────────────── github-release: @@ -476,7 +542,7 @@ jobs: id: collect run: | mkdir release-files - find artifacts -type f \( -name "fn-*" -o -name "*.sha256" -o -name "*.asc" -o -name "*.exe" -o -name "*.exe.sha256" -o -name "*.blockmap" -o -name "*.dmg" -o -name "*.dmg.sha256" -o -name "*.zip" -o -name "*.zip.sha256" -o -name "*.apk" -o -name "*.AppImage" -o -name "*.AppImage.sha256" -o -name "*.deb" -o -name "*.deb.sha256" -o -name "*.tar.gz" -o -name "*.tar.gz.sha256" -o -name "latest*.yml" \) -exec cp {} release-files/ \; + find artifacts -type f \( -name "fn-*" -o -name "*.sha256" -o -name "*.asc" -o -name "*.exe" -o -name "*.exe.sha256" -o -name "*.blockmap" -o -name "*.dmg" -o -name "*.dmg.sha256" -o -name "*.zip" -o -name "*.zip.sha256" -o -name "*.apk" -o -name "*.aab" -o -name "*.AppImage" -o -name "*.AppImage.sha256" -o -name "*.deb" -o -name "*.deb.sha256" -o -name "*.tar.gz" -o -name "*.tar.gz.sha256" -o -name "latest*.yml" \) -exec cp {} release-files/ \; ls -la release-files/ count=$(find release-files -type f | wc -l | tr -d ' ') echo "count=$count" >> "$GITHUB_OUTPUT" diff --git a/.github/workflows/test-release.yml b/.github/workflows/test-release.yml index 1acd26e0be..a314be59a1 100644 --- a/.github/workflows/test-release.yml +++ b/.github/workflows/test-release.yml @@ -346,15 +346,27 @@ jobs: packages/desktop/dist-electron/latest-linux.yml - # ── Build Android APK artifact ─────────────────────────────────────── + # ── Build Android APK/AAB artifacts ────────────────────────────────── # FNXC:Release 2026-06-25-12:00: # Keep the tag-less rehearsal workflow in parity with release.yml so APK # generation, checksum output, and artifact collection are validated before a # version tag tries to publish the Android asset publicly. + # FNXC:Release 2026-06-25-18:10: + # Android signing is optional and secret-gated on ANDROID_KEYSTORE_BASE64, + # ANDROID_KEYSTORE_PASSWORD, ANDROID_KEY_ALIAS, and ANDROID_KEY_PASSWORD. + # The Capacitor Android project is gitignored/regenerated, so CI injects + # signing with android.injected.signing.* Gradle properties instead of + # committing native build.gradle edits. When the keystore is absent, keep the + # FN-7014 unsigned debug APK fallback; Play Store upload remains out of scope + # and is tracked separately from sideload release artifacts. build-android: - name: Build Android APK + name: Build Android APK/AAB runs-on: ubuntu-latest timeout-minutes: 30 + # Job-level env mirrors the desktop signing pattern: step `if:` conditions + # can inspect env values, but cannot read secrets.* directly. + env: + ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} steps: - name: Checkout @@ -393,18 +405,67 @@ jobs: fi pnpm --filter @fusion/mobile cap sync android - - name: Build Android APK + - name: Decode Android signing keystore + if: ${{ env.ANDROID_KEYSTORE_BASE64 != '' }} + run: | + printf '%s' "$ANDROID_KEYSTORE_BASE64" | base64 --decode > "$RUNNER_TEMP/fusion-release.keystore" + + - name: Build signed Android release APK and AAB + if: ${{ env.ANDROID_KEYSTORE_BASE64 != '' }} + env: + ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} + ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} + ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} + run: | + cd packages/mobile/android + chmod +x gradlew + ./gradlew assembleRelease bundleRelease \ + -Pandroid.injected.signing.store.file="$RUNNER_TEMP/fusion-release.keystore" \ + -Pandroid.injected.signing.store.password="$ANDROID_KEYSTORE_PASSWORD" \ + -Pandroid.injected.signing.key.alias="$ANDROID_KEY_ALIAS" \ + -Pandroid.injected.signing.key.password="$ANDROID_KEY_PASSWORD" + + - name: Normalize signed Android release assets + if: ${{ env.ANDROID_KEYSTORE_BASE64 != '' }} + run: | + APK="packages/mobile/android/app/build/outputs/apk/release/app-release.apk" + AAB="packages/mobile/android/app/build/outputs/bundle/release/app-release.aab" + if [ ! -f "$APK" ]; then + echo "::error::Expected signed Android APK missing at $APK" >&2 + exit 1 + fi + if [ ! -f "$AAB" ]; then + echo "::error::Expected signed Android AAB missing at $AAB" >&2 + exit 1 + fi + mkdir -p packages/mobile/dist + cp "$APK" packages/mobile/dist/fusion-android-release.apk + cp "$AAB" packages/mobile/dist/fusion-android-release.aab + + - name: Verify signed Android APK signature + if: ${{ env.ANDROID_KEYSTORE_BASE64 != '' }} + run: | + APK="packages/mobile/dist/fusion-android-release.apk" + APKSIGNER="" + if [ -n "${ANDROID_SDK_ROOT:-}" ] && [ -d "$ANDROID_SDK_ROOT/build-tools" ]; then + APKSIGNER=$(find "$ANDROID_SDK_ROOT/build-tools" -maxdepth 2 -type f -name apksigner | sort -V | tail -n 1 || true) + fi + if [ -n "$APKSIGNER" ]; then + "$APKSIGNER" verify --verbose "$APK" + else + jarsigner -verify -strict "$APK" + fi + + - name: Build unsigned Android debug APK + if: ${{ env.ANDROID_KEYSTORE_BASE64 == '' }} run: | cd packages/mobile/android chmod +x gradlew ./gradlew assembleDebug - - name: Normalize Android APK asset + - name: Normalize unsigned Android APK asset + if: ${{ env.ANDROID_KEYSTORE_BASE64 == '' }} run: | - # FNXC:Release 2026-06-25-12:00: - # Ship the secret-free debug APK because this repo has no Android - # signing keystore configured; signed release APK/AAB distribution is a - # separate product task, not a binary-release plumbing prerequisite. APK="packages/mobile/android/app/build/outputs/apk/debug/app-debug.apk" if [ ! -f "$APK" ]; then echo "::error::Expected Android APK missing at $APK" >&2 @@ -413,18 +474,23 @@ jobs: mkdir -p packages/mobile/dist cp "$APK" packages/mobile/dist/fusion-android.apk - - name: Generate Android APK checksum + - name: Generate Android artifact checksums run: | cd packages/mobile/dist - sha256sum fusion-android.apk > fusion-android.apk.sha256 + for file in fusion-android*.apk fusion-android-release.aab; do + [ -f "$file" ] || continue + sha256sum "$file" > "$file.sha256" + done - - name: Upload Android APK artifact + - name: Upload Android artifacts uses: actions/upload-artifact@v4 with: name: fusion-android-apk path: | - packages/mobile/dist/fusion-android.apk - packages/mobile/dist/fusion-android.apk.sha256 + packages/mobile/dist/fusion-android*.apk + packages/mobile/dist/fusion-android*.apk.sha256 + packages/mobile/dist/fusion-android-release.aab + packages/mobile/dist/fusion-android-release.aab.sha256 # ── Collect all artifacts ───────────────────────────────────────────── collect: @@ -441,7 +507,7 @@ jobs: - name: Combine artifacts run: | mkdir combined - find artifacts -type f \( -name "fn-*" -o -name "*.sha256" -o -name "*.asc" -o -name "*.exe" -o -name "*.exe.sha256" -o -name "*.blockmap" -o -name "*.dmg" -o -name "*.dmg.sha256" -o -name "*.zip" -o -name "*.zip.sha256" -o -name "*.apk" -o -name "*.AppImage" -o -name "*.AppImage.sha256" -o -name "*.deb" -o -name "*.deb.sha256" -o -name "*.tar.gz" -o -name "*.tar.gz.sha256" -o -name "latest*.yml" \) -exec cp {} combined/ \; + find artifacts -type f \( -name "fn-*" -o -name "*.sha256" -o -name "*.asc" -o -name "*.exe" -o -name "*.exe.sha256" -o -name "*.blockmap" -o -name "*.dmg" -o -name "*.dmg.sha256" -o -name "*.zip" -o -name "*.zip.sha256" -o -name "*.apk" -o -name "*.aab" -o -name "*.AppImage" -o -name "*.AppImage.sha256" -o -name "*.deb" -o -name "*.deb.sha256" -o -name "*.tar.gz" -o -name "*.tar.gz.sha256" -o -name "latest*.yml" \) -exec cp {} combined/ \; ls -la combined/ - name: Upload combined archive diff --git a/MOBILE.md b/MOBILE.md index 419b8047e1..11ea34a507 100644 --- a/MOBILE.md +++ b/MOBILE.md @@ -111,7 +111,23 @@ Mobile CI is defined in `.github/workflows/mobile.yml`. - `build-ios` (sync/build iOS when `packages/mobile/ios/` exists) - `build-android` (sync/build Android when `packages/mobile/android/` exists) -Artifacts from the Mobile Builds workflow are retained for 30 days. Tagged binary releases also run the Android build leg in `.github/workflows/release.yml` and publish `fusion-android.apk` plus `fusion-android.apk.sha256` as GitHub Release assets; `.github/workflows/test-release.yml` mirrors that path in its tag-less rehearsal artifact. +Artifacts from the Mobile Builds workflow are retained for 30 days. Tagged binary releases also run the Android build leg in `.github/workflows/release.yml`; `.github/workflows/test-release.yml` mirrors that path in its tag-less rehearsal artifact. + +When the repository has Android signing secrets configured (`ANDROID_KEYSTORE_BASE64`, `ANDROID_KEYSTORE_PASSWORD`, `ANDROID_KEY_ALIAS`, `ANDROID_KEY_PASSWORD`), the release pipeline publishes signed `fusion-android-release.apk` and `fusion-android-release.aab` assets plus `.sha256` checksums. Without those secrets, the pipeline preserves the secret-free fallback and publishes the unsigned debug APK as `fusion-android.apk` plus `fusion-android.apk.sha256`. + +Install the signed APK by enabling **Install unknown apps** for the transfer source on the device, then sideloading it: + +```bash +adb install fusion-android-release.apk +``` + +Verify the APK signer before distribution when Android SDK build-tools are available: + +```bash +apksigner verify --print-certs fusion-android-release.apk +``` + +The `.aab` file is for Play distribution and is not directly sideloadable with `adb install`. Automated Play Store / Play Console upload remains out of scope for now because it needs a Google service-account JSON secret, a published Play listing, and fastlane or `r0adkll/upload-google-play` wiring; that work is tracked separately in FN-7043 from the sideload-first release assets. ## Replacing PWA Icons diff --git a/RELEASING.md b/RELEASING.md index 2b9caa3e1e..22194a7403 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -57,17 +57,17 @@ When you merge the Version Packages PR: - It creates a git tag `v{version}` based on the `kb` CLI package version - The tag push triggers `release.yml`, which: - Builds platform-specific binaries for Linux x64, macOS x64, macOS arm64, and Windows x64 - - Builds the Android APK as `fusion-android.apk` - - Signs macOS binaries (codesign + notarization) and Windows binaries (Authenticode) - - Generates SHA256 checksums for all binaries and the Android APK - - Creates a **GitHub Release** with all binaries, the Android APK, and checksums attached + - Builds Android release assets: signed `fusion-android-release.apk` + `fusion-android-release.aab` when Android signing secrets are configured, otherwise unsigned debug `fusion-android.apk` + - Signs macOS binaries (codesign + notarization), Windows binaries (Authenticode), and Android release artifacts when keystore secrets are available + - Generates SHA256 checksums for all binaries and Android artifacts + - Creates a **GitHub Release** with all binaries, Android artifacts, and checksums attached ## Release channels | Channel | Workflow | Trigger | Output | |---------|----------|---------|--------| | npm | `version.yml` | Push to `main` | npm packages with provenance | -| GitHub Release | `release.yml` | Version tag (`v*`) | Signed platform binaries, Android APK + checksums | +| GitHub Release | `release.yml` | Version tag (`v*`) | Signed platform binaries, Android APK/AAB + checksums | ## Platform binaries @@ -76,16 +76,36 @@ When you merge the Version Packages PR: | Linux x64 | `fusion-linux-x64` | — | | macOS arm64 | `fusion-darwin-arm64` | ✓ (codesign + notarization) | | Windows x64 | `fusion-windows-x64.exe` | ✓ (Authenticode) | -| Android | `fusion-android.apk` | — (debug/unsigned APK) | +| Android | `fusion-android-release.apk`, `fusion-android-release.aab` | ✓ when Android keystore secrets are configured | +| Android fallback | `fusion-android.apk` | — (debug/unsigned APK when Android keystore secrets are absent) | > macOS Intel (`darwin-x64`) is intentionally not shipped: the CLI is Apple-Silicon-only because `macos-13` GitHub runners are too scarce to build reliably. The desktop macOS DMG/ZIP remains universal. +## Android release signing + +`release.yml` and the tag-less `test-release.yml` rehearsal workflow publish signed Android release artifacts when all Android signing secrets are configured: + +- `ANDROID_KEYSTORE_BASE64` — base64-encoded `.jks` / `.keystore` file +- `ANDROID_KEYSTORE_PASSWORD` +- `ANDROID_KEY_ALIAS` +- `ANDROID_KEY_PASSWORD` + +Encode the keystore before saving it as a GitHub Actions secret: + +```bash +base64 -w0 release.keystore +``` + +The Android native project under `packages/mobile/android/` is generated and gitignored, so CI does not commit signing configuration into Gradle files. Instead, the release job injects signing at build time with Android Gradle Plugin `android.injected.signing.*` properties, builds `assembleRelease` and `bundleRelease`, verifies the APK signature, and uploads `fusion-android-release.apk`, `fusion-android-release.aab`, and matching `.sha256` files. If `ANDROID_KEYSTORE_BASE64` is absent, the workflow preserves the secret-free path by building the unsigned debug APK as `fusion-android.apk` with `fusion-android.apk.sha256`. + +Automated Play Store / Play Console upload is intentionally out of scope for the release pipeline right now. It needs a Google service-account JSON secret, a published Play listing, and fastlane or `r0adkll/upload-google-play` wiring; that work is tracked separately in FN-7043 while Fusion remains sideload-first for pre-1.0 Android distribution. + ## Testing binary builds Use the **Test Release** workflow (`test-release.yml`) to manually test binary builds without creating a real release: 1. Go to **Actions** → **Test Release** → **Run workflow** -2. The workflow builds all 4 platform binaries plus the Android APK, runs smoke tests, and uploads artifacts +2. The workflow builds all 4 platform binaries plus the Android APK/AAB path (signed when Android signing secrets are available, unsigned debug APK otherwise), runs smoke tests, and uploads artifacts 3. Download the `all-binaries` artifact to inspect the output ## Manual release (fallback) diff --git a/packages/cli/src/__tests__/ci-workflow.test.ts b/packages/cli/src/__tests__/ci-workflow.test.ts index 8ec939b65b..e1e3853857 100644 --- a/packages/cli/src/__tests__/ci-workflow.test.ts +++ b/packages/cli/src/__tests__/ci-workflow.test.ts @@ -418,6 +418,18 @@ describe("Binary release workflow (.github/workflows/release.yml)", () => { expect(workflow.jobs["github-release"].needs).toContain("build-binaries"); expect(workflow.jobs["github-release"].needs).toContain("build-android"); }); + + it("wires signed Android AAB artifacts into release aggregation", () => { + const androidJob = workflow.jobs["build-android"]; + const collectStep = workflow.jobs["github-release"].steps.find((step: any) => step.name === "Collect release files"); + + expect(androidJob.env.ANDROID_KEYSTORE_BASE64).toBe("${{ secrets.ANDROID_KEYSTORE_BASE64 }}"); + expect(content).toContain("./gradlew assembleRelease bundleRelease"); + expect(content).toContain("fusion-android-release.aab"); + expect(collectStep.run).toContain('-name "*.apk"'); + expect(collectStep.run).toContain('-name "*.aab"'); + expect(collectStep.run).toContain('-name "*.sha256"'); + }); }); describe("Test-release workflow (.github/workflows/test-release.yml)", () => { @@ -485,6 +497,18 @@ describe("Test-release workflow (.github/workflows/test-release.yml)", () => { expect(workflow.jobs.collect.needs).toContain("build-android"); expect(content).toContain("all-binaries"); }); + + it("wires signed Android AAB artifacts into rehearsal aggregation", () => { + const androidJob = workflow.jobs["build-android"]; + const combineStep = workflow.jobs.collect.steps.find((step: any) => step.name === "Combine artifacts"); + + expect(androidJob.env.ANDROID_KEYSTORE_BASE64).toBe("${{ secrets.ANDROID_KEYSTORE_BASE64 }}"); + expect(content).toContain("./gradlew assembleRelease bundleRelease"); + expect(content).toContain("fusion-android-release.aab"); + expect(combineStep.run).toContain('-name "*.apk"'); + expect(combineStep.run).toContain('-name "*.aab"'); + expect(combineStep.run).toContain('-name "*.sha256"'); + }); }); describe("Code signing — Release workflow secrets", () => { diff --git a/packages/desktop/README.md b/packages/desktop/README.md index 77232796a3..9c0f114e5a 100644 --- a/packages/desktop/README.md +++ b/packages/desktop/README.md @@ -364,7 +364,7 @@ Desktop packaging is configured in `electron-builder.yml`. - Windows: x64 + arm64 outputs (NSIS + portable), matching `.exe.sha256` sidecars, and `.blockmap` files. - macOS: `Fusion--mac-arm64.dmg`, `Fusion--mac-x64.dmg`, matching `.zip` variants, `.sha256` sidecars, and `.blockmap` files. - Linux: `Fusion--linux-x64.AppImage` and `Fusion--linux-arm64.AppImage` with matching `.sha256` sidecars, plus best-effort `.deb` and `.tar.gz` outputs per arch (`Fusion--linux-x64.{deb,tar.gz}` / `Fusion--linux-arm64.{deb,tar.gz}`) and sidecars when available on the runner image. - - Android: `fusion-android.apk` and `fusion-android.apk.sha256` from the Capacitor/Gradle debug APK build. + - Android: when Android signing secrets are configured, `fusion-android-release.apk`, `fusion-android-release.apk.sha256`, `fusion-android-release.aab`, and `fusion-android-release.aab.sha256`; otherwise the secret-free fallback publishes `fusion-android.apk` and `fusion-android.apk.sha256` from the Capacitor/Gradle debug APK build. - Tag-less release rehearsal workflow (`.github/workflows/test-release.yml`) mirrors that artifact collection path without publishing a real GitHub Release. - Linux ARM64 artifacts are cross-built from the `ubuntu-latest` x64 runner by passing `electron-builder --linux --x64 --arm64`; running/validating arm64 installers still requires an arm64 Linux device or emulator. - Linux desktop artifacts can include detached GPG signature sidecars (`*.AppImage.asc`, `*.deb.asc`, `*.tar.gz.asc`) when Linux signing secrets are configured in CI; full Linux desktop code-signing rollout remains tracked in FN-5605. diff --git a/packages/desktop/src/__tests__/release-workflow.test.ts b/packages/desktop/src/__tests__/release-workflow.test.ts index d7b9fd8e68..b1c5cdb92b 100644 --- a/packages/desktop/src/__tests__/release-workflow.test.ts +++ b/packages/desktop/src/__tests__/release-workflow.test.ts @@ -68,7 +68,7 @@ describe("desktop release workflow wiring", () => { expect(testRelease).toContain('-name "latest*.yml"'); }); - it("adds Android APK build and aggregation wiring to release workflows", async () => { + it("adds signed Android APK/AAB build and aggregation wiring to release workflows", async () => { const release = await readRepoFile(".github/workflows/release.yml"); const testRelease = await readRepoFile(".github/workflows/test-release.yml"); @@ -79,12 +79,32 @@ describe("desktop release workflow wiring", () => { expect(workflow).toContain('java-version: "17"'); expect(workflow).toContain("pnpm --filter @fusion/mobile cap add android"); expect(workflow).toContain("pnpm --filter @fusion/mobile cap sync android"); + expect(workflow).toContain("ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}"); + expect(workflow).toContain("ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}"); + expect(workflow).toContain("ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}"); + expect(workflow).toContain("ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}"); + expect(workflow).toContain("env.ANDROID_KEYSTORE_BASE64 != ''"); + expect(workflow).toContain("env.ANDROID_KEYSTORE_BASE64 == ''"); + expect(workflow).toContain("./gradlew assembleRelease bundleRelease"); + expect(workflow).toContain("android.injected.signing.store.file"); + expect(workflow).toContain("android.injected.signing.store.password"); + expect(workflow).toContain("android.injected.signing.key.alias"); + expect(workflow).toContain("android.injected.signing.key.password"); expect(workflow).toContain("./gradlew assembleDebug"); expect(workflow).toContain("packages/mobile/android/app/build/outputs/apk/debug/app-debug.apk"); + expect(workflow).toContain("packages/mobile/android/app/build/outputs/apk/release/app-release.apk"); + expect(workflow).toContain("packages/mobile/android/app/build/outputs/bundle/release/app-release.aab"); expect(workflow).toContain("packages/mobile/dist/fusion-android.apk"); - expect(workflow).toContain("sha256sum fusion-android.apk > fusion-android.apk.sha256"); + expect(workflow).toContain("packages/mobile/dist/fusion-android-release.apk"); + expect(workflow).toContain("packages/mobile/dist/fusion-android-release.aab"); + expect(workflow).toContain("apksigner"); + expect(workflow).toContain("jarsigner -verify -strict"); + expect(workflow).toContain("sha256sum \"$file\" > \"$file.sha256\""); expect(workflow).toContain("name: fusion-android-apk"); + expect(workflow).toContain("fusion-android-release.apk"); + expect(workflow).toContain("fusion-android-release.aab"); expect(workflow).toContain('-name "*.apk"'); + expect(workflow).toContain('-name "*.aab"'); } }); }); From 6415eed0c113766013671b417de757a76f373273 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Thu, 25 Jun 2026 20:28:38 -0700 Subject: [PATCH 08/50] FN-7034: Align steps dropdown trigger styling Align the optional steps dropdown trigger with shared task creation button styling. - Reuse the dashboard btn btn-sm classes for the workflow optional steps trigger. - Remove bespoke trigger button styling so shared button tokens control padding, border, radius, and states. - Extend dropdown tests to cover shared classes across empty, selected, and disabled states. - Add a patch changeset for the published Fusion package. Files changed: .changeset/fn-7034-steps-dropdown-style.md | 7 +++ .../components/WorkflowOptionalStepsDropdown.css | 14 ------ .../components/WorkflowOptionalStepsDropdown.tsx | 9 +++- .../WorkflowOptionalStepsDropdown.test.tsx | 50 ++++++++++++++++++++-- 4 files changed, 61 insertions(+), 19 deletions(-) Fusion-Task-Id: FN-7034 Fusion-Task-Lineage: 3ca2a726-2c67-44a0-9a88-467e4fc1ad6b --- .changeset/fn-7034-steps-dropdown-style.md | 7 +++ .../WorkflowOptionalStepsDropdown.css | 14 ------ .../WorkflowOptionalStepsDropdown.tsx | 9 +++- .../WorkflowOptionalStepsDropdown.test.tsx | 50 +++++++++++++++++-- 4 files changed, 61 insertions(+), 19 deletions(-) create mode 100644 .changeset/fn-7034-steps-dropdown-style.md diff --git a/.changeset/fn-7034-steps-dropdown-style.md b/.changeset/fn-7034-steps-dropdown-style.md new file mode 100644 index 0000000000..046899d2ae --- /dev/null +++ b/.changeset/fn-7034-steps-dropdown-style.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Match the optional steps dropdown trigger to shared task creation buttons. +category: fix +dev: Reuses the dashboard `.btn .btn-sm` trigger styling for WorkflowOptionalStepsDropdown. diff --git a/packages/dashboard/app/components/WorkflowOptionalStepsDropdown.css b/packages/dashboard/app/components/WorkflowOptionalStepsDropdown.css index 2a91a9f0fb..dff10a8c26 100644 --- a/packages/dashboard/app/components/WorkflowOptionalStepsDropdown.css +++ b/packages/dashboard/app/components/WorkflowOptionalStepsDropdown.css @@ -6,20 +6,6 @@ display: inline-flex; } -.wf-optional-steps-dropdown-trigger { - display: inline-flex; - align-items: center; - justify-content: space-between; - gap: 6px; - padding: 4px 8px; - font-size: 0.8rem; - border: 1px solid var(--border); - border-radius: var(--radius-sm, 6px); - background: var(--surface, transparent); - color: inherit; - cursor: pointer; -} - .wf-optional-steps-dropdown-trigger:disabled { opacity: 0.5; cursor: default; diff --git a/packages/dashboard/app/components/WorkflowOptionalStepsDropdown.tsx b/packages/dashboard/app/components/WorkflowOptionalStepsDropdown.tsx index 80fd71c0ad..87a23c1011 100644 --- a/packages/dashboard/app/components/WorkflowOptionalStepsDropdown.tsx +++ b/packages/dashboard/app/components/WorkflowOptionalStepsDropdown.tsx @@ -16,6 +16,11 @@ * matching the quick-add card's prior no-chip-block behavior and the modal's * empty-state choice, so both surfaces look identical. * + * FNXC:TaskCreationButtons 2026-06-25-00:00: + * The optional-steps trigger must reuse shared `.btn .btn-sm` styling so it has + * the same padding, background, border, radius, and interaction affordances as + * the quick-add and New Task action buttons on every creation surface. + * * Accessibility: trigger has aria-haspopup/aria-expanded; the panel is a * role="listbox" labelled by the trigger; each option is a role="option" with * aria-checked. Escape closes and refocuses the trigger; arrow keys move the @@ -145,7 +150,7 @@ export function WorkflowOptionalStepsDropdown({ ref={triggerRef} type="button" id={labelId} - className="wf-optional-steps-dropdown-trigger" + className="btn btn-sm wf-optional-steps-dropdown-trigger" data-testid={triggerTestId} aria-haspopup="listbox" aria-expanded={isOpen} @@ -157,7 +162,7 @@ export function WorkflowOptionalStepsDropdown({ onKeyDown={onTriggerKeyDown} > {triggerLabel} - + {isOpen && diff --git a/packages/dashboard/app/components/__tests__/WorkflowOptionalStepsDropdown.test.tsx b/packages/dashboard/app/components/__tests__/WorkflowOptionalStepsDropdown.test.tsx index 4d458345b2..6ceac1ab9e 100644 --- a/packages/dashboard/app/components/__tests__/WorkflowOptionalStepsDropdown.test.tsx +++ b/packages/dashboard/app/components/__tests__/WorkflowOptionalStepsDropdown.test.tsx @@ -13,8 +13,27 @@ const STEP: ResolvedWorkflowOptionalStep = { defaultOn: false, }; +const STEP_TWO: ResolvedWorkflowOptionalStep = { + templateId: "test-review", + name: "Test Review", + description: "Review test coverage", + icon: "check-circle", + phase: "post-implementation", + defaultOn: false, +}; + // Controlled host: parent owns the enabled set, mirroring the create surfaces. -function Host({ steps, initial = [] }: { steps: ResolvedWorkflowOptionalStep[]; initial?: string[] }) { +function Host({ + steps, + initial = [], + disabled = false, + triggerTestId, +}: { + steps: ResolvedWorkflowOptionalStep[]; + initial?: string[]; + disabled?: boolean; + triggerTestId?: string; +}) { const [enabled, setEnabled] = useState(initial); return ( setEnabled((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id])) } + disabled={disabled} + triggerTestId={triggerTestId} /> ); } +function expectSharedButtonTrigger(trigger: HTMLElement) { + expect(trigger).toHaveClass("btn", "btn-sm", "wf-optional-steps-dropdown-trigger"); + expect(trigger).toHaveAttribute("aria-haspopup", "listbox"); + expect(trigger).toHaveAttribute("aria-expanded"); +} + afterEach(() => { cleanup(); vi.clearAllMocks(); @@ -38,9 +65,26 @@ describe("WorkflowOptionalStepsDropdown", () => { expect(container.firstChild).toBeNull(); }); - it("reflects the selected count in the trigger label", () => { - render(); + it("uses shared button classes and preserves trigger attributes when none are selected", () => { + render(); + const trigger = screen.getByTestId("custom-optional-steps-trigger"); + expectSharedButtonTrigger(trigger); + expect(trigger).toHaveTextContent("Steps: none"); + expect(trigger).toHaveAttribute("aria-expanded", "false"); + }); + + it("uses shared button classes and count label when multiple steps are selected", () => { + render(); const trigger = screen.getByTestId("wf-optional-steps-dropdown-trigger"); + expectSharedButtonTrigger(trigger); + expect(trigger).toHaveTextContent("Steps: 2 selected"); + }); + + it("uses shared button classes and disabled semantics when submitting", () => { + render(); + const trigger = screen.getByTestId("wf-optional-steps-dropdown-trigger"); + expectSharedButtonTrigger(trigger); + expect(trigger).toBeDisabled(); expect(trigger).toHaveTextContent("Steps: none"); }); From b663eebcb3dab00b3ccf87e43f02f6fcd369739d Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Thu, 25 Jun 2026 20:50:30 -0700 Subject: [PATCH 09/50] FN-7035: split oversized test suites Split oversized ChatView and notifier suites while updating the line-count baseline. - Move ChatView core contract and interaction coverage into focused sibling test files. - Move notifier runtime coverage into its own suite and share setup through a test harness. - Document the line-count guard decision and ratchet baseline entries for existing growth. Files changed: .../__tests__/ChatView.core-contracts.test.tsx | 623 ++++++++ .../__tests__/ChatView.core-interactions.test.tsx | 1261 +++++++++++++++ .../components/__tests__/ChatView.core.test.tsx | 1652 +------------------- .../engine/src/__tests__/notifier.runtime.test.ts | 810 ++++++++++ .../engine/src/__tests__/notifier.test-harness.ts | 71 + packages/engine/src/__tests__/notifier.test.ts | 847 +--------- scripts/check-file-line-count.mjs | 3 + scripts/line-count-baseline.json | 12 +- 8 files changed, 2778 insertions(+), 2501 deletions(-) Fusion-Task-Id: FN-7035 Fusion-Task-Lineage: 14cebb57-925b-41c7-9c8b-472f34b76fe2 --- .../ChatView.core-contracts.test.tsx | 623 +++++++ .../ChatView.core-interactions.test.tsx | 1261 +++++++++++++ .../__tests__/ChatView.core.test.tsx | 1652 +---------------- .../src/__tests__/notifier.runtime.test.ts | 810 ++++++++ .../src/__tests__/notifier.test-harness.ts | 71 + .../engine/src/__tests__/notifier.test.ts | 847 +-------- scripts/check-file-line-count.mjs | 3 + scripts/line-count-baseline.json | 12 +- 8 files changed, 2778 insertions(+), 2501 deletions(-) create mode 100644 packages/dashboard/app/components/__tests__/ChatView.core-contracts.test.tsx create mode 100644 packages/dashboard/app/components/__tests__/ChatView.core-interactions.test.tsx create mode 100644 packages/engine/src/__tests__/notifier.runtime.test.ts create mode 100644 packages/engine/src/__tests__/notifier.test-harness.ts diff --git a/packages/dashboard/app/components/__tests__/ChatView.core-contracts.test.tsx b/packages/dashboard/app/components/__tests__/ChatView.core-contracts.test.tsx new file mode 100644 index 0000000000..68ad5133b6 --- /dev/null +++ b/packages/dashboard/app/components/__tests__/ChatView.core-contracts.test.tsx @@ -0,0 +1,623 @@ +/* +FNXC:DashboardTests 2026-06-25-17:44: +ChatView suite split 5/5 (model/delete/css contracts) extracts model-tag, session-delete, and CSS-contract describes from ChatView.core.test.tsx so the cap-crosser is split into focused siblings rather than grandfathered. Shares ChatView.test-harness; vi.mock factories stay inline and self-contained per the harness TDZ warning. +*/ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { act, fireEvent, screen, waitFor, within } from "@testing-library/react"; +import { userEvent } from "@testing-library/user-event"; +import { useState } from "react"; +import { ChatView } from "../ChatView"; +import type { DiscoveredSkill } from "@fusion/dashboard"; +import type { UseChatReturn, ChatSessionInfo } from "../../hooks/useChat"; +import { loadAllAppCss } from "../../test/cssFixture"; +import { FileBrowserProvider } from "../../context/FileBrowserContext"; +import { SWR_CACHE_KEYS, writeCache } from "../../utils/swrCache"; +import { + renderWithAct, + setupMockChat, + setupMockRooms, + mockViewportMode, + activeSessionFixture, + createMockSkill, + defaultChatState, + defaultModelsResponse, + mockUseChat, + mockFetchModels, + mockFetchDiscoveredSkills, + mockCreateObjectURL, + mockRevokeObjectURL, + mockClipboardWriteText, + installChatViewEnv, +} from "./ChatView.test-harness"; + +// Mock the hooks +vi.mock("../../hooks/useChat"); +vi.mock("../../hooks/useChatRooms"); +vi.mock("../../hooks/useNavigationHistory", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useNavigationHistoryContext: () => ({ pushNav: vi.fn(), replaceCurrent: vi.fn() }), + }; +}); + +// Mock lucide-react icons - spread actual module and override specific icons +vi.mock("lucide-react", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + MessageSquare: ({ "data-testid": testId, ...props }: any) => ( + + ), + Send: ({ "data-testid": testId, ...props }: any) => , + Plus: ({ "data-testid": testId, ...props }: any) => , + Search: ({ "data-testid": testId, ...props }: any) => , + Trash2: ({ "data-testid": testId, ...props }: any) => , + Archive: ({ "data-testid": testId, ...props }: any) => , + Pencil: ({ "data-testid": testId, ...props }: any) => , + ChevronLeft: ({ "data-testid": testId, ...props }: any) => , + Bot: ({ "data-testid": testId, ...props }: any) => , + Square: ({ "data-testid": testId, ...props }: any) => , + Eye: ({ "data-testid": testId, ...props }: any) => , + EyeOff: ({ "data-testid": testId, ...props }: any) => , + Paperclip: ({ "data-testid": testId, ...props }: any) => , + File: ({ "data-testid": testId, ...props }: any) => , + Copy: ({ "data-testid": testId, ...props }: any) => , + Check: ({ "data-testid": testId, ...props }: any) => , + }; +}); + +// Mock CustomModelDropdown - no longer used but kept for other tests +vi.mock("../CustomModelDropdown", () => ({ + CustomModelDropdown: ({ + value, + onChange, + label, + }: { + value: string; + onChange: (value: string) => void; + label: string; + }) => ( + + ), +})); + +// Mock fetchAgents for new chat dialog +vi.mock("../../api", () => ({ + fetchModels: vi.fn().mockResolvedValue({ + models: [ + { provider: "anthropic", id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5", reasoning: true, contextWindow: 200000 }, + { provider: "openai", id: "gpt-4o", name: "GPT-4o", reasoning: false, contextWindow: 128000 }, + ], + favoriteProviders: [], + favoriteModels: [], + defaultProvider: "anthropic", + defaultModelId: "claude-sonnet-4-5", + }), + fetchAgents: vi.fn().mockResolvedValue([ + { id: "agent-001", name: "Alpha", role: "executor", state: "idle", icon: undefined, createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z", metadata: {} }, + { id: "agent-002", name: "Beta", role: "reviewer", state: "idle", icon: undefined, createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z", metadata: {} }, + ]), + fetchDiscoveredSkills: vi.fn().mockResolvedValue([]), + fetchTasks: vi.fn().mockResolvedValue([]), + searchFiles: vi.fn().mockResolvedValue({ files: [] }), +})); + +installChatViewEnv(); + + +describe("formatModelTag helper function", () => { + // Import the function for testing - we'll test it via the UI behavior instead + // The function is not exported, so we test it indirectly through the component + + it("formats claude-sonnet-4-5 model ID correctly", async () => { + setupMockChat({ + activeSession: { + id: "session-001", + agentId: "agent-001", + status: "active", + title: "Test", + modelProvider: "anthropic", + modelId: "claude-sonnet-4-5", + createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z", + }, + messages: [ + { id: "msg-001", sessionId: "session-001", role: "assistant", content: "Hi!", createdAt: "2026-04-08T00:00:00.000Z" }, + ], + }); + + await renderWithAct(); + + const modelTag = document.querySelector(".chat-model-tag") as HTMLElement | null; + expect(modelTag?.textContent).toContain("Claude Sonnet"); + }); + + it("formats gpt-4o model ID correctly", async () => { + setupMockChat({ + activeSession: { + id: "session-001", + agentId: "agent-001", + status: "active", + title: "Test", + modelProvider: "openai", + modelId: "gpt-4o", + createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z", + }, + messages: [ + { id: "msg-001", sessionId: "session-001", role: "assistant", content: "Hi!", createdAt: "2026-04-08T00:00:00.000Z" }, + ], + }); + + await renderWithAct(); + + const modelTag = document.querySelector(".chat-model-tag") as HTMLElement | null; + expect(modelTag?.textContent).toContain("GPT-4o"); + }); + + it("formats gemini-2.5-pro model ID correctly", async () => { + setupMockChat({ + activeSession: { + id: "session-001", + agentId: "agent-001", + status: "active", + title: "Test", + modelProvider: "google", + modelId: "gemini-2.5-pro", + createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z", + }, + messages: [ + { id: "msg-001", sessionId: "session-001", role: "assistant", content: "Hi!", createdAt: "2026-04-08T00:00:00.000Z" }, + ], + }); + + await renderWithAct(); + + const modelTag = document.querySelector(".chat-model-tag") as HTMLElement | null; + expect(modelTag?.textContent).toContain("Gemini"); + }); + + it("returns null when modelId is missing", async () => { + setupMockChat({ + activeSession: { + id: "session-001", + agentId: "__fn_agent__", + status: "active", + title: "Test", + modelProvider: "anthropic", + createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z", + }, + messages: [ + { id: "msg-001", sessionId: "session-001", role: "assistant", content: "Hi!", createdAt: "2026-04-08T00:00:00.000Z" }, + ], + }); + + await renderWithAct(); + + const modelTag = document.querySelector(".chat-model-tag") as HTMLElement | null; + expect(modelTag).not.toBeInTheDocument(); + }); + + it("returns null when provider is missing", async () => { + setupMockChat({ + activeSession: { + id: "session-001", + agentId: "__fn_agent__", + status: "active", + title: "Test", + modelId: "claude-sonnet-4-5", + createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z", + }, + messages: [ + { id: "msg-001", sessionId: "session-001", role: "assistant", content: "Hi!", createdAt: "2026-04-08T00:00:00.000Z" }, + ], + }); + + await renderWithAct(); + + const modelTag = document.querySelector(".chat-model-tag") as HTMLElement | null; + expect(modelTag).not.toBeInTheDocument(); + }); +}); + +describe("Chat Session Delete Button", () => { + it("renders delete button on each session item", async () => { + setupMockChat({ + sessions: [ + { id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat 1", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }, + { id: "session-002", agentId: "agent-002", status: "active", title: "Test Chat 2", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }, + ], + filteredSessions: [ + { id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat 1", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }, + { id: "session-002", agentId: "agent-002", status: "active", title: "Test Chat 2", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }, + ], + }); + + await renderWithAct(); + + const deleteButtons = screen.getAllByTestId("chat-session-delete-btn"); + expect(deleteButtons.length).toBe(2); + }); + + it("clicking delete button shows confirmation dialog", async () => { + setupMockChat({ + sessions: [{ id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }], + filteredSessions: [{ id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }], + }); + + await renderWithAct(); + + const deleteButton = screen.getByTestId("chat-session-delete-btn"); + await userEvent.click(deleteButton); + + // Dialog should be open + const dialog = document.querySelector(".chat-new-dialog") as HTMLElement | null; + expect(dialog).toBeInTheDocument(); + expect(within(dialog!).getByText("Delete Conversation?")).toBeInTheDocument(); + }); + + it("clicking delete button does not select the session", async () => { + const selectSession = vi.fn(); + setupMockChat({ + sessions: [{ id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }], + filteredSessions: [{ id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }], + selectSession, + }); + + await renderWithAct(); + + const deleteButton = screen.getByTestId("chat-session-delete-btn"); + await userEvent.click(deleteButton); + + expect(selectSession).not.toHaveBeenCalled(); + }); + + it("renames from the desktop context menu with the current title prefilled", async () => { + const renameSession = vi.fn().mockResolvedValue(undefined); + const renamedSession: ChatSessionInfo = { id: "session-001", agentId: "agent-001", status: "active", title: "Renamed Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }; + setupMockChat({ + activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }, + sessions: [{ id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }], + filteredSessions: [{ id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }], + renameSession, + }); + + const view = await renderWithAct(); + + fireEvent.contextMenu(screen.getByTestId("chat-session-session-001")); + expect(screen.getByTestId("chat-context-rename")).toBeInTheDocument(); + await userEvent.click(screen.getByTestId("chat-context-rename")); + + const input = screen.getByTestId("chat-rename-input") as HTMLInputElement; + expect(input.value).toBe("Test Chat"); + await userEvent.clear(input); + await userEvent.type(input, "Renamed Chat"); + await userEvent.click(screen.getByTestId("chat-rename-save")); + + expect(renameSession).toHaveBeenCalledWith("session-001", "Renamed Chat"); + + setupMockChat({ + activeSession: renamedSession, + sessions: [renamedSession], + filteredSessions: [renamedSession], + renameSession, + }); + await act(async () => { + view.rerender(); + }); + + expect(screen.getByTestId("chat-session-session-001")).toHaveTextContent("Renamed Chat"); + const headerTitle = document.querySelector(".chat-thread-header-title") as HTMLElement | null; + expect(headerTitle).toHaveTextContent("Renamed Chat"); + }); + + it("prefills rename as empty for an untitled session and names it", async () => { + const renameSession = vi.fn().mockResolvedValue(undefined); + const untitledSession: ChatSessionInfo = { id: "session-001", agentId: "agent-001", status: "active", title: null, createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }; + setupMockChat({ + activeSession: untitledSession, + sessions: [untitledSession], + filteredSessions: [untitledSession], + renameSession, + }); + + await renderWithAct(); + + fireEvent.contextMenu(screen.getByTestId("chat-session-session-001")); + await userEvent.click(screen.getByTestId("chat-context-rename")); + + const input = screen.getByTestId("chat-rename-input") as HTMLInputElement; + expect(input.value).toBe(""); + await userEvent.type(input, "Named from Untitled"); + await userEvent.click(screen.getByTestId("chat-rename-save")); + + expect(renameSession).toHaveBeenCalledWith("session-001", "Named from Untitled"); + }); + + it("renames from the mobile session switcher and preserves the active header title surface", async () => { + const restoreMatchMedia = mockViewportMode("mobile"); + const renameSession = vi.fn().mockResolvedValue(undefined); + try { + setupMockChat({ + activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Mobile Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }, + sessions: [{ id: "session-001", agentId: "agent-001", status: "active", title: "Mobile Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }], + filteredSessions: [{ id: "session-001", agentId: "agent-001", status: "active", title: "Mobile Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }], + renameSession, + }); + + const view = await renderWithAct(); + + expect(screen.getByTestId("chat-mobile-session-trigger")).toHaveTextContent("Mobile Chat"); + await userEvent.click(screen.getByTestId("chat-mobile-session-trigger")); + await userEvent.click(screen.getByTestId("chat-mobile-session-rename-session-001")); + + const input = screen.getByTestId("chat-rename-input") as HTMLInputElement; + expect(input.value).toBe("Mobile Chat"); + await userEvent.clear(input); + await userEvent.type(input, "Mobile Renamed"); + await userEvent.click(screen.getByTestId("chat-rename-save")); + + expect(renameSession).toHaveBeenCalledWith("session-001", "Mobile Renamed"); + + const renamedSession: ChatSessionInfo = { id: "session-001", agentId: "agent-001", status: "active", title: "Mobile Renamed", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }; + setupMockChat({ + activeSession: renamedSession, + sessions: [renamedSession], + filteredSessions: [renamedSession], + renameSession, + }); + await act(async () => { + view.rerender(); + }); + + expect(screen.getByTestId("chat-mobile-session-trigger")).toHaveTextContent("Mobile Renamed"); + const headerTitle = document.querySelector(".chat-thread-header-title") as HTMLElement | null; + expect(headerTitle).toHaveTextContent("Mobile Renamed"); + } finally { + restoreMatchMedia.mockRestore(); + } + }); + + it("confirming delete calls deleteSession", async () => { + const deleteSession = vi.fn(); + setupMockChat({ + sessions: [{ id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }], + filteredSessions: [{ id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }], + deleteSession, + }); + + await renderWithAct(); + + const deleteButton = screen.getByTestId("chat-session-delete-btn"); + await userEvent.click(deleteButton); + + // Click confirm in dialog + const dialog = document.querySelector(".chat-new-dialog") as HTMLElement | null; + await userEvent.click(within(dialog!).getByText("Delete")); + + expect(deleteSession).toHaveBeenCalledWith("session-001"); + }); +}); + +describe("ChatView CSS — failure bubble contracts", () => { + const css = loadAllAppCss(); + + it("uses shared error surface tokens for failure bubbles and detail affordances", async () => { + const bubbleMatch = css.match(/\.chat-message--failure\s*\{([^}]*)\}/); + const badgeMatch = css.match(/\.chat-message-failure-badge\s*\{([^}]*)\}/); + const detailsMatch = css.match(/\.chat-message-failure-details\s*\{([^}]*)\}/); + const linkMatch = css.match(/\.chat-message-failure-reference-link\s*\{([^}]*)\}/); + + expect(bubbleMatch?.[1]).toContain("background: var(--status-error-bg)"); + expect(bubbleMatch?.[1]).toContain("border: var(--btn-border-width) solid var(--status-error-bg-deep)"); + expect(badgeMatch?.[1]).toContain("background: var(--status-error-bg-deep)"); + expect(detailsMatch?.[1]).toContain("background: var(--status-error-bg-deep)"); + expect(linkMatch?.[1]).toContain("background: var(--status-error-bg-deep)"); + }); +}); + +describe("ChatView CSS — tablet assistant bubble width", () => { + const css = loadAllAppCss(); + + it("widens assistant, streaming, and failure bubbles on tablet containers while preserving user and mobile caps", async () => { + const baseMessageRule = css.match(/\.chat-message\s*\{([^}]*)\}/); + const userRule = css.match(/\.chat-message--user\s*\{([^}]*)\}/); + const tabletRule = css.match( + /@container\s+chat-view\s+\(min-width:\s*48\.0625rem\)\s+and\s+\(max-width:\s*64rem\)\s*\{([\s\S]*?)\n\}/, + ); + + expect(baseMessageRule?.[1]).toContain("max-width: 75%"); + expect(userRule?.[1]).toContain("align-self: flex-end"); + expect(userRule?.[1]).not.toContain("max-width"); + expect(tabletRule?.[1]).toMatch( + /\.chat-message--assistant,\s*\.chat-message--streaming,\s*\.chat-message--failure\s*\{[^}]*max-width:\s*88%/, + ); + expect(tabletRule?.[1]).not.toMatch(/\.chat-message--user\s*\{[^}]*max-width/); + expect(css).toMatch(/@media\s*\(max-width:\s*768px\)[\s\S]*?\.chat-message\s*\{[^}]*max-width:\s*90%/); + }); +}); + +describe("ChatView CSS — active state edge highlights", () => { + const css = loadAllAppCss(); + + function findRule(selector: string): string { + const escapedSelector = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const match = css.match(new RegExp(`${escapedSelector}\\s*\\{([^}]*)\\}`)); + expect(match).toBeTruthy(); + return match?.[1] ?? ""; + } + + function mobileRuleContains(selector: string, propertyPattern: RegExp): boolean { + const escapedSelector = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const mobileRegex = /@media[^{}]*\(max-width:\s*768px\)[^{]*\{([\s\S]*?)\n\}/g; + let match; + while ((match = mobileRegex.exec(css)) !== null) { + const ruleMatch = match[1].match(new RegExp(`${escapedSelector}\\s*\\{([^}]*)\\}`)); + if (ruleMatch && propertyPattern.test(ruleMatch[1])) { + return true; + } + } + return false; + } + + it("keeps scope-tab active tint without the removed bottom underline", async () => { + const activeScopeRule = findRule(".chat-sidebar-scope-btn--active"); + + expect(activeScopeRule).toContain("background: var(--card)"); + expect(activeScopeRule).toContain("color: var(--text)"); + expect(activeScopeRule).not.toContain("box-shadow"); + expect(activeScopeRule).not.toContain("inset"); + }); + + it("renders the header Direct/Rooms toggle with visible borders", async () => { + const headerScopeRule = findRule(".chat-view-header-scope-toggle"); + const headerScopeButtonRule = findRule(".chat-view-header-scope-toggle .chat-sidebar-scope-btn"); + const headerActiveScopeRule = findRule(".chat-view-header-scope-toggle .chat-sidebar-scope-btn--active"); + + expect(headerScopeRule).toContain("border: 1px solid var(--border)"); + expect(headerScopeRule).toContain("height: var(--view-header-content-row, 28px)"); + expect(headerScopeButtonRule).toContain("border: 1px solid transparent"); + expect(headerScopeButtonRule).toContain("height: 100%"); + expect(headerActiveScopeRule).toContain("border-color: var(--todo)"); + }); + + it("collapses header Direct/Rooms labels to icons at very narrow widths", async () => { + expect(css).toMatch(/@media\s*\(max-width:\s*460px\)[\s\S]*?\.chat-view-header-scope-toggle\s*\{[^}]*width:\s*72px/); + expect(css).toMatch(/@media\s*\(max-width:\s*460px\)[\s\S]*?\.chat-view-header-scope-toggle \.chat-sidebar-scope-btn span\s*\{[^}]*clip:\s*rect\(0 0 0 0\)/); + }); + + it("keeps active chat-row background without the removed left edge or offset", async () => { + const activeSessionRule = findRule(".chat-session-item--active"); + + expect(activeSessionRule).toContain("background: color-mix(in srgb, var(--todo) 12%, transparent)"); + expect(activeSessionRule).not.toContain("border-left"); + expect(activeSessionRule).not.toContain("padding-left: calc(var(--space-md) - (var(--btn-border-width) * 3))"); + }); + + it("does not reintroduce either removed highlight in mobile rules", async () => { + expect(mobileRuleContains(".chat-sidebar-scope-btn--active", /box-shadow\s*:\s*inset/)).toBe(false); + expect(mobileRuleContains(".chat-session-item--active", /border-left\s*:/)).toBe(false); + expect(mobileRuleContains(".chat-session-item--active", /padding-left\s*:\s*calc\(var\(--space-md\)\s*-\s*\(var\(--btn-border-width\)\s*\*\s*3\)\)/)).toBe(false); + }); +}); + +describe("FN-3911 chat session list layout", () => { + const css = loadAllAppCss(); + + it("reserves right padding on title and preview rows so text clears the delete button", async () => { + const titleMatch = css.match(/\.chat-session-title\s*\{([^}]*)\}/); + const previewMatch = css.match(/\.chat-session-preview\s*\{([^}]*)\}/); + expect(titleMatch).toBeTruthy(); + expect(previewMatch).toBeTruthy(); + expect(titleMatch?.[1]).toMatch(/padding-right:\s*calc\(var\(--space-md\)\s*\*\s*3\)/); + expect(previewMatch?.[1]).toMatch(/padding-right:\s*calc\(var\(--space-md\)\s*\*\s*3\)/); + }); + + it("FN-4385: keeps mobile title/preview clearance matched to compact delete button", async () => { + expect(css).toMatch( + /@media\s*\(max-width:\s*768px\)[\s\S]*?\.chat-session-title,\s*\.chat-session-preview\s*\{\s*padding-right:\s*calc\(var\(--space-md\)\s*\*\s*3\);\s*\}/, + ); + }); +}); + +describe("Chat Session Delete Button CSS", () => { + const css = loadAllAppCss(); + + it(".chat-session-delete-btn exists with opacity: 0", async () => { + const match = css.match(/\.chat-session-delete-btn\s*\{([^}]*)\}/); + expect(match).toBeTruthy(); + expect(match![1]).toContain("opacity: 0"); + }); + + it(".chat-session-item:hover .chat-session-delete-btn has opacity: 1", async () => { + const match = css.match(/\.chat-session-item:hover\s*\.chat-session-delete-btn\s*\{([^}]*)\}/); + expect(match).toBeTruthy(); + expect(match![1]).toContain("opacity: 1"); + }); + + it("FN-4352: mobile delete button stays visible without min-size inflation", async () => { + const mobileRegex = /@media[^{]*\(max-width:\s*768px\)[^{]*\{([\s\S]*?)\n\}/g; + let match; + let deleteRule = ""; + while ((match = mobileRegex.exec(css)) !== null) { + const mediaContent = match[1]; + if (mediaContent.includes(".chat-session-delete-btn")) { + deleteRule = mediaContent.match(/\.chat-session-delete-btn\s*\{([^}]*)\}/)?.[1] ?? ""; + if (deleteRule) break; + } + } + + expect(deleteRule).toContain("opacity: 1"); + expect(deleteRule).not.toContain("min-width:"); + expect(deleteRule).not.toContain("min-height:"); + }); +}); + +describe("ChatView CSS — mobile thread switcher", () => { + const css = loadAllAppCss(); + + it("includes mobile session switcher trigger and dropdown tokenized contracts", async () => { + const triggerMatch = css.match(/\.chat-mobile-session-trigger\s*\{([^}]*)\}/); + const triggerIconMatch = css.match(/\.chat-mobile-session-trigger\s*>\s*svg\s*\{([^}]*)\}/); + const dropdownMatch = css.match(/\.chat-mobile-session-dropdown\s*\{([^}]*)\}/); + const optionMatch = css.match(/\.chat-mobile-session-option\s*\{([^}]*)\}/); + const optionTitleMatch = css.match(/\.chat-mobile-session-option-title\s*\{([^}]*)\}/); + expect(triggerMatch).toBeTruthy(); + expect(triggerIconMatch).toBeTruthy(); + expect(dropdownMatch).toBeTruthy(); + expect(optionMatch).toBeTruthy(); + expect(optionTitleMatch).toBeTruthy(); + expect(triggerMatch?.[1]).toContain("min-height: calc(var(--space-lg) * 2 + var(--space-xs))"); + expect(triggerMatch?.[1]).toContain("min-width: 0"); + expect(triggerMatch?.[1]).toContain("padding: var(--space-xs) var(--space-sm)"); + expect(triggerMatch?.[1]).toContain("font: inherit"); + expect(triggerMatch?.[1]).toContain("line-height: normal"); + expect(triggerMatch?.[1]).toContain("text-align: left"); + expect(triggerIconMatch?.[1]).toContain("width: var(--icon-size-md)"); + expect(triggerIconMatch?.[1]).toContain("height: var(--icon-size-md)"); + expect(dropdownMatch?.[1]).toContain("background: var(--surface)"); + expect(dropdownMatch?.[1]).toContain("border: 1px solid var(--border)"); + expect(optionMatch?.[1]).toContain("min-height: calc(var(--space-lg) * 2.25)"); + expect(optionMatch?.[1]).toContain("align-items: flex-start"); + expect(optionMatch?.[1]).toContain("line-height: normal"); + expect(optionTitleMatch?.[1]).toContain("display: block"); + expect(optionTitleMatch?.[1]).toContain("line-height: normal"); + expect(optionTitleMatch?.[1]).toContain("white-space: normal"); + expect(optionTitleMatch?.[1]).toContain("overflow-wrap: anywhere"); + }); + + it("keeps mobile override for header identity overflow visible so dropdown can render", async () => { + expect(css).toMatch(/@media\s*\(max-width:\s*768px\)[\s\S]*?\.chat-thread-header-identity\s*\{[^}]*overflow:\s*visible;/); + }); +}); + +describe("ChatView CSS — nested flexbox scrolling fix", () => { + const css = loadAllAppCss(); + + it(".chat-session-list has min-height: 0 for proper vertical scrolling", async () => { + const match = css.match(/\.chat-session-list\s*\{([^}]*)\}/); + expect(match).toBeTruthy(); + expect(match![1]).toContain("min-height: 0"); + }); + + it(".chat-thread has min-height: 0 for proper vertical scrolling", async () => { + const match = css.match(/\.chat-thread\s*\{([^}]*)\}/); + expect(match).toBeTruthy(); + expect(match![1]).toContain("min-height: 0"); + }); + + it(".chat-messages has min-height: 0 for proper vertical scrolling", async () => { + const match = css.match(/\.chat-messages\s*\{([^}]*)\}/); + expect(match).toBeTruthy(); + expect(match![1]).toContain("min-height: 0"); + }); +}); + diff --git a/packages/dashboard/app/components/__tests__/ChatView.core-interactions.test.tsx b/packages/dashboard/app/components/__tests__/ChatView.core-interactions.test.tsx new file mode 100644 index 0000000000..5098f69e4f --- /dev/null +++ b/packages/dashboard/app/components/__tests__/ChatView.core-interactions.test.tsx @@ -0,0 +1,1261 @@ +/* +FNXC:DashboardTests 2026-06-25-17:44: +ChatView suite split 4/5 (core interactions) extracts the attachments, mentions, slash-skill, and streaming-state blocks from ChatView.core.test.tsx so each focused sibling stays under the line-count guard without dropping coverage. Shares ChatView.test-harness; vi.mock factories stay inline and self-contained per the harness TDZ warning. +*/ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { act, fireEvent, screen, waitFor, within } from "@testing-library/react"; +import { userEvent } from "@testing-library/user-event"; +import { useState } from "react"; +import { ChatView } from "../ChatView"; +import type { DiscoveredSkill } from "@fusion/dashboard"; +import type { UseChatReturn, ChatSessionInfo } from "../../hooks/useChat"; +import { loadAllAppCss } from "../../test/cssFixture"; +import { FileBrowserProvider } from "../../context/FileBrowserContext"; +import { SWR_CACHE_KEYS, writeCache } from "../../utils/swrCache"; +import { + renderWithAct, + setupMockChat, + setupMockRooms, + mockViewportMode, + activeSessionFixture, + createMockSkill, + defaultChatState, + defaultModelsResponse, + mockUseChat, + mockFetchModels, + mockFetchDiscoveredSkills, + mockCreateObjectURL, + mockRevokeObjectURL, + mockClipboardWriteText, + installChatViewEnv, +} from "./ChatView.test-harness"; + +// Mock the hooks +vi.mock("../../hooks/useChat"); +vi.mock("../../hooks/useChatRooms"); +vi.mock("../../hooks/useNavigationHistory", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useNavigationHistoryContext: () => ({ pushNav: vi.fn(), replaceCurrent: vi.fn() }), + }; +}); + +// Mock lucide-react icons - spread actual module and override specific icons +vi.mock("lucide-react", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + MessageSquare: ({ "data-testid": testId, ...props }: any) => ( + + ), + Send: ({ "data-testid": testId, ...props }: any) => , + Plus: ({ "data-testid": testId, ...props }: any) => , + Search: ({ "data-testid": testId, ...props }: any) => , + Trash2: ({ "data-testid": testId, ...props }: any) => , + Archive: ({ "data-testid": testId, ...props }: any) => , + Pencil: ({ "data-testid": testId, ...props }: any) => , + ChevronLeft: ({ "data-testid": testId, ...props }: any) => , + Bot: ({ "data-testid": testId, ...props }: any) => , + Square: ({ "data-testid": testId, ...props }: any) => , + Eye: ({ "data-testid": testId, ...props }: any) => , + EyeOff: ({ "data-testid": testId, ...props }: any) => , + Paperclip: ({ "data-testid": testId, ...props }: any) => , + File: ({ "data-testid": testId, ...props }: any) => , + Copy: ({ "data-testid": testId, ...props }: any) => , + Check: ({ "data-testid": testId, ...props }: any) => , + }; +}); + +// Mock CustomModelDropdown - no longer used but kept for other tests +vi.mock("../CustomModelDropdown", () => ({ + CustomModelDropdown: ({ + value, + onChange, + label, + }: { + value: string; + onChange: (value: string) => void; + label: string; + }) => ( + + ), +})); + +// Mock fetchAgents for new chat dialog +vi.mock("../../api", () => ({ + fetchModels: vi.fn().mockResolvedValue({ + models: [ + { provider: "anthropic", id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5", reasoning: true, contextWindow: 200000 }, + { provider: "openai", id: "gpt-4o", name: "GPT-4o", reasoning: false, contextWindow: 128000 }, + ], + favoriteProviders: [], + favoriteModels: [], + defaultProvider: "anthropic", + defaultModelId: "claude-sonnet-4-5", + }), + fetchAgents: vi.fn().mockResolvedValue([ + { id: "agent-001", name: "Alpha", role: "executor", state: "idle", icon: undefined, createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z", metadata: {} }, + { id: "agent-002", name: "Beta", role: "reviewer", state: "idle", icon: undefined, createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z", metadata: {} }, + ]), + fetchDiscoveredSkills: vi.fn().mockResolvedValue([]), + fetchTasks: vi.fn().mockResolvedValue([]), + searchFiles: vi.fn().mockResolvedValue({ files: [] }), +})); + +installChatViewEnv(); + + +describe("ChatView core interactions", () => { + describe("attachments", () => { + it("clicking paperclip triggers hidden file input", async () => { + setupMockChat({ activeSession: activeSessionFixture, messages: [] }); + await renderWithAct(); + + const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement; + const clickSpy = vi.spyOn(fileInput, "click"); + + await userEvent.click(screen.getByTestId("chat-attach-btn")); + expect(clickSpy).toHaveBeenCalled(); + }); + + it("allows attaching an image and sends with attachments only", async () => { + const sendMessage = vi.fn(); + setupMockChat({ activeSession: activeSessionFixture, messages: [], sendMessage }); + await renderWithAct(); + + const attachButton = screen.getByTestId("chat-attach-btn"); + expect(attachButton).toBeInTheDocument(); + + const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement; + const imageFile = new File(["image"], "shot.png", { type: "image/png" }); + fireEvent.change(fileInput, { target: { files: [imageFile] } }); + + expect(await screen.findByTestId("chat-attachment-previews")).toBeInTheDocument(); + const sendButton = screen.getByTestId("chat-send-btn"); + expect(sendButton).not.toBeDisabled(); + + await userEvent.click(sendButton); + expect(sendMessage).toHaveBeenCalledWith("", [imageFile]); + expect(screen.queryByTestId("chat-attachment-previews")).not.toBeInTheDocument(); + }); + + it("accepts non-image files and renders filename preview", async () => { + setupMockChat({ activeSession: activeSessionFixture, messages: [] }); + await renderWithAct(); + + const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement; + const textFile = new File(["hello"], "note.txt", { type: "text/plain" }); + fireEvent.change(fileInput, { target: { files: [textFile] } }); + + expect(await screen.findByText("note.txt")).toBeInTheDocument(); + expect(mockCreateObjectURL).not.toHaveBeenCalled(); + }); + + it("adds image attachments from paste events", async () => { + setupMockChat({ activeSession: activeSessionFixture, messages: [] }); + await renderWithAct(); + + const textarea = screen.getByTestId("chat-input"); + const imageFile = new File(["image"], "paste.png", { type: "image/png" }); + fireEvent.paste(textarea, { clipboardData: { files: [imageFile] } }); + + expect(await screen.findByTestId("chat-attachment-previews")).toBeInTheDocument(); + }); + + it("adds attachments from drag-and-drop", async () => { + setupMockChat({ activeSession: activeSessionFixture, messages: [] }); + await renderWithAct(); + + const wrapper = document.querySelector(".chat-input-wrapper") as HTMLElement; + const textFile = new File(["log"], "drop.log", { type: "text/x-log" }); + fireEvent.drop(wrapper, { dataTransfer: { files: [textFile] } }); + + expect(await screen.findByText("drop.log")).toBeInTheDocument(); + }); + + it("removes pending attachments and revokes preview urls", async () => { + setupMockChat({ activeSession: activeSessionFixture, messages: [] }); + await renderWithAct(); + + const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement; + const imageFile = new File(["image"], "shot.png", { type: "image/png" }); + fireEvent.change(fileInput, { target: { files: [imageFile] } }); + + const removeButton = await screen.findByTestId("chat-attachment-remove-0"); + await userEvent.click(removeButton); + + expect(mockRevokeObjectURL).toHaveBeenCalledWith("blob:shot.png"); + expect(screen.queryByTestId("chat-attachment-previews")).not.toBeInTheDocument(); + }); + + it("renders message attachments inline as actionable links", async () => { + setupMockChat({ + activeSession: activeSessionFixture, + messages: [ + { + id: "msg-attach", + sessionId: "session-001", + role: "assistant", + content: "Attached files", + createdAt: "2026-04-08T00:00:00.000Z", + attachments: [ + { + id: "att-1", + filename: "img-1.png", + originalName: "capture.png", + mimeType: "image/png", + size: 10, + createdAt: "2026-04-08T00:00:00.000Z", + }, + { + id: "att-2", + filename: "note.txt", + originalName: "note.txt", + mimeType: "text/plain", + size: 20, + createdAt: "2026-04-08T00:00:00.000Z", + }, + ], + }, + ], + }); + + await renderWithAct(); + + const links = screen.getAllByTestId("chat-message-attachment"); + expect(links).toHaveLength(2); + expect(links[0]).toHaveAttribute("href", "/api/chat/sessions/session-001/attachments/img-1.png"); + expect(links[0]).toHaveAttribute("target", "_blank"); + expect(links[1]).toHaveAttribute("href", "/api/chat/sessions/session-001/attachments/note.txt"); + expect(screen.getByText("note.txt")).toBeInTheDocument(); + }); + }); + + describe("agent mentions", () => { + it("shows mention popup when @ is typed", async () => { + setupMockChat({ activeSession: activeSessionFixture, messages: [] }); + + await renderWithAct(); + + const textarea = screen.getByTestId("chat-input"); + await userEvent.type(textarea, "@"); + + expect(await screen.findByTestId("agent-mention-popup")).toBeInTheDocument(); + }); + + it("filters mention popup by text after @", async () => { + setupMockChat({ activeSession: activeSessionFixture, messages: [] }); + + await renderWithAct(); + + const textarea = screen.getByTestId("chat-input"); + await userEvent.type(textarea, "@be"); + + expect(await screen.findByTestId("agent-mention-item-agent-002")).toBeInTheDocument(); + expect(screen.queryByTestId("agent-mention-item-agent-001")).not.toBeInTheDocument(); + }); + + it("hides mention popup on Escape", async () => { + setupMockChat({ activeSession: activeSessionFixture, messages: [] }); + + await renderWithAct(); + + const textarea = screen.getByTestId("chat-input"); + await userEvent.type(textarea, "@"); + expect(await screen.findByTestId("agent-mention-popup")).toBeInTheDocument(); + + await userEvent.keyboard("{Escape}"); + expect(screen.queryByTestId("agent-mention-popup")).not.toBeInTheDocument(); + }); + + it("inserts mention text when selecting an agent", async () => { + setupMockChat({ activeSession: activeSessionFixture, messages: [] }); + + await renderWithAct(); + + const textarea = screen.getByTestId("chat-input") as HTMLTextAreaElement; + await userEvent.type(textarea, "@al"); + + const mentionItem = await screen.findByTestId("agent-mention-item-agent-001"); + await userEvent.click(mentionItem); + + expect(textarea.value).toBe("@Alpha "); + expect(screen.queryByTestId("agent-mention-popup")).not.toBeInTheDocument(); + }); + + it("uses room member ordering in popup and marks non-member mention chips in room messages", async () => { + setupMockChat({ activeSession: activeSessionFixture, messages: [] }); + setupMockRooms({ + activeRoom: { + id: "room-001", + slug: "engineering", + name: "engineering", + createdBy: "agent-001", + status: "active", + createdAt: "2026-04-08T00:00:00.000Z", + updatedAt: "2026-04-08T00:00:00.000Z", + }, + activeRoomMembers: [ + { roomId: "room-001", agentId: "agent-001", role: "member", addedAt: "2026-04-08T00:00:00.000Z" }, + ], + messages: [ + { + id: "room-msg-1", + roomId: "room-001", + role: "user", + content: "Ping @Alpha and @Beta", + senderAgentId: "agent-001", + metadata: null, + attachments: [], + mentions: ["agent-001", "agent-002"], + createdAt: "2026-04-08T00:00:00.000Z", + }, + ], + }); + + const allCss = await loadAllAppCss(); + const style = document.createElement("style"); + style.textContent = allCss; + document.head.appendChild(style); + + await renderWithAct(); + + const user = userEvent.setup({ delay: null }); + await user.click(screen.getByTestId("chat-sidebar-scope-rooms")); + const textarea = screen.getByTestId("chat-input"); + await user.type(textarea, "@"); + + expect(screen.getByTestId("agent-mention-members-header")).toBeInTheDocument(); + expect(screen.queryByTestId("agent-mention-others-header")).not.toBeInTheDocument(); + + const bubble = screen.getByText("Ping", { exact: false }).closest(".chat-message--user"); + expect(bubble).toBeTruthy(); + + const memberChip = screen.getByText("@Alpha", { selector: ".chat-mention-chip" }); + const nonMemberChip = screen.getByText("@Beta", { selector: ".chat-mention-chip--non-member" }); + expect(nonMemberChip).toHaveAttribute("title", "Not a member of engineering"); + + // FN-4520: member mention chip text must not visually collapse into sent-bubble background. + expect(getComputedStyle(memberChip).color).not.toBe(getComputedStyle(bubble as Element).backgroundColor); + // FN-4520: non-member mention chip text must remain legible inside sent bubbles. + expect(getComputedStyle(nonMemberChip).color).not.toBe(getComputedStyle(bubble as Element).backgroundColor); + }); + + it("renders assistant mentions as plain text in markdown mode", async () => { + setupMockChat({ + activeSession: activeSessionFixture, + messages: [ + { + id: "msg-001", + sessionId: "session-001", + role: "assistant", + content: "Talk to @Alpha and @Unknown next.", + createdAt: "2026-04-08T00:00:00.000Z", + }, + ], + }); + + await renderWithAct(); + + await waitFor(() => { + expect(screen.getByText(/Talk to @Alpha and @Unknown next\./)).toBeInTheDocument(); + }); + expect(screen.queryByText("@Alpha", { selector: ".chat-mention-chip" })).toBeNull(); + expect(screen.queryByText("@Unknown", { selector: ".chat-mention-chip" })).toBeNull(); + }); + }); + + describe("slash skill autocomplete", () => { + it("shows the skill menu when typing slash in the chat input", async () => { + mockFetchDiscoveredSkills.mockResolvedValueOnce([ + createMockSkill({ id: "skill-refactor", name: "refactor/code", relativePath: "skills/refactor/code.md" }), + ]); + setupMockChat({ activeSession: activeSessionFixture, messages: [] }); + + await renderWithAct(); + + const textarea = screen.getByTestId("chat-input"); + await userEvent.type(textarea, "/"); + + expect(await screen.findByTestId("chat-skill-menu")).toBeInTheDocument(); + expect(screen.getByText("refactor/code")).toBeInTheDocument(); + }); + + it("filters discovered skills from slash input", async () => { + mockFetchDiscoveredSkills.mockResolvedValueOnce([ + createMockSkill({ id: "skill-review", name: "review/pr", relativePath: "skills/review/pr.md" }), + createMockSkill({ id: "skill-deploy", name: "deploy/app", relativePath: "skills/deploy/app.md" }), + ]); + setupMockChat({ activeSession: activeSessionFixture, messages: [] }); + + await renderWithAct(); + + const textarea = screen.getByTestId("chat-input"); + await userEvent.type(textarea, "/re"); + + expect(await screen.findByText("review/pr")).toBeInTheDocument(); + expect(screen.queryByText("deploy/app")).not.toBeInTheDocument(); + }); + + it("inserts /skill command when clicking a menu item", async () => { + mockFetchDiscoveredSkills.mockResolvedValueOnce([ + createMockSkill({ id: "skill-review", name: "review/pr", relativePath: "skills/review/pr.md" }), + ]); + setupMockChat({ activeSession: activeSessionFixture, messages: [] }); + + await renderWithAct(); + + const textarea = screen.getByTestId("chat-input"); + await userEvent.type(textarea, "/re"); + + await userEvent.click(await screen.findByRole("option", { name: /review\/pr/i })); + + expect(textarea).toHaveValue("/skill:review/pr "); + expect(screen.queryByTestId("chat-skill-menu")).not.toBeInTheDocument(); + }); + + it("supports arrow navigation with wrapping and Enter selection", async () => { + mockFetchDiscoveredSkills.mockResolvedValueOnce([ + createMockSkill({ id: "skill-alpha", name: "alpha", relativePath: "skills/alpha.md" }), + createMockSkill({ id: "skill-beta", name: "beta", relativePath: "skills/beta.md" }), + createMockSkill({ id: "skill-gamma", name: "gamma", relativePath: "skills/gamma.md" }), + ]); + setupMockChat({ activeSession: activeSessionFixture, messages: [] }); + await renderWithAct(); + + const textarea = screen.getByTestId("chat-input"); + fireEvent.change(textarea, { target: { value: "/" } }); + await screen.findByRole("option", { name: /alpha/i }); + + // Wrap to bottom from the first item. + fireEvent.keyDown(textarea, { key: "ArrowUp" }); + await waitFor(() => + expect(screen.getByRole("option", { name: /gamma/i })).toHaveClass( + "chat-skill-menu-item--highlighted", + ), + ); + + fireEvent.keyDown(textarea, { key: "Enter" }); + await waitFor(() => expect(textarea).toHaveValue("/skill:gamma ")); + }); + + it("keeps the keyboard highlight when revalidation re-delivers an identical skill list", async () => { + // Regression: the SWR skills cache re-delivers content-identical lists + // with fresh array identities (cache reads re-parse; revalidation + // notifies a new array). The highlight reset must key on skill ids, not + // array identity, or a revalidation landing mid-navigation wipes the + // user's keyboard position (the source of this test family's CI flakes). + const skillsList = [ + createMockSkill({ id: "skill-alpha", name: "alpha", relativePath: "skills/alpha.md" }), + createMockSkill({ id: "skill-beta", name: "beta", relativePath: "skills/beta.md" }), + createMockSkill({ id: "skill-gamma", name: "gamma", relativePath: "skills/gamma.md" }), + ]; + // Seed the cache so the menu renders before the (deferred) revalidation fetch. + writeCache(`${SWR_CACHE_KEYS.DISCOVERED_SKILLS_PREFIX}proj-123`, skillsList); + let resolveFetch!: (skills: DiscoveredSkill[]) => void; + mockFetchDiscoveredSkills.mockImplementationOnce( + () => new Promise((resolve) => { resolveFetch = resolve; }), + ); + setupMockChat({ activeSession: activeSessionFixture, messages: [] }); + await renderWithAct(); + + const textarea = screen.getByTestId("chat-input"); + fireEvent.change(textarea, { target: { value: "/" } }); + await screen.findByRole("option", { name: /alpha/i }); + + fireEvent.keyDown(textarea, { key: "ArrowUp" }); + await waitFor(() => + expect(screen.getByRole("option", { name: /gamma/i })).toHaveClass( + "chat-skill-menu-item--highlighted", + ), + ); + + // Revalidation lands mid-navigation: identical content, new identity. + await act(async () => { + resolveFetch(JSON.parse(JSON.stringify(skillsList)) as DiscoveredSkill[]); + }); + + expect(screen.getByRole("option", { name: /gamma/i })).toHaveClass( + "chat-skill-menu-item--highlighted", + ); + }); + + it("supports selecting highlighted skill with Tab", async () => { + mockFetchDiscoveredSkills.mockResolvedValueOnce([ + createMockSkill({ id: "skill-alpha", name: "alpha", relativePath: "skills/alpha.md" }), + createMockSkill({ id: "skill-beta", name: "beta", relativePath: "skills/beta.md" }), + ]); + setupMockChat({ activeSession: activeSessionFixture, messages: [] }); + + await renderWithAct(); + + const textarea = screen.getByTestId("chat-input"); + await userEvent.type(textarea, "/"); + await screen.findByRole("option", { name: /alpha/i }); + + await userEvent.keyboard("{ArrowDown}"); + expect(screen.getByRole("option", { name: /beta/i })).toHaveClass( + "chat-skill-menu-item--highlighted", + ); + + await userEvent.keyboard("{Tab}"); + expect(textarea).toHaveValue("/skill:beta "); + }); + + it("closes the menu when pressing Escape", async () => { + mockFetchDiscoveredSkills.mockResolvedValueOnce([ + createMockSkill({ id: "skill-review", name: "review/pr", relativePath: "skills/review/pr.md" }), + ]); + setupMockChat({ activeSession: activeSessionFixture, messages: [] }); + + await renderWithAct(); + + const textarea = screen.getByTestId("chat-input"); + await userEvent.type(textarea, "/"); + expect(await screen.findByTestId("chat-skill-menu")).toBeInTheDocument(); + + await userEvent.keyboard("{Escape}"); + expect(screen.queryByTestId("chat-skill-menu")).not.toBeInTheDocument(); + }); + + it("closes the menu when slash trigger pattern no longer matches", async () => { + mockFetchDiscoveredSkills.mockResolvedValueOnce([ + createMockSkill({ id: "skill-review", name: "review/pr", relativePath: "skills/review/pr.md" }), + ]); + setupMockChat({ activeSession: activeSessionFixture, messages: [] }); + + await renderWithAct(); + + const textarea = screen.getByTestId("chat-input"); + await userEvent.type(textarea, "/re"); + expect(await screen.findByTestId("chat-skill-menu")).toBeInTheDocument(); + + await userEvent.type(textarea, " "); + expect(screen.queryByTestId("chat-skill-menu")).not.toBeInTheDocument(); + }); + + it("shows loading indicator while discovered skills are still loading", async () => { + let resolveSkills: ((skills: DiscoveredSkill[]) => void) | undefined; + mockFetchDiscoveredSkills.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveSkills = resolve; + }), + ); + setupMockChat({ activeSession: activeSessionFixture, messages: [] }); + + await renderWithAct(); + + const textarea = screen.getByTestId("chat-input"); + await userEvent.type(textarea, "/"); + + expect(await screen.findByText("Loading skills…")).toBeInTheDocument(); + + resolveSkills?.([createMockSkill({ id: "skill-review", name: "review/pr", relativePath: "skills/review/pr.md" })]); + await waitFor(() => { + expect(screen.getByText("review/pr")).toBeInTheDocument(); + }); + }); + + it("does not crash when discovered skills fail to load", async () => { + mockFetchDiscoveredSkills.mockRejectedValueOnce(new Error("skills endpoint unavailable")); + setupMockChat({ activeSession: activeSessionFixture, messages: [] }); + + await renderWithAct(); + + const textarea = screen.getByTestId("chat-input"); + await userEvent.type(textarea, "/"); + + expect(await screen.findByText("No skills available")).toBeInTheDocument(); + }); + }); + + it("disables send button when input is empty", async () => { + setupMockChat({ + activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }, + messages: [], + }); + + await renderWithAct(); + + const sendButton = screen.getByTestId("chat-send-btn"); + expect(sendButton).toBeDisabled(); + }); + + it("renders stop button when streaming", async () => { + setupMockChat({ + activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }, + messages: [], + isStreaming: true, + }); + + await renderWithAct(); + + expect(screen.getByTestId("chat-stop-btn")).toBeInTheDocument(); + expect(screen.queryByTestId("chat-send-btn")).not.toBeInTheDocument(); + }); + + it("clicking stop button calls stopStreaming", async () => { + const stopStreaming = vi.fn(); + setupMockChat({ + activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }, + messages: [], + isStreaming: true, + stopStreaming, + }); + + await renderWithAct(); + + await userEvent.click(screen.getByTestId("chat-stop-btn")); + expect(stopStreaming).toHaveBeenCalledTimes(1); + }); + + it("FN-6576 does not let a send gesture trailing click press the swapped stop button", async () => { + const viewportSpy = mockViewportMode("mobile"); + const sendMessage = vi.fn(); + const stopStreaming = vi.fn(); + mockUseChat.mockImplementation(() => { + const [isStreaming, setIsStreaming] = useState(false); + return { + ...defaultChatState, + activeSession: activeSessionFixture, + sessions: [activeSessionFixture], + filteredSessions: [activeSessionFixture], + messages: [], + isStreaming, + sendMessage: (message, files) => { + sendMessage(message, files); + setIsStreaming(true); + }, + stopStreaming, + } satisfies UseChatReturn; + }); + + await renderWithAct(); + + fireEvent.change(screen.getByTestId("chat-input"), { target: { value: "Start streaming" } }); + await act(async () => { + fireEvent.pointerDown(screen.getByTestId("chat-send-btn"), { pointerType: "touch" }); + fireEvent.touchStart(screen.getByTestId("chat-send-btn")); + }); + expect(sendMessage).toHaveBeenCalledTimes(1); + expect(sendMessage).toHaveBeenCalledWith("Start streaming", []); + + await act(async () => { + fireEvent.click(screen.getByTestId("chat-stop-btn")); + }); + expect(stopStreaming).not.toHaveBeenCalled(); + viewportSpy.mockRestore(); + }); + + it("FN-6576 allows a standalone mobile stop tap exactly once", async () => { + const viewportSpy = mockViewportMode("mobile"); + const stopStreaming = vi.fn(); + setupMockChat({ + activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }, + messages: [], + isStreaming: true, + stopStreaming, + }); + + await renderWithAct(); + + await act(async () => { + fireEvent.pointerDown(screen.getByTestId("chat-stop-btn"), { pointerType: "touch" }); + fireEvent.touchStart(screen.getByTestId("chat-stop-btn")); + fireEvent.click(screen.getByTestId("chat-stop-btn")); + }); + expect(stopStreaming).toHaveBeenCalledTimes(1); + viewportSpy.mockRestore(); + }); + + it("FN-6576 allows a genuine stop tap within the send click-latch window", async () => { + const viewportSpy = mockViewportMode("mobile"); + const sendMessage = vi.fn(); + const stopStreaming = vi.fn(); + mockUseChat.mockImplementation(() => { + const [isStreaming, setIsStreaming] = useState(false); + return { + ...defaultChatState, + activeSession: activeSessionFixture, + sessions: [activeSessionFixture], + filteredSessions: [activeSessionFixture], + messages: [], + isStreaming, + sendMessage: (message, files) => { + sendMessage(message, files); + setIsStreaming(true); + }, + stopStreaming, + } satisfies UseChatReturn; + }); + + await renderWithAct(); + + fireEvent.change(screen.getByTestId("chat-input"), { target: { value: "Start then stop" } }); + await act(async () => { + fireEvent.pointerDown(screen.getByTestId("chat-send-btn"), { pointerType: "touch" }); + }); + expect(sendMessage).toHaveBeenCalledTimes(1); + await act(async () => { + await new Promise((resolve) => window.setTimeout(resolve, 0)); + }); + + await act(async () => { + fireEvent.pointerDown(screen.getByTestId("chat-stop-btn"), { pointerType: "touch" }); + fireEvent.touchStart(screen.getByTestId("chat-stop-btn")); + fireEvent.click(screen.getByTestId("chat-stop-btn")); + }); + expect(stopStreaming).toHaveBeenCalledTimes(1); + viewportSpy.mockRestore(); + }); + + it("renders send button when not streaming", async () => { + setupMockChat({ + activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }, + messages: [], + isStreaming: false, + }); + + await renderWithAct(); + + expect(screen.getByTestId("chat-send-btn")).toBeInTheDocument(); + }); + + it("renders pending message indicator and dismisses it", async () => { + const clearPendingMessage = vi.fn(); + setupMockChat({ + activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }, + messages: [], + pendingMessage: "Queued while streaming", + clearPendingMessage, + }); + + await renderWithAct(); + + expect(screen.getByTestId("chat-pending-indicator")).toHaveTextContent("Queued: Queued while streaming"); + + await userEvent.click(screen.getByTestId("chat-pending-dismiss")); + expect(clearPendingMessage).toHaveBeenCalledTimes(1); + }); + + it("textarea is enabled during streaming", async () => { + setupMockChat({ + activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }, + messages: [ + { id: "msg-001", sessionId: "session-001", role: "user", content: "Hello", createdAt: "2026-04-08T00:00:00.000Z" }, + ], + isStreaming: true, + streamingText: "Thinking...", + }); + + await renderWithAct(); + + const textarea = screen.getByTestId("chat-input"); + expect(textarea).not.toBeDisabled(); + }); + + it("user can type while streaming", async () => { + setupMockChat({ + activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }, + messages: [ + { id: "msg-001", sessionId: "session-001", role: "user", content: "Hello", createdAt: "2026-04-08T00:00:00.000Z" }, + ], + isStreaming: true, + streamingText: "Thinking...", + }); + + await renderWithAct(); + + const textarea = screen.getByTestId("chat-input"); + + // User should be able to type in the textarea while streaming + fireEvent.change(textarea, { target: { value: "Second message" } }); + expect((textarea as HTMLTextAreaElement).value).toBe("Second message"); + }); + + it("shows streaming indicator when isStreaming is true", async () => { + setupMockChat({ + activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }, + messages: [ + { id: "msg-001", sessionId: "session-001", role: "user", content: "Hello", createdAt: "2026-04-08T00:00:00.000Z" }, + ], + isStreaming: true, + streamingText: "Typing...", + }); + + await renderWithAct(); + + // Streaming message should show + const streamingMessage = document.querySelector(".chat-message--streaming") as HTMLElement | null; + expect(streamingMessage).toBeInTheDocument(); + expect(streamingMessage?.textContent).toContain("Typing"); + }); + + it("shows thinking blocks collapsed by default", async () => { + setupMockChat({ + activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }, + messages: [ + { id: "msg-001", sessionId: "session-001", role: "assistant", content: "Here's my response", thinkingOutput: "I need to think about this...", createdAt: "2026-04-08T00:00:00.000Z" }, + ], + }); + + await renderWithAct(); + + const message = screen.getByTestId("chat-message-msg-001"); + const details = message.querySelector("details"); + expect(details).toBeInTheDocument(); + expect(details).toHaveProperty("open", false); + }); + + describe("streaming states", () => { + it("keeps mobile thread visible when active session metadata refreshes during streaming", async () => { + const mediaQuerySpy = mockViewportMode("mobile"); + const streamingState: UseChatReturn = { + ...defaultChatState, + sessions: [{ ...activeSessionFixture }], + filteredSessions: [{ ...activeSessionFixture }], + activeSession: { ...activeSessionFixture }, + messages: [], + isStreaming: true, + streamingText: "", + streamingThinking: "", + }; + const refreshedStreamingState: UseChatReturn = { + ...streamingState, + sessions: [{ ...activeSessionFixture, updatedAt: "2026-04-08T00:05:00.000Z" }], + filteredSessions: [{ ...activeSessionFixture, updatedAt: "2026-04-08T00:05:00.000Z" }], + activeSession: null, + }; + + mockUseChat + .mockReturnValueOnce(streamingState) + .mockReturnValue(refreshedStreamingState); + + const { rerender } = await renderWithAct(); + + expect(document.querySelector(".chat-message--streaming")?.textContent).toContain("Working"); + rerender(); + + expect(document.querySelector(".chat-message--streaming")?.textContent).toContain("Working"); + expect(screen.queryByText("Start a new conversation")).not.toBeInTheDocument(); + expect(screen.queryByText("No messages yet. Start the conversation!")).not.toBeInTheDocument(); + expect(screen.getByTestId("chat-back-btn")).toBeInTheDocument(); + + void mediaQuerySpy; + }); + + it("keeps the streaming indicator visible while message history is still loading", async () => { + setupMockChat({ + activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }, + messages: [], + messagesLoading: true, + isStreaming: true, + streamingText: "", + streamingThinking: "", + }); + + await renderWithAct(); + + const streamingMessage = document.querySelector(".chat-message--streaming") as HTMLElement | null; + expect(streamingMessage).toBeInTheDocument(); + expect(streamingMessage?.textContent).toContain("Working"); + expect(screen.queryByText("Loading messages...")).not.toBeInTheDocument(); + }); + + it("shows waiting indicator when streaming starts before text arrives", async () => { + setupMockChat({ + activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }, + messages: [ + { id: "msg-001", sessionId: "session-001", role: "user", content: "Hello", createdAt: "2026-04-08T00:00:00.000Z" }, + ], + isStreaming: true, + streamingText: "", + streamingThinking: "", + }); + + await renderWithAct(); + + // Streaming message should show with "Working..." text + const streamingMessage = document.querySelector(".chat-message--streaming") as HTMLElement | null; + expect(streamingMessage).toBeInTheDocument(); + expect(streamingMessage?.textContent).toContain("Working"); + + // Waiting class should be present + const waitingContent = streamingMessage?.querySelector(".chat-message-content--waiting"); + expect(waitingContent).toBeInTheDocument(); + + // Typing indicator dots should be rendered + const typingIndicator = streamingMessage?.querySelector(".chat-typing-indicator"); + expect(typingIndicator).toBeInTheDocument(); + expect(typingIndicator?.querySelectorAll("span").length).toBe(3); + }); + + it("shows thinking indicator when streaming thinking arrives before text", async () => { + setupMockChat({ + activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }, + messages: [ + { id: "msg-001", sessionId: "session-001", role: "user", content: "Hello", createdAt: "2026-04-08T00:00:00.000Z" }, + ], + isStreaming: true, + streamingText: "", + streamingThinking: "analyzing the request...", + }); + + await renderWithAct(); + + // Streaming message should show with "Thinking..." text + const streamingMessage = document.querySelector(".chat-message--streaming") as HTMLElement | null; + expect(streamingMessage).toBeInTheDocument(); + expect(streamingMessage?.textContent).toContain("Thinking"); + + // Thinking details should be rendered + const thinkingDetails = streamingMessage?.querySelector("details.chat-message-thinking"); + expect(thinkingDetails).toBeInTheDocument(); + expect(thinkingDetails?.querySelector(".chat-message-thinking-content")?.textContent).toContain("analyzing the request"); + + // Typing indicator dots should be rendered + const typingIndicator = streamingMessage?.querySelector(".chat-typing-indicator"); + expect(typingIndicator).toBeInTheDocument(); + }); + }); + + it("filters sessions by search query", async () => { + setupMockChat({ + sessions: [ + { id: "session-001", agentId: "agent-001", status: "active", title: "Frontend work", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }, + { id: "session-002", agentId: "agent-002", status: "active", title: "Backend API", createdAt: "2026-04-07T00:00:00.000Z", updatedAt: "2026-04-07T00:00:00.000Z" }, + ], + filteredSessions: [ + { id: "session-001", agentId: "agent-001", status: "active", title: "Frontend work", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }, + ], + searchQuery: "frontend", + setSearchQuery: vi.fn(), + }); + + await renderWithAct(); + + expect(screen.getByText("Frontend work")).toBeInTheDocument(); + expect(screen.queryByText("Backend API")).not.toBeInTheDocument(); + }); + + it("shows empty state with Start Chat button (no inline agent selector)", async () => { + setupMockChat({ sessions: [], filteredSessions: [] }); + + await renderWithAct(); + + expect(screen.getByText("Start a new conversation")).toBeInTheDocument(); + // Find the New Chat button in the empty state section + const emptyStateText = screen.getByText("Start a new conversation"); + const emptyState = emptyStateText.closest(".chat-empty-state") as HTMLElement | null; + expect(within(emptyState!).getByRole("button", { name: /new chat/i })).toBeInTheDocument(); + // Should NOT have an agent selector in empty state + expect(emptyState?.querySelector("select")).toBeNull(); + }); + + it("shows context menu on right-click", async () => { + setupMockChat({ + sessions: [{ id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }], + filteredSessions: [{ id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }], + }); + + await renderWithAct(); + + const sessionItem = screen.getByTestId("chat-session-session-001"); + + await userEvent.pointer({ target: sessionItem, keys: "[MouseRight]" }); + + expect(screen.getByTestId("chat-context-archive")).toBeInTheDocument(); + expect(screen.getByTestId("chat-context-delete")).toBeInTheDocument(); + }); + + it("calls archiveSession when clicking Archive in context menu", async () => { + const archiveSession = vi.fn(); + setupMockChat({ + sessions: [{ id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }], + filteredSessions: [{ id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }], + archiveSession, + }); + + await renderWithAct(); + + const sessionItem = screen.getByTestId("chat-session-session-001"); + await userEvent.pointer({ target: sessionItem, keys: "[MouseRight]" }); + + await userEvent.click(screen.getByTestId("chat-context-archive")); + + expect(archiveSession).toHaveBeenCalledWith("session-001"); + }); + + it("shows delete confirmation dialog", async () => { + setupMockChat({ + sessions: [{ id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }], + filteredSessions: [{ id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }], + }); + + await renderWithAct(); + + const sessionItem = screen.getByTestId("chat-session-session-001"); + await userEvent.pointer({ target: sessionItem, keys: "[MouseRight]" }); + + await userEvent.click(screen.getByTestId("chat-context-delete")); + + // Dialog should be open + const dialog = document.querySelector(".chat-new-dialog") as HTMLElement | null; + expect(dialog).toBeInTheDocument(); + expect(within(dialog!).getByText("Delete Conversation?")).toBeInTheDocument(); + }); + + it("shows formatted model label for fn agent sessions in sidebar", async () => { + setupMockChat({ + sessions: [{ + id: "session-001", + agentId: "__fn_agent__", + status: "active", + title: "My Chat", + modelProvider: "anthropic", + modelId: "claude-sonnet-4-5", + updatedAt: "2026-04-08T00:00:00.000Z", + createdAt: "2026-04-08T00:00:00.000Z", + }], + filteredSessions: [{ + id: "session-001", + agentId: "__fn_agent__", + status: "active", + title: "My Chat", + modelProvider: "anthropic", + modelId: "claude-sonnet-4-5", + updatedAt: "2026-04-08T00:00:00.000Z", + createdAt: "2026-04-08T00:00:00.000Z", + }], + }); + + await renderWithAct(); + + const sessionItem = screen.getByTestId("chat-session-session-001"); + expect(within(sessionItem).getByText("Claude Sonnet 4.5")).toBeInTheDocument(); + expect(within(sessionItem).queryByText("Fusion")).not.toBeInTheDocument(); + }); + + it("shows Fusion fallback for fn agent sessions in sidebar without model info", async () => { + mockFetchModels.mockResolvedValue({ + models: [], + favoriteProviders: [], + favoriteModels: [], + defaultProvider: null, + defaultModelId: null, + }); + setupMockChat({ + sessions: [{ id: "session-001", agentId: "__fn_agent__", status: "active", title: "My Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }], + filteredSessions: [{ id: "session-001", agentId: "__fn_agent__", status: "active", title: "My Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }], + }); + + await renderWithAct(); + + const sessionItem = screen.getByTestId("chat-session-session-001"); + expect(within(sessionItem).getByText("Fusion")).toBeInTheDocument(); + }); + + it("shows agent ID for non-fn agent sessions in sidebar", async () => { + setupMockChat({ + sessions: [{ id: "session-001", agentId: "my-custom-agent", status: "active", title: "Custom Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }], + filteredSessions: [{ id: "session-001", agentId: "my-custom-agent", status: "active", title: "Custom Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }], + }); + + await renderWithAct(); + + const sessionItem = screen.getByTestId("chat-session-session-001"); + // Should show the agent ID (truncated to 30 chars) + expect(within(sessionItem).getByText("my-custom-agent")).toBeInTheDocument(); + }); + + it("shows formatted model name in thread header title for fn agent sessions", async () => { + setupMockChat({ + activeSession: { + id: "session-001", + agentId: "__fn_agent__", + status: "active", + title: "Test Chat", + modelProvider: "anthropic", + modelId: "claude-sonnet-4-5", + createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z", + }, + messages: [ + { id: "msg-001", sessionId: "session-001", role: "assistant", content: "Hi!", createdAt: "2026-04-08T00:00:00.000Z" }, + ], + }); + + await renderWithAct(); + + const title = document.querySelector(".chat-thread-header-title") as HTMLElement | null; + expect(title).toBeInTheDocument(); + expect(title).toHaveTextContent("Claude Sonnet 4.5"); + expect(title).not.toHaveTextContent("Fusion"); + }); + + it("shows model tag in thread header when non-fn session has model", async () => { + setupMockChat({ + activeSession: { + id: "session-001", + agentId: "agent-001", + status: "active", + title: "Agent Chat", + modelProvider: "anthropic", + modelId: "claude-sonnet-4-5", + createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z", + }, + messages: [ + { id: "msg-001", sessionId: "session-001", role: "user", content: "Hello", createdAt: "2026-04-08T00:00:00.000Z" }, + { id: "msg-002", sessionId: "session-001", role: "assistant", content: "Hi!", createdAt: "2026-04-08T00:01:00.000Z" }, + ], + }); + + await renderWithAct(); + + const headerModelTag = document.querySelector(".chat-thread-header .chat-model-tag") as HTMLElement | null; + expect(headerModelTag).toBeInTheDocument(); + expect(headerModelTag?.textContent).toContain("Claude"); + }); + + it("does not show duplicate model tag in thread header for fn agent sessions", async () => { + setupMockChat({ + activeSession: { + id: "session-001", + agentId: "__fn_agent__", + status: "active", + title: "Test Chat", + modelProvider: "anthropic", + modelId: "claude-sonnet-4-5", + createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z", + }, + messages: [ + { id: "msg-001", sessionId: "session-001", role: "assistant", content: "Hi!", createdAt: "2026-04-08T00:00:00.000Z" }, + ], + }); + + await renderWithAct(); + + const title = document.querySelector(".chat-thread-header-title") as HTMLElement | null; + expect(title).toHaveTextContent("Claude Sonnet 4.5"); + + const headerModelTag = document.querySelector(".chat-thread-header .chat-model-tag") as HTMLElement | null; + expect(headerModelTag).toBeNull(); + }); + + it("keeps provider identity text grouped in header while render toggle stays on the same row", async () => { + setupMockChat({ + activeSession: { + id: "session-001", + agentId: "agent-001", + status: "active", + title: "Agent Chat", + modelProvider: "anthropic", + modelId: "claude-sonnet-4-5", + createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z", + }, + messages: [ + { id: "msg-001", sessionId: "session-001", role: "assistant", content: "Hi!", createdAt: "2026-04-08T00:00:00.000Z" }, + ], + }); + + await renderWithAct(); + + const header = document.querySelector(".chat-thread-header") as HTMLElement | null; + const identity = screen.getByTestId("chat-thread-header-identity"); + const toggle = screen.getByTestId("chat-thread-render-toggle"); + const providerIcon = identity.querySelector(".provider-icon"); + const modelTag = identity.querySelector(".chat-model-tag"); + const newChatButton = screen.getByTestId("chat-new-btn"); + + expect(header).toBeInTheDocument(); + expect(newChatButton.closest(".view-header")).toBeInTheDocument(); + expect(providerIcon).toBeInTheDocument(); + expect(within(identity).getByText("Agent Chat")).toBeInTheDocument(); + expect(modelTag).toBeInTheDocument(); + expect(modelTag).toHaveTextContent("Claude Sonnet 4.5"); + expect(toggle).toBeInTheDocument(); + expect(header?.children[header.children.length - 1]).toBe(toggle); + expect(document.querySelectorAll(".chat-thread-header .chat-model-tag")).toHaveLength(1); + }); + + it("does not show model tag when session has no model", async () => { + setupMockChat({ + activeSession: { + id: "session-001", + agentId: "__fn_agent__", + status: "active", + title: "Test Chat", + createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z", + }, + messages: [ + { id: "msg-001", sessionId: "session-001", role: "user", content: "Hello", createdAt: "2026-04-08T00:00:00.000Z" }, + { id: "msg-002", sessionId: "session-001", role: "assistant", content: "Hi!", createdAt: "2026-04-08T00:01:00.000Z" }, + ], + }); + + await renderWithAct(); + + const modelTag = document.querySelector(".chat-model-tag") as HTMLElement | null; + expect(modelTag).not.toBeInTheDocument(); + }); + + it("does not repeat the model tag in per-message avatars for non-fn sessions", async () => { + // Per-message model tags were intentionally removed — the model is shown + // once in the thread header. The avatar should still render with the + // agent name (no agent identity collapse for real agents) but no model + // tag inside it. + setupMockChat({ + activeSession: { + id: "session-001", + agentId: "agent-001", + status: "active", + title: "Agent Chat", + modelProvider: "openai", + modelId: "gpt-4o", + createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z", + }, + messages: [ + { id: "msg-001", sessionId: "session-001", role: "assistant", content: "Hi!", createdAt: "2026-04-08T00:01:00.000Z" }, + ], + }); + + await renderWithAct(); + + const messageBubble = screen.getByTestId("chat-message-msg-001"); + const avatar = messageBubble.querySelector(".chat-message-avatar") as HTMLElement | null; + expect(avatar).toBeInTheDocument(); + expect(avatar?.querySelector(".chat-model-tag")).toBeNull(); + }); + + it("hides per-message identity entirely for fn agent (model-only) sessions even when model is set", async () => { + setupMockChat({ + activeSession: { + id: "session-001", + agentId: "__fn_agent__", + status: "active", + title: "Test Chat", + modelProvider: "openai", + modelId: "gpt-4o", + createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z", + }, + messages: [ + { id: "msg-001", sessionId: "session-001", role: "assistant", content: "Hi!", createdAt: "2026-04-08T00:01:00.000Z" }, + ], + }); + + await renderWithAct(); + + const messageBubble = screen.getByTestId("chat-message-msg-001"); + expect(messageBubble.querySelector(".chat-message-avatar")).toBeNull(); + }); +}); diff --git a/packages/dashboard/app/components/__tests__/ChatView.core.test.tsx b/packages/dashboard/app/components/__tests__/ChatView.core.test.tsx index 5821ef9c48..16c81a2901 100644 --- a/packages/dashboard/app/components/__tests__/ChatView.core.test.tsx +++ b/packages/dashboard/app/components/__tests__/ChatView.core.test.tsx @@ -1,6 +1,6 @@ /* FNXC:DashboardTests 2026-06-25-16:30: -ChatView suite split 1/3 (core) (was ChatView.test.tsx). Shares ChatView.test-harness for fixtures, +ChatView suite split 1/5 (core) (was ChatView.test.tsx). Shares ChatView.test-harness for fixtures, helpers, vi.mocked handles, and installChatViewEnv(). vi.mock factories stay inline & self -contained here (see harness header for why delegating them triggers a TDZ ReferenceError). */ @@ -1569,1653 +1569,5 @@ describe("ChatView", () => { expect(sendMessage).not.toHaveBeenCalled(); }); - describe("attachments", () => { - it("clicking paperclip triggers hidden file input", async () => { - setupMockChat({ activeSession: activeSessionFixture, messages: [] }); - await renderWithAct(); - - const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement; - const clickSpy = vi.spyOn(fileInput, "click"); - - await userEvent.click(screen.getByTestId("chat-attach-btn")); - expect(clickSpy).toHaveBeenCalled(); - }); - - it("allows attaching an image and sends with attachments only", async () => { - const sendMessage = vi.fn(); - setupMockChat({ activeSession: activeSessionFixture, messages: [], sendMessage }); - await renderWithAct(); - - const attachButton = screen.getByTestId("chat-attach-btn"); - expect(attachButton).toBeInTheDocument(); - - const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement; - const imageFile = new File(["image"], "shot.png", { type: "image/png" }); - fireEvent.change(fileInput, { target: { files: [imageFile] } }); - - expect(await screen.findByTestId("chat-attachment-previews")).toBeInTheDocument(); - const sendButton = screen.getByTestId("chat-send-btn"); - expect(sendButton).not.toBeDisabled(); - - await userEvent.click(sendButton); - expect(sendMessage).toHaveBeenCalledWith("", [imageFile]); - expect(screen.queryByTestId("chat-attachment-previews")).not.toBeInTheDocument(); - }); - - it("accepts non-image files and renders filename preview", async () => { - setupMockChat({ activeSession: activeSessionFixture, messages: [] }); - await renderWithAct(); - - const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement; - const textFile = new File(["hello"], "note.txt", { type: "text/plain" }); - fireEvent.change(fileInput, { target: { files: [textFile] } }); - - expect(await screen.findByText("note.txt")).toBeInTheDocument(); - expect(mockCreateObjectURL).not.toHaveBeenCalled(); - }); - - it("adds image attachments from paste events", async () => { - setupMockChat({ activeSession: activeSessionFixture, messages: [] }); - await renderWithAct(); - - const textarea = screen.getByTestId("chat-input"); - const imageFile = new File(["image"], "paste.png", { type: "image/png" }); - fireEvent.paste(textarea, { clipboardData: { files: [imageFile] } }); - - expect(await screen.findByTestId("chat-attachment-previews")).toBeInTheDocument(); - }); - - it("adds attachments from drag-and-drop", async () => { - setupMockChat({ activeSession: activeSessionFixture, messages: [] }); - await renderWithAct(); - - const wrapper = document.querySelector(".chat-input-wrapper") as HTMLElement; - const textFile = new File(["log"], "drop.log", { type: "text/x-log" }); - fireEvent.drop(wrapper, { dataTransfer: { files: [textFile] } }); - - expect(await screen.findByText("drop.log")).toBeInTheDocument(); - }); - - it("removes pending attachments and revokes preview urls", async () => { - setupMockChat({ activeSession: activeSessionFixture, messages: [] }); - await renderWithAct(); - - const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement; - const imageFile = new File(["image"], "shot.png", { type: "image/png" }); - fireEvent.change(fileInput, { target: { files: [imageFile] } }); - - const removeButton = await screen.findByTestId("chat-attachment-remove-0"); - await userEvent.click(removeButton); - - expect(mockRevokeObjectURL).toHaveBeenCalledWith("blob:shot.png"); - expect(screen.queryByTestId("chat-attachment-previews")).not.toBeInTheDocument(); - }); - - it("renders message attachments inline as actionable links", async () => { - setupMockChat({ - activeSession: activeSessionFixture, - messages: [ - { - id: "msg-attach", - sessionId: "session-001", - role: "assistant", - content: "Attached files", - createdAt: "2026-04-08T00:00:00.000Z", - attachments: [ - { - id: "att-1", - filename: "img-1.png", - originalName: "capture.png", - mimeType: "image/png", - size: 10, - createdAt: "2026-04-08T00:00:00.000Z", - }, - { - id: "att-2", - filename: "note.txt", - originalName: "note.txt", - mimeType: "text/plain", - size: 20, - createdAt: "2026-04-08T00:00:00.000Z", - }, - ], - }, - ], - }); - - await renderWithAct(); - - const links = screen.getAllByTestId("chat-message-attachment"); - expect(links).toHaveLength(2); - expect(links[0]).toHaveAttribute("href", "/api/chat/sessions/session-001/attachments/img-1.png"); - expect(links[0]).toHaveAttribute("target", "_blank"); - expect(links[1]).toHaveAttribute("href", "/api/chat/sessions/session-001/attachments/note.txt"); - expect(screen.getByText("note.txt")).toBeInTheDocument(); - }); - }); - - describe("agent mentions", () => { - it("shows mention popup when @ is typed", async () => { - setupMockChat({ activeSession: activeSessionFixture, messages: [] }); - - await renderWithAct(); - - const textarea = screen.getByTestId("chat-input"); - await userEvent.type(textarea, "@"); - - expect(await screen.findByTestId("agent-mention-popup")).toBeInTheDocument(); - }); - - it("filters mention popup by text after @", async () => { - setupMockChat({ activeSession: activeSessionFixture, messages: [] }); - - await renderWithAct(); - - const textarea = screen.getByTestId("chat-input"); - await userEvent.type(textarea, "@be"); - - expect(await screen.findByTestId("agent-mention-item-agent-002")).toBeInTheDocument(); - expect(screen.queryByTestId("agent-mention-item-agent-001")).not.toBeInTheDocument(); - }); - - it("hides mention popup on Escape", async () => { - setupMockChat({ activeSession: activeSessionFixture, messages: [] }); - - await renderWithAct(); - - const textarea = screen.getByTestId("chat-input"); - await userEvent.type(textarea, "@"); - expect(await screen.findByTestId("agent-mention-popup")).toBeInTheDocument(); - - await userEvent.keyboard("{Escape}"); - expect(screen.queryByTestId("agent-mention-popup")).not.toBeInTheDocument(); - }); - - it("inserts mention text when selecting an agent", async () => { - setupMockChat({ activeSession: activeSessionFixture, messages: [] }); - - await renderWithAct(); - - const textarea = screen.getByTestId("chat-input") as HTMLTextAreaElement; - await userEvent.type(textarea, "@al"); - - const mentionItem = await screen.findByTestId("agent-mention-item-agent-001"); - await userEvent.click(mentionItem); - - expect(textarea.value).toBe("@Alpha "); - expect(screen.queryByTestId("agent-mention-popup")).not.toBeInTheDocument(); - }); - - it("uses room member ordering in popup and marks non-member mention chips in room messages", async () => { - setupMockChat({ activeSession: activeSessionFixture, messages: [] }); - setupMockRooms({ - activeRoom: { - id: "room-001", - slug: "engineering", - name: "engineering", - createdBy: "agent-001", - status: "active", - createdAt: "2026-04-08T00:00:00.000Z", - updatedAt: "2026-04-08T00:00:00.000Z", - }, - activeRoomMembers: [ - { roomId: "room-001", agentId: "agent-001", role: "member", addedAt: "2026-04-08T00:00:00.000Z" }, - ], - messages: [ - { - id: "room-msg-1", - roomId: "room-001", - role: "user", - content: "Ping @Alpha and @Beta", - senderAgentId: "agent-001", - metadata: null, - attachments: [], - mentions: ["agent-001", "agent-002"], - createdAt: "2026-04-08T00:00:00.000Z", - }, - ], - }); - - const allCss = await loadAllAppCss(); - const style = document.createElement("style"); - style.textContent = allCss; - document.head.appendChild(style); - - await renderWithAct(); - - const user = userEvent.setup({ delay: null }); - await user.click(screen.getByTestId("chat-sidebar-scope-rooms")); - const textarea = screen.getByTestId("chat-input"); - await user.type(textarea, "@"); - - expect(screen.getByTestId("agent-mention-members-header")).toBeInTheDocument(); - expect(screen.queryByTestId("agent-mention-others-header")).not.toBeInTheDocument(); - - const bubble = screen.getByText("Ping", { exact: false }).closest(".chat-message--user"); - expect(bubble).toBeTruthy(); - - const memberChip = screen.getByText("@Alpha", { selector: ".chat-mention-chip" }); - const nonMemberChip = screen.getByText("@Beta", { selector: ".chat-mention-chip--non-member" }); - expect(nonMemberChip).toHaveAttribute("title", "Not a member of engineering"); - - // FN-4520: member mention chip text must not visually collapse into sent-bubble background. - expect(getComputedStyle(memberChip).color).not.toBe(getComputedStyle(bubble as Element).backgroundColor); - // FN-4520: non-member mention chip text must remain legible inside sent bubbles. - expect(getComputedStyle(nonMemberChip).color).not.toBe(getComputedStyle(bubble as Element).backgroundColor); - }); - - it("renders assistant mentions as plain text in markdown mode", async () => { - setupMockChat({ - activeSession: activeSessionFixture, - messages: [ - { - id: "msg-001", - sessionId: "session-001", - role: "assistant", - content: "Talk to @Alpha and @Unknown next.", - createdAt: "2026-04-08T00:00:00.000Z", - }, - ], - }); - - await renderWithAct(); - - await waitFor(() => { - expect(screen.getByText(/Talk to @Alpha and @Unknown next\./)).toBeInTheDocument(); - }); - expect(screen.queryByText("@Alpha", { selector: ".chat-mention-chip" })).toBeNull(); - expect(screen.queryByText("@Unknown", { selector: ".chat-mention-chip" })).toBeNull(); - }); - }); - - describe("slash skill autocomplete", () => { - it("shows the skill menu when typing slash in the chat input", async () => { - mockFetchDiscoveredSkills.mockResolvedValueOnce([ - createMockSkill({ id: "skill-refactor", name: "refactor/code", relativePath: "skills/refactor/code.md" }), - ]); - setupMockChat({ activeSession: activeSessionFixture, messages: [] }); - - await renderWithAct(); - - const textarea = screen.getByTestId("chat-input"); - await userEvent.type(textarea, "/"); - - expect(await screen.findByTestId("chat-skill-menu")).toBeInTheDocument(); - expect(screen.getByText("refactor/code")).toBeInTheDocument(); - }); - - it("filters discovered skills from slash input", async () => { - mockFetchDiscoveredSkills.mockResolvedValueOnce([ - createMockSkill({ id: "skill-review", name: "review/pr", relativePath: "skills/review/pr.md" }), - createMockSkill({ id: "skill-deploy", name: "deploy/app", relativePath: "skills/deploy/app.md" }), - ]); - setupMockChat({ activeSession: activeSessionFixture, messages: [] }); - - await renderWithAct(); - - const textarea = screen.getByTestId("chat-input"); - await userEvent.type(textarea, "/re"); - - expect(await screen.findByText("review/pr")).toBeInTheDocument(); - expect(screen.queryByText("deploy/app")).not.toBeInTheDocument(); - }); - - it("inserts /skill command when clicking a menu item", async () => { - mockFetchDiscoveredSkills.mockResolvedValueOnce([ - createMockSkill({ id: "skill-review", name: "review/pr", relativePath: "skills/review/pr.md" }), - ]); - setupMockChat({ activeSession: activeSessionFixture, messages: [] }); - - await renderWithAct(); - - const textarea = screen.getByTestId("chat-input"); - await userEvent.type(textarea, "/re"); - - await userEvent.click(await screen.findByRole("option", { name: /review\/pr/i })); - - expect(textarea).toHaveValue("/skill:review/pr "); - expect(screen.queryByTestId("chat-skill-menu")).not.toBeInTheDocument(); - }); - - it("supports arrow navigation with wrapping and Enter selection", async () => { - mockFetchDiscoveredSkills.mockResolvedValueOnce([ - createMockSkill({ id: "skill-alpha", name: "alpha", relativePath: "skills/alpha.md" }), - createMockSkill({ id: "skill-beta", name: "beta", relativePath: "skills/beta.md" }), - createMockSkill({ id: "skill-gamma", name: "gamma", relativePath: "skills/gamma.md" }), - ]); - setupMockChat({ activeSession: activeSessionFixture, messages: [] }); - await renderWithAct(); - - const textarea = screen.getByTestId("chat-input"); - fireEvent.change(textarea, { target: { value: "/" } }); - await screen.findByRole("option", { name: /alpha/i }); - - // Wrap to bottom from the first item. - fireEvent.keyDown(textarea, { key: "ArrowUp" }); - await waitFor(() => - expect(screen.getByRole("option", { name: /gamma/i })).toHaveClass( - "chat-skill-menu-item--highlighted", - ), - ); - - fireEvent.keyDown(textarea, { key: "Enter" }); - await waitFor(() => expect(textarea).toHaveValue("/skill:gamma ")); - }); - - it("keeps the keyboard highlight when revalidation re-delivers an identical skill list", async () => { - // Regression: the SWR skills cache re-delivers content-identical lists - // with fresh array identities (cache reads re-parse; revalidation - // notifies a new array). The highlight reset must key on skill ids, not - // array identity, or a revalidation landing mid-navigation wipes the - // user's keyboard position (the source of this test family's CI flakes). - const skillsList = [ - createMockSkill({ id: "skill-alpha", name: "alpha", relativePath: "skills/alpha.md" }), - createMockSkill({ id: "skill-beta", name: "beta", relativePath: "skills/beta.md" }), - createMockSkill({ id: "skill-gamma", name: "gamma", relativePath: "skills/gamma.md" }), - ]; - // Seed the cache so the menu renders before the (deferred) revalidation fetch. - writeCache(`${SWR_CACHE_KEYS.DISCOVERED_SKILLS_PREFIX}proj-123`, skillsList); - let resolveFetch!: (skills: DiscoveredSkill[]) => void; - mockFetchDiscoveredSkills.mockImplementationOnce( - () => new Promise((resolve) => { resolveFetch = resolve; }), - ); - setupMockChat({ activeSession: activeSessionFixture, messages: [] }); - await renderWithAct(); - - const textarea = screen.getByTestId("chat-input"); - fireEvent.change(textarea, { target: { value: "/" } }); - await screen.findByRole("option", { name: /alpha/i }); - - fireEvent.keyDown(textarea, { key: "ArrowUp" }); - await waitFor(() => - expect(screen.getByRole("option", { name: /gamma/i })).toHaveClass( - "chat-skill-menu-item--highlighted", - ), - ); - - // Revalidation lands mid-navigation: identical content, new identity. - await act(async () => { - resolveFetch(JSON.parse(JSON.stringify(skillsList)) as DiscoveredSkill[]); - }); - - expect(screen.getByRole("option", { name: /gamma/i })).toHaveClass( - "chat-skill-menu-item--highlighted", - ); - }); - - it("supports selecting highlighted skill with Tab", async () => { - mockFetchDiscoveredSkills.mockResolvedValueOnce([ - createMockSkill({ id: "skill-alpha", name: "alpha", relativePath: "skills/alpha.md" }), - createMockSkill({ id: "skill-beta", name: "beta", relativePath: "skills/beta.md" }), - ]); - setupMockChat({ activeSession: activeSessionFixture, messages: [] }); - - await renderWithAct(); - - const textarea = screen.getByTestId("chat-input"); - await userEvent.type(textarea, "/"); - await screen.findByRole("option", { name: /alpha/i }); - - await userEvent.keyboard("{ArrowDown}"); - expect(screen.getByRole("option", { name: /beta/i })).toHaveClass( - "chat-skill-menu-item--highlighted", - ); - - await userEvent.keyboard("{Tab}"); - expect(textarea).toHaveValue("/skill:beta "); - }); - - it("closes the menu when pressing Escape", async () => { - mockFetchDiscoveredSkills.mockResolvedValueOnce([ - createMockSkill({ id: "skill-review", name: "review/pr", relativePath: "skills/review/pr.md" }), - ]); - setupMockChat({ activeSession: activeSessionFixture, messages: [] }); - - await renderWithAct(); - - const textarea = screen.getByTestId("chat-input"); - await userEvent.type(textarea, "/"); - expect(await screen.findByTestId("chat-skill-menu")).toBeInTheDocument(); - - await userEvent.keyboard("{Escape}"); - expect(screen.queryByTestId("chat-skill-menu")).not.toBeInTheDocument(); - }); - - it("closes the menu when slash trigger pattern no longer matches", async () => { - mockFetchDiscoveredSkills.mockResolvedValueOnce([ - createMockSkill({ id: "skill-review", name: "review/pr", relativePath: "skills/review/pr.md" }), - ]); - setupMockChat({ activeSession: activeSessionFixture, messages: [] }); - - await renderWithAct(); - - const textarea = screen.getByTestId("chat-input"); - await userEvent.type(textarea, "/re"); - expect(await screen.findByTestId("chat-skill-menu")).toBeInTheDocument(); - - await userEvent.type(textarea, " "); - expect(screen.queryByTestId("chat-skill-menu")).not.toBeInTheDocument(); - }); - - it("shows loading indicator while discovered skills are still loading", async () => { - let resolveSkills: ((skills: DiscoveredSkill[]) => void) | undefined; - mockFetchDiscoveredSkills.mockImplementationOnce( - () => - new Promise((resolve) => { - resolveSkills = resolve; - }), - ); - setupMockChat({ activeSession: activeSessionFixture, messages: [] }); - - await renderWithAct(); - - const textarea = screen.getByTestId("chat-input"); - await userEvent.type(textarea, "/"); - - expect(await screen.findByText("Loading skills…")).toBeInTheDocument(); - - resolveSkills?.([createMockSkill({ id: "skill-review", name: "review/pr", relativePath: "skills/review/pr.md" })]); - await waitFor(() => { - expect(screen.getByText("review/pr")).toBeInTheDocument(); - }); - }); - - it("does not crash when discovered skills fail to load", async () => { - mockFetchDiscoveredSkills.mockRejectedValueOnce(new Error("skills endpoint unavailable")); - setupMockChat({ activeSession: activeSessionFixture, messages: [] }); - - await renderWithAct(); - - const textarea = screen.getByTestId("chat-input"); - await userEvent.type(textarea, "/"); - - expect(await screen.findByText("No skills available")).toBeInTheDocument(); - }); - }); - - it("disables send button when input is empty", async () => { - setupMockChat({ - activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }, - messages: [], - }); - - await renderWithAct(); - - const sendButton = screen.getByTestId("chat-send-btn"); - expect(sendButton).toBeDisabled(); - }); - - it("renders stop button when streaming", async () => { - setupMockChat({ - activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }, - messages: [], - isStreaming: true, - }); - - await renderWithAct(); - - expect(screen.getByTestId("chat-stop-btn")).toBeInTheDocument(); - expect(screen.queryByTestId("chat-send-btn")).not.toBeInTheDocument(); - }); - - it("clicking stop button calls stopStreaming", async () => { - const stopStreaming = vi.fn(); - setupMockChat({ - activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }, - messages: [], - isStreaming: true, - stopStreaming, - }); - - await renderWithAct(); - - await userEvent.click(screen.getByTestId("chat-stop-btn")); - expect(stopStreaming).toHaveBeenCalledTimes(1); - }); - - it("FN-6576 does not let a send gesture trailing click press the swapped stop button", async () => { - const viewportSpy = mockViewportMode("mobile"); - const sendMessage = vi.fn(); - const stopStreaming = vi.fn(); - mockUseChat.mockImplementation(() => { - const [isStreaming, setIsStreaming] = useState(false); - return { - ...defaultChatState, - activeSession: activeSessionFixture, - sessions: [activeSessionFixture], - filteredSessions: [activeSessionFixture], - messages: [], - isStreaming, - sendMessage: (message, files) => { - sendMessage(message, files); - setIsStreaming(true); - }, - stopStreaming, - } satisfies UseChatReturn; - }); - - await renderWithAct(); - - fireEvent.change(screen.getByTestId("chat-input"), { target: { value: "Start streaming" } }); - await act(async () => { - fireEvent.pointerDown(screen.getByTestId("chat-send-btn"), { pointerType: "touch" }); - fireEvent.touchStart(screen.getByTestId("chat-send-btn")); - }); - expect(sendMessage).toHaveBeenCalledTimes(1); - expect(sendMessage).toHaveBeenCalledWith("Start streaming", []); - - await act(async () => { - fireEvent.click(screen.getByTestId("chat-stop-btn")); - }); - expect(stopStreaming).not.toHaveBeenCalled(); - viewportSpy.mockRestore(); - }); - - it("FN-6576 allows a standalone mobile stop tap exactly once", async () => { - const viewportSpy = mockViewportMode("mobile"); - const stopStreaming = vi.fn(); - setupMockChat({ - activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }, - messages: [], - isStreaming: true, - stopStreaming, - }); - - await renderWithAct(); - - await act(async () => { - fireEvent.pointerDown(screen.getByTestId("chat-stop-btn"), { pointerType: "touch" }); - fireEvent.touchStart(screen.getByTestId("chat-stop-btn")); - fireEvent.click(screen.getByTestId("chat-stop-btn")); - }); - expect(stopStreaming).toHaveBeenCalledTimes(1); - viewportSpy.mockRestore(); - }); - - it("FN-6576 allows a genuine stop tap within the send click-latch window", async () => { - const viewportSpy = mockViewportMode("mobile"); - const sendMessage = vi.fn(); - const stopStreaming = vi.fn(); - mockUseChat.mockImplementation(() => { - const [isStreaming, setIsStreaming] = useState(false); - return { - ...defaultChatState, - activeSession: activeSessionFixture, - sessions: [activeSessionFixture], - filteredSessions: [activeSessionFixture], - messages: [], - isStreaming, - sendMessage: (message, files) => { - sendMessage(message, files); - setIsStreaming(true); - }, - stopStreaming, - } satisfies UseChatReturn; - }); - - await renderWithAct(); - - fireEvent.change(screen.getByTestId("chat-input"), { target: { value: "Start then stop" } }); - await act(async () => { - fireEvent.pointerDown(screen.getByTestId("chat-send-btn"), { pointerType: "touch" }); - }); - expect(sendMessage).toHaveBeenCalledTimes(1); - await act(async () => { - await new Promise((resolve) => window.setTimeout(resolve, 0)); - }); - - await act(async () => { - fireEvent.pointerDown(screen.getByTestId("chat-stop-btn"), { pointerType: "touch" }); - fireEvent.touchStart(screen.getByTestId("chat-stop-btn")); - fireEvent.click(screen.getByTestId("chat-stop-btn")); - }); - expect(stopStreaming).toHaveBeenCalledTimes(1); - viewportSpy.mockRestore(); - }); - - it("renders send button when not streaming", async () => { - setupMockChat({ - activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }, - messages: [], - isStreaming: false, - }); - - await renderWithAct(); - - expect(screen.getByTestId("chat-send-btn")).toBeInTheDocument(); - }); - - it("renders pending message indicator and dismisses it", async () => { - const clearPendingMessage = vi.fn(); - setupMockChat({ - activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }, - messages: [], - pendingMessage: "Queued while streaming", - clearPendingMessage, - }); - - await renderWithAct(); - - expect(screen.getByTestId("chat-pending-indicator")).toHaveTextContent("Queued: Queued while streaming"); - - await userEvent.click(screen.getByTestId("chat-pending-dismiss")); - expect(clearPendingMessage).toHaveBeenCalledTimes(1); - }); - - it("textarea is enabled during streaming", async () => { - setupMockChat({ - activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }, - messages: [ - { id: "msg-001", sessionId: "session-001", role: "user", content: "Hello", createdAt: "2026-04-08T00:00:00.000Z" }, - ], - isStreaming: true, - streamingText: "Thinking...", - }); - - await renderWithAct(); - - const textarea = screen.getByTestId("chat-input"); - expect(textarea).not.toBeDisabled(); - }); - - it("user can type while streaming", async () => { - setupMockChat({ - activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }, - messages: [ - { id: "msg-001", sessionId: "session-001", role: "user", content: "Hello", createdAt: "2026-04-08T00:00:00.000Z" }, - ], - isStreaming: true, - streamingText: "Thinking...", - }); - - await renderWithAct(); - - const textarea = screen.getByTestId("chat-input"); - - // User should be able to type in the textarea while streaming - fireEvent.change(textarea, { target: { value: "Second message" } }); - expect((textarea as HTMLTextAreaElement).value).toBe("Second message"); - }); - - it("shows streaming indicator when isStreaming is true", async () => { - setupMockChat({ - activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }, - messages: [ - { id: "msg-001", sessionId: "session-001", role: "user", content: "Hello", createdAt: "2026-04-08T00:00:00.000Z" }, - ], - isStreaming: true, - streamingText: "Typing...", - }); - - await renderWithAct(); - - // Streaming message should show - const streamingMessage = document.querySelector(".chat-message--streaming") as HTMLElement | null; - expect(streamingMessage).toBeInTheDocument(); - expect(streamingMessage?.textContent).toContain("Typing"); - }); - - it("shows thinking blocks collapsed by default", async () => { - setupMockChat({ - activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }, - messages: [ - { id: "msg-001", sessionId: "session-001", role: "assistant", content: "Here's my response", thinkingOutput: "I need to think about this...", createdAt: "2026-04-08T00:00:00.000Z" }, - ], - }); - - await renderWithAct(); - - const message = screen.getByTestId("chat-message-msg-001"); - const details = message.querySelector("details"); - expect(details).toBeInTheDocument(); - expect(details).toHaveProperty("open", false); - }); - - describe("streaming states", () => { - it("keeps mobile thread visible when active session metadata refreshes during streaming", async () => { - const mediaQuerySpy = mockViewportMode("mobile"); - const streamingState: UseChatReturn = { - ...defaultChatState, - sessions: [{ ...activeSessionFixture }], - filteredSessions: [{ ...activeSessionFixture }], - activeSession: { ...activeSessionFixture }, - messages: [], - isStreaming: true, - streamingText: "", - streamingThinking: "", - }; - const refreshedStreamingState: UseChatReturn = { - ...streamingState, - sessions: [{ ...activeSessionFixture, updatedAt: "2026-04-08T00:05:00.000Z" }], - filteredSessions: [{ ...activeSessionFixture, updatedAt: "2026-04-08T00:05:00.000Z" }], - activeSession: null, - }; - - mockUseChat - .mockReturnValueOnce(streamingState) - .mockReturnValue(refreshedStreamingState); - - const { rerender } = await renderWithAct(); - - expect(document.querySelector(".chat-message--streaming")?.textContent).toContain("Working"); - rerender(); - - expect(document.querySelector(".chat-message--streaming")?.textContent).toContain("Working"); - expect(screen.queryByText("Start a new conversation")).not.toBeInTheDocument(); - expect(screen.queryByText("No messages yet. Start the conversation!")).not.toBeInTheDocument(); - expect(screen.getByTestId("chat-back-btn")).toBeInTheDocument(); - - void mediaQuerySpy; - }); - - it("keeps the streaming indicator visible while message history is still loading", async () => { - setupMockChat({ - activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }, - messages: [], - messagesLoading: true, - isStreaming: true, - streamingText: "", - streamingThinking: "", - }); - - await renderWithAct(); - - const streamingMessage = document.querySelector(".chat-message--streaming") as HTMLElement | null; - expect(streamingMessage).toBeInTheDocument(); - expect(streamingMessage?.textContent).toContain("Working"); - expect(screen.queryByText("Loading messages...")).not.toBeInTheDocument(); - }); - - it("shows waiting indicator when streaming starts before text arrives", async () => { - setupMockChat({ - activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }, - messages: [ - { id: "msg-001", sessionId: "session-001", role: "user", content: "Hello", createdAt: "2026-04-08T00:00:00.000Z" }, - ], - isStreaming: true, - streamingText: "", - streamingThinking: "", - }); - - await renderWithAct(); - - // Streaming message should show with "Working..." text - const streamingMessage = document.querySelector(".chat-message--streaming") as HTMLElement | null; - expect(streamingMessage).toBeInTheDocument(); - expect(streamingMessage?.textContent).toContain("Working"); - - // Waiting class should be present - const waitingContent = streamingMessage?.querySelector(".chat-message-content--waiting"); - expect(waitingContent).toBeInTheDocument(); - - // Typing indicator dots should be rendered - const typingIndicator = streamingMessage?.querySelector(".chat-typing-indicator"); - expect(typingIndicator).toBeInTheDocument(); - expect(typingIndicator?.querySelectorAll("span").length).toBe(3); - }); - - it("shows thinking indicator when streaming thinking arrives before text", async () => { - setupMockChat({ - activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }, - messages: [ - { id: "msg-001", sessionId: "session-001", role: "user", content: "Hello", createdAt: "2026-04-08T00:00:00.000Z" }, - ], - isStreaming: true, - streamingText: "", - streamingThinking: "analyzing the request...", - }); - - await renderWithAct(); - - // Streaming message should show with "Thinking..." text - const streamingMessage = document.querySelector(".chat-message--streaming") as HTMLElement | null; - expect(streamingMessage).toBeInTheDocument(); - expect(streamingMessage?.textContent).toContain("Thinking"); - - // Thinking details should be rendered - const thinkingDetails = streamingMessage?.querySelector("details.chat-message-thinking"); - expect(thinkingDetails).toBeInTheDocument(); - expect(thinkingDetails?.querySelector(".chat-message-thinking-content")?.textContent).toContain("analyzing the request"); - - // Typing indicator dots should be rendered - const typingIndicator = streamingMessage?.querySelector(".chat-typing-indicator"); - expect(typingIndicator).toBeInTheDocument(); - }); - }); - - it("filters sessions by search query", async () => { - setupMockChat({ - sessions: [ - { id: "session-001", agentId: "agent-001", status: "active", title: "Frontend work", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }, - { id: "session-002", agentId: "agent-002", status: "active", title: "Backend API", createdAt: "2026-04-07T00:00:00.000Z", updatedAt: "2026-04-07T00:00:00.000Z" }, - ], - filteredSessions: [ - { id: "session-001", agentId: "agent-001", status: "active", title: "Frontend work", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }, - ], - searchQuery: "frontend", - setSearchQuery: vi.fn(), - }); - - await renderWithAct(); - - expect(screen.getByText("Frontend work")).toBeInTheDocument(); - expect(screen.queryByText("Backend API")).not.toBeInTheDocument(); - }); - - it("shows empty state with Start Chat button (no inline agent selector)", async () => { - setupMockChat({ sessions: [], filteredSessions: [] }); - - await renderWithAct(); - - expect(screen.getByText("Start a new conversation")).toBeInTheDocument(); - // Find the New Chat button in the empty state section - const emptyStateText = screen.getByText("Start a new conversation"); - const emptyState = emptyStateText.closest(".chat-empty-state") as HTMLElement | null; - expect(within(emptyState!).getByRole("button", { name: /new chat/i })).toBeInTheDocument(); - // Should NOT have an agent selector in empty state - expect(emptyState?.querySelector("select")).toBeNull(); - }); - - it("shows context menu on right-click", async () => { - setupMockChat({ - sessions: [{ id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }], - filteredSessions: [{ id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }], - }); - - await renderWithAct(); - - const sessionItem = screen.getByTestId("chat-session-session-001"); - - await userEvent.pointer({ target: sessionItem, keys: "[MouseRight]" }); - - expect(screen.getByTestId("chat-context-archive")).toBeInTheDocument(); - expect(screen.getByTestId("chat-context-delete")).toBeInTheDocument(); - }); - - it("calls archiveSession when clicking Archive in context menu", async () => { - const archiveSession = vi.fn(); - setupMockChat({ - sessions: [{ id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }], - filteredSessions: [{ id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }], - archiveSession, - }); - - await renderWithAct(); - - const sessionItem = screen.getByTestId("chat-session-session-001"); - await userEvent.pointer({ target: sessionItem, keys: "[MouseRight]" }); - - await userEvent.click(screen.getByTestId("chat-context-archive")); - - expect(archiveSession).toHaveBeenCalledWith("session-001"); - }); - - it("shows delete confirmation dialog", async () => { - setupMockChat({ - sessions: [{ id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }], - filteredSessions: [{ id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }], - }); - - await renderWithAct(); - - const sessionItem = screen.getByTestId("chat-session-session-001"); - await userEvent.pointer({ target: sessionItem, keys: "[MouseRight]" }); - - await userEvent.click(screen.getByTestId("chat-context-delete")); - - // Dialog should be open - const dialog = document.querySelector(".chat-new-dialog") as HTMLElement | null; - expect(dialog).toBeInTheDocument(); - expect(within(dialog!).getByText("Delete Conversation?")).toBeInTheDocument(); - }); - - it("shows formatted model label for fn agent sessions in sidebar", async () => { - setupMockChat({ - sessions: [{ - id: "session-001", - agentId: "__fn_agent__", - status: "active", - title: "My Chat", - modelProvider: "anthropic", - modelId: "claude-sonnet-4-5", - updatedAt: "2026-04-08T00:00:00.000Z", - createdAt: "2026-04-08T00:00:00.000Z", - }], - filteredSessions: [{ - id: "session-001", - agentId: "__fn_agent__", - status: "active", - title: "My Chat", - modelProvider: "anthropic", - modelId: "claude-sonnet-4-5", - updatedAt: "2026-04-08T00:00:00.000Z", - createdAt: "2026-04-08T00:00:00.000Z", - }], - }); - - await renderWithAct(); - - const sessionItem = screen.getByTestId("chat-session-session-001"); - expect(within(sessionItem).getByText("Claude Sonnet 4.5")).toBeInTheDocument(); - expect(within(sessionItem).queryByText("Fusion")).not.toBeInTheDocument(); - }); - - it("shows Fusion fallback for fn agent sessions in sidebar without model info", async () => { - mockFetchModels.mockResolvedValue({ - models: [], - favoriteProviders: [], - favoriteModels: [], - defaultProvider: null, - defaultModelId: null, - }); - setupMockChat({ - sessions: [{ id: "session-001", agentId: "__fn_agent__", status: "active", title: "My Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }], - filteredSessions: [{ id: "session-001", agentId: "__fn_agent__", status: "active", title: "My Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }], - }); - - await renderWithAct(); - - const sessionItem = screen.getByTestId("chat-session-session-001"); - expect(within(sessionItem).getByText("Fusion")).toBeInTheDocument(); - }); - - it("shows agent ID for non-fn agent sessions in sidebar", async () => { - setupMockChat({ - sessions: [{ id: "session-001", agentId: "my-custom-agent", status: "active", title: "Custom Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }], - filteredSessions: [{ id: "session-001", agentId: "my-custom-agent", status: "active", title: "Custom Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }], - }); - - await renderWithAct(); - - const sessionItem = screen.getByTestId("chat-session-session-001"); - // Should show the agent ID (truncated to 30 chars) - expect(within(sessionItem).getByText("my-custom-agent")).toBeInTheDocument(); - }); - - it("shows formatted model name in thread header title for fn agent sessions", async () => { - setupMockChat({ - activeSession: { - id: "session-001", - agentId: "__fn_agent__", - status: "active", - title: "Test Chat", - modelProvider: "anthropic", - modelId: "claude-sonnet-4-5", - createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z", - }, - messages: [ - { id: "msg-001", sessionId: "session-001", role: "assistant", content: "Hi!", createdAt: "2026-04-08T00:00:00.000Z" }, - ], - }); - - await renderWithAct(); - - const title = document.querySelector(".chat-thread-header-title") as HTMLElement | null; - expect(title).toBeInTheDocument(); - expect(title).toHaveTextContent("Claude Sonnet 4.5"); - expect(title).not.toHaveTextContent("Fusion"); - }); - - it("shows model tag in thread header when non-fn session has model", async () => { - setupMockChat({ - activeSession: { - id: "session-001", - agentId: "agent-001", - status: "active", - title: "Agent Chat", - modelProvider: "anthropic", - modelId: "claude-sonnet-4-5", - createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z", - }, - messages: [ - { id: "msg-001", sessionId: "session-001", role: "user", content: "Hello", createdAt: "2026-04-08T00:00:00.000Z" }, - { id: "msg-002", sessionId: "session-001", role: "assistant", content: "Hi!", createdAt: "2026-04-08T00:01:00.000Z" }, - ], - }); - - await renderWithAct(); - - const headerModelTag = document.querySelector(".chat-thread-header .chat-model-tag") as HTMLElement | null; - expect(headerModelTag).toBeInTheDocument(); - expect(headerModelTag?.textContent).toContain("Claude"); - }); - - it("does not show duplicate model tag in thread header for fn agent sessions", async () => { - setupMockChat({ - activeSession: { - id: "session-001", - agentId: "__fn_agent__", - status: "active", - title: "Test Chat", - modelProvider: "anthropic", - modelId: "claude-sonnet-4-5", - createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z", - }, - messages: [ - { id: "msg-001", sessionId: "session-001", role: "assistant", content: "Hi!", createdAt: "2026-04-08T00:00:00.000Z" }, - ], - }); - - await renderWithAct(); - - const title = document.querySelector(".chat-thread-header-title") as HTMLElement | null; - expect(title).toHaveTextContent("Claude Sonnet 4.5"); - - const headerModelTag = document.querySelector(".chat-thread-header .chat-model-tag") as HTMLElement | null; - expect(headerModelTag).toBeNull(); - }); - - it("keeps provider identity text grouped in header while render toggle stays on the same row", async () => { - setupMockChat({ - activeSession: { - id: "session-001", - agentId: "agent-001", - status: "active", - title: "Agent Chat", - modelProvider: "anthropic", - modelId: "claude-sonnet-4-5", - createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z", - }, - messages: [ - { id: "msg-001", sessionId: "session-001", role: "assistant", content: "Hi!", createdAt: "2026-04-08T00:00:00.000Z" }, - ], - }); - - await renderWithAct(); - - const header = document.querySelector(".chat-thread-header") as HTMLElement | null; - const identity = screen.getByTestId("chat-thread-header-identity"); - const toggle = screen.getByTestId("chat-thread-render-toggle"); - const providerIcon = identity.querySelector(".provider-icon"); - const modelTag = identity.querySelector(".chat-model-tag"); - const newChatButton = screen.getByTestId("chat-new-btn"); - - expect(header).toBeInTheDocument(); - expect(newChatButton.closest(".view-header")).toBeInTheDocument(); - expect(providerIcon).toBeInTheDocument(); - expect(within(identity).getByText("Agent Chat")).toBeInTheDocument(); - expect(modelTag).toBeInTheDocument(); - expect(modelTag).toHaveTextContent("Claude Sonnet 4.5"); - expect(toggle).toBeInTheDocument(); - expect(header?.children[header.children.length - 1]).toBe(toggle); - expect(document.querySelectorAll(".chat-thread-header .chat-model-tag")).toHaveLength(1); - }); - - it("does not show model tag when session has no model", async () => { - setupMockChat({ - activeSession: { - id: "session-001", - agentId: "__fn_agent__", - status: "active", - title: "Test Chat", - createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z", - }, - messages: [ - { id: "msg-001", sessionId: "session-001", role: "user", content: "Hello", createdAt: "2026-04-08T00:00:00.000Z" }, - { id: "msg-002", sessionId: "session-001", role: "assistant", content: "Hi!", createdAt: "2026-04-08T00:01:00.000Z" }, - ], - }); - - await renderWithAct(); - - const modelTag = document.querySelector(".chat-model-tag") as HTMLElement | null; - expect(modelTag).not.toBeInTheDocument(); - }); - - it("does not repeat the model tag in per-message avatars for non-fn sessions", async () => { - // Per-message model tags were intentionally removed — the model is shown - // once in the thread header. The avatar should still render with the - // agent name (no agent identity collapse for real agents) but no model - // tag inside it. - setupMockChat({ - activeSession: { - id: "session-001", - agentId: "agent-001", - status: "active", - title: "Agent Chat", - modelProvider: "openai", - modelId: "gpt-4o", - createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z", - }, - messages: [ - { id: "msg-001", sessionId: "session-001", role: "assistant", content: "Hi!", createdAt: "2026-04-08T00:01:00.000Z" }, - ], - }); - - await renderWithAct(); - - const messageBubble = screen.getByTestId("chat-message-msg-001"); - const avatar = messageBubble.querySelector(".chat-message-avatar") as HTMLElement | null; - expect(avatar).toBeInTheDocument(); - expect(avatar?.querySelector(".chat-model-tag")).toBeNull(); - }); - - it("hides per-message identity entirely for fn agent (model-only) sessions even when model is set", async () => { - setupMockChat({ - activeSession: { - id: "session-001", - agentId: "__fn_agent__", - status: "active", - title: "Test Chat", - modelProvider: "openai", - modelId: "gpt-4o", - createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z", - }, - messages: [ - { id: "msg-001", sessionId: "session-001", role: "assistant", content: "Hi!", createdAt: "2026-04-08T00:01:00.000Z" }, - ], - }); - - await renderWithAct(); - - const messageBubble = screen.getByTestId("chat-message-msg-001"); - expect(messageBubble.querySelector(".chat-message-avatar")).toBeNull(); - }); + // Extracted late ChatView interaction describes live in ChatView.core-interactions.test.tsx. }); - -describe("formatModelTag helper function", () => { - // Import the function for testing - we'll test it via the UI behavior instead - // The function is not exported, so we test it indirectly through the component - - it("formats claude-sonnet-4-5 model ID correctly", async () => { - setupMockChat({ - activeSession: { - id: "session-001", - agentId: "agent-001", - status: "active", - title: "Test", - modelProvider: "anthropic", - modelId: "claude-sonnet-4-5", - createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z", - }, - messages: [ - { id: "msg-001", sessionId: "session-001", role: "assistant", content: "Hi!", createdAt: "2026-04-08T00:00:00.000Z" }, - ], - }); - - await renderWithAct(); - - const modelTag = document.querySelector(".chat-model-tag") as HTMLElement | null; - expect(modelTag?.textContent).toContain("Claude Sonnet"); - }); - - it("formats gpt-4o model ID correctly", async () => { - setupMockChat({ - activeSession: { - id: "session-001", - agentId: "agent-001", - status: "active", - title: "Test", - modelProvider: "openai", - modelId: "gpt-4o", - createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z", - }, - messages: [ - { id: "msg-001", sessionId: "session-001", role: "assistant", content: "Hi!", createdAt: "2026-04-08T00:00:00.000Z" }, - ], - }); - - await renderWithAct(); - - const modelTag = document.querySelector(".chat-model-tag") as HTMLElement | null; - expect(modelTag?.textContent).toContain("GPT-4o"); - }); - - it("formats gemini-2.5-pro model ID correctly", async () => { - setupMockChat({ - activeSession: { - id: "session-001", - agentId: "agent-001", - status: "active", - title: "Test", - modelProvider: "google", - modelId: "gemini-2.5-pro", - createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z", - }, - messages: [ - { id: "msg-001", sessionId: "session-001", role: "assistant", content: "Hi!", createdAt: "2026-04-08T00:00:00.000Z" }, - ], - }); - - await renderWithAct(); - - const modelTag = document.querySelector(".chat-model-tag") as HTMLElement | null; - expect(modelTag?.textContent).toContain("Gemini"); - }); - - it("returns null when modelId is missing", async () => { - setupMockChat({ - activeSession: { - id: "session-001", - agentId: "__fn_agent__", - status: "active", - title: "Test", - modelProvider: "anthropic", - createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z", - }, - messages: [ - { id: "msg-001", sessionId: "session-001", role: "assistant", content: "Hi!", createdAt: "2026-04-08T00:00:00.000Z" }, - ], - }); - - await renderWithAct(); - - const modelTag = document.querySelector(".chat-model-tag") as HTMLElement | null; - expect(modelTag).not.toBeInTheDocument(); - }); - - it("returns null when provider is missing", async () => { - setupMockChat({ - activeSession: { - id: "session-001", - agentId: "__fn_agent__", - status: "active", - title: "Test", - modelId: "claude-sonnet-4-5", - createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z", - }, - messages: [ - { id: "msg-001", sessionId: "session-001", role: "assistant", content: "Hi!", createdAt: "2026-04-08T00:00:00.000Z" }, - ], - }); - - await renderWithAct(); - - const modelTag = document.querySelector(".chat-model-tag") as HTMLElement | null; - expect(modelTag).not.toBeInTheDocument(); - }); -}); - -describe("Chat Session Delete Button", () => { - it("renders delete button on each session item", async () => { - setupMockChat({ - sessions: [ - { id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat 1", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }, - { id: "session-002", agentId: "agent-002", status: "active", title: "Test Chat 2", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }, - ], - filteredSessions: [ - { id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat 1", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }, - { id: "session-002", agentId: "agent-002", status: "active", title: "Test Chat 2", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }, - ], - }); - - await renderWithAct(); - - const deleteButtons = screen.getAllByTestId("chat-session-delete-btn"); - expect(deleteButtons.length).toBe(2); - }); - - it("clicking delete button shows confirmation dialog", async () => { - setupMockChat({ - sessions: [{ id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }], - filteredSessions: [{ id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }], - }); - - await renderWithAct(); - - const deleteButton = screen.getByTestId("chat-session-delete-btn"); - await userEvent.click(deleteButton); - - // Dialog should be open - const dialog = document.querySelector(".chat-new-dialog") as HTMLElement | null; - expect(dialog).toBeInTheDocument(); - expect(within(dialog!).getByText("Delete Conversation?")).toBeInTheDocument(); - }); - - it("clicking delete button does not select the session", async () => { - const selectSession = vi.fn(); - setupMockChat({ - sessions: [{ id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }], - filteredSessions: [{ id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }], - selectSession, - }); - - await renderWithAct(); - - const deleteButton = screen.getByTestId("chat-session-delete-btn"); - await userEvent.click(deleteButton); - - expect(selectSession).not.toHaveBeenCalled(); - }); - - it("renames from the desktop context menu with the current title prefilled", async () => { - const renameSession = vi.fn().mockResolvedValue(undefined); - const renamedSession: ChatSessionInfo = { id: "session-001", agentId: "agent-001", status: "active", title: "Renamed Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }; - setupMockChat({ - activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }, - sessions: [{ id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }], - filteredSessions: [{ id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }], - renameSession, - }); - - const view = await renderWithAct(); - - fireEvent.contextMenu(screen.getByTestId("chat-session-session-001")); - expect(screen.getByTestId("chat-context-rename")).toBeInTheDocument(); - await userEvent.click(screen.getByTestId("chat-context-rename")); - - const input = screen.getByTestId("chat-rename-input") as HTMLInputElement; - expect(input.value).toBe("Test Chat"); - await userEvent.clear(input); - await userEvent.type(input, "Renamed Chat"); - await userEvent.click(screen.getByTestId("chat-rename-save")); - - expect(renameSession).toHaveBeenCalledWith("session-001", "Renamed Chat"); - - setupMockChat({ - activeSession: renamedSession, - sessions: [renamedSession], - filteredSessions: [renamedSession], - renameSession, - }); - await act(async () => { - view.rerender(); - }); - - expect(screen.getByTestId("chat-session-session-001")).toHaveTextContent("Renamed Chat"); - const headerTitle = document.querySelector(".chat-thread-header-title") as HTMLElement | null; - expect(headerTitle).toHaveTextContent("Renamed Chat"); - }); - - it("prefills rename as empty for an untitled session and names it", async () => { - const renameSession = vi.fn().mockResolvedValue(undefined); - const untitledSession: ChatSessionInfo = { id: "session-001", agentId: "agent-001", status: "active", title: null, createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }; - setupMockChat({ - activeSession: untitledSession, - sessions: [untitledSession], - filteredSessions: [untitledSession], - renameSession, - }); - - await renderWithAct(); - - fireEvent.contextMenu(screen.getByTestId("chat-session-session-001")); - await userEvent.click(screen.getByTestId("chat-context-rename")); - - const input = screen.getByTestId("chat-rename-input") as HTMLInputElement; - expect(input.value).toBe(""); - await userEvent.type(input, "Named from Untitled"); - await userEvent.click(screen.getByTestId("chat-rename-save")); - - expect(renameSession).toHaveBeenCalledWith("session-001", "Named from Untitled"); - }); - - it("renames from the mobile session switcher and preserves the active header title surface", async () => { - const restoreMatchMedia = mockViewportMode("mobile"); - const renameSession = vi.fn().mockResolvedValue(undefined); - try { - setupMockChat({ - activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Mobile Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }, - sessions: [{ id: "session-001", agentId: "agent-001", status: "active", title: "Mobile Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }], - filteredSessions: [{ id: "session-001", agentId: "agent-001", status: "active", title: "Mobile Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }], - renameSession, - }); - - const view = await renderWithAct(); - - expect(screen.getByTestId("chat-mobile-session-trigger")).toHaveTextContent("Mobile Chat"); - await userEvent.click(screen.getByTestId("chat-mobile-session-trigger")); - await userEvent.click(screen.getByTestId("chat-mobile-session-rename-session-001")); - - const input = screen.getByTestId("chat-rename-input") as HTMLInputElement; - expect(input.value).toBe("Mobile Chat"); - await userEvent.clear(input); - await userEvent.type(input, "Mobile Renamed"); - await userEvent.click(screen.getByTestId("chat-rename-save")); - - expect(renameSession).toHaveBeenCalledWith("session-001", "Mobile Renamed"); - - const renamedSession: ChatSessionInfo = { id: "session-001", agentId: "agent-001", status: "active", title: "Mobile Renamed", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }; - setupMockChat({ - activeSession: renamedSession, - sessions: [renamedSession], - filteredSessions: [renamedSession], - renameSession, - }); - await act(async () => { - view.rerender(); - }); - - expect(screen.getByTestId("chat-mobile-session-trigger")).toHaveTextContent("Mobile Renamed"); - const headerTitle = document.querySelector(".chat-thread-header-title") as HTMLElement | null; - expect(headerTitle).toHaveTextContent("Mobile Renamed"); - } finally { - restoreMatchMedia.mockRestore(); - } - }); - - it("confirming delete calls deleteSession", async () => { - const deleteSession = vi.fn(); - setupMockChat({ - sessions: [{ id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }], - filteredSessions: [{ id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }], - deleteSession, - }); - - await renderWithAct(); - - const deleteButton = screen.getByTestId("chat-session-delete-btn"); - await userEvent.click(deleteButton); - - // Click confirm in dialog - const dialog = document.querySelector(".chat-new-dialog") as HTMLElement | null; - await userEvent.click(within(dialog!).getByText("Delete")); - - expect(deleteSession).toHaveBeenCalledWith("session-001"); - }); -}); - -describe("ChatView CSS — failure bubble contracts", () => { - const css = loadAllAppCss(); - - it("uses shared error surface tokens for failure bubbles and detail affordances", async () => { - const bubbleMatch = css.match(/\.chat-message--failure\s*\{([^}]*)\}/); - const badgeMatch = css.match(/\.chat-message-failure-badge\s*\{([^}]*)\}/); - const detailsMatch = css.match(/\.chat-message-failure-details\s*\{([^}]*)\}/); - const linkMatch = css.match(/\.chat-message-failure-reference-link\s*\{([^}]*)\}/); - - expect(bubbleMatch?.[1]).toContain("background: var(--status-error-bg)"); - expect(bubbleMatch?.[1]).toContain("border: var(--btn-border-width) solid var(--status-error-bg-deep)"); - expect(badgeMatch?.[1]).toContain("background: var(--status-error-bg-deep)"); - expect(detailsMatch?.[1]).toContain("background: var(--status-error-bg-deep)"); - expect(linkMatch?.[1]).toContain("background: var(--status-error-bg-deep)"); - }); -}); - -describe("ChatView CSS — tablet assistant bubble width", () => { - const css = loadAllAppCss(); - - it("widens assistant, streaming, and failure bubbles on tablet containers while preserving user and mobile caps", async () => { - const baseMessageRule = css.match(/\.chat-message\s*\{([^}]*)\}/); - const userRule = css.match(/\.chat-message--user\s*\{([^}]*)\}/); - const tabletRule = css.match( - /@container\s+chat-view\s+\(min-width:\s*48\.0625rem\)\s+and\s+\(max-width:\s*64rem\)\s*\{([\s\S]*?)\n\}/, - ); - - expect(baseMessageRule?.[1]).toContain("max-width: 75%"); - expect(userRule?.[1]).toContain("align-self: flex-end"); - expect(userRule?.[1]).not.toContain("max-width"); - expect(tabletRule?.[1]).toMatch( - /\.chat-message--assistant,\s*\.chat-message--streaming,\s*\.chat-message--failure\s*\{[^}]*max-width:\s*88%/, - ); - expect(tabletRule?.[1]).not.toMatch(/\.chat-message--user\s*\{[^}]*max-width/); - expect(css).toMatch(/@media\s*\(max-width:\s*768px\)[\s\S]*?\.chat-message\s*\{[^}]*max-width:\s*90%/); - }); -}); - -describe("ChatView CSS — active state edge highlights", () => { - const css = loadAllAppCss(); - - function findRule(selector: string): string { - const escapedSelector = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const match = css.match(new RegExp(`${escapedSelector}\\s*\\{([^}]*)\\}`)); - expect(match).toBeTruthy(); - return match?.[1] ?? ""; - } - - function mobileRuleContains(selector: string, propertyPattern: RegExp): boolean { - const escapedSelector = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const mobileRegex = /@media[^{}]*\(max-width:\s*768px\)[^{]*\{([\s\S]*?)\n\}/g; - let match; - while ((match = mobileRegex.exec(css)) !== null) { - const ruleMatch = match[1].match(new RegExp(`${escapedSelector}\\s*\\{([^}]*)\\}`)); - if (ruleMatch && propertyPattern.test(ruleMatch[1])) { - return true; - } - } - return false; - } - - it("keeps scope-tab active tint without the removed bottom underline", async () => { - const activeScopeRule = findRule(".chat-sidebar-scope-btn--active"); - - expect(activeScopeRule).toContain("background: var(--card)"); - expect(activeScopeRule).toContain("color: var(--text)"); - expect(activeScopeRule).not.toContain("box-shadow"); - expect(activeScopeRule).not.toContain("inset"); - }); - - it("renders the header Direct/Rooms toggle with visible borders", async () => { - const headerScopeRule = findRule(".chat-view-header-scope-toggle"); - const headerScopeButtonRule = findRule(".chat-view-header-scope-toggle .chat-sidebar-scope-btn"); - const headerActiveScopeRule = findRule(".chat-view-header-scope-toggle .chat-sidebar-scope-btn--active"); - - expect(headerScopeRule).toContain("border: 1px solid var(--border)"); - expect(headerScopeRule).toContain("height: var(--view-header-content-row, 28px)"); - expect(headerScopeButtonRule).toContain("border: 1px solid transparent"); - expect(headerScopeButtonRule).toContain("height: 100%"); - expect(headerActiveScopeRule).toContain("border-color: var(--todo)"); - }); - - it("collapses header Direct/Rooms labels to icons at very narrow widths", async () => { - expect(css).toMatch(/@media\s*\(max-width:\s*460px\)[\s\S]*?\.chat-view-header-scope-toggle\s*\{[^}]*width:\s*72px/); - expect(css).toMatch(/@media\s*\(max-width:\s*460px\)[\s\S]*?\.chat-view-header-scope-toggle \.chat-sidebar-scope-btn span\s*\{[^}]*clip:\s*rect\(0 0 0 0\)/); - }); - - it("keeps active chat-row background without the removed left edge or offset", async () => { - const activeSessionRule = findRule(".chat-session-item--active"); - - expect(activeSessionRule).toContain("background: color-mix(in srgb, var(--todo) 12%, transparent)"); - expect(activeSessionRule).not.toContain("border-left"); - expect(activeSessionRule).not.toContain("padding-left: calc(var(--space-md) - (var(--btn-border-width) * 3))"); - }); - - it("does not reintroduce either removed highlight in mobile rules", async () => { - expect(mobileRuleContains(".chat-sidebar-scope-btn--active", /box-shadow\s*:\s*inset/)).toBe(false); - expect(mobileRuleContains(".chat-session-item--active", /border-left\s*:/)).toBe(false); - expect(mobileRuleContains(".chat-session-item--active", /padding-left\s*:\s*calc\(var\(--space-md\)\s*-\s*\(var\(--btn-border-width\)\s*\*\s*3\)\)/)).toBe(false); - }); -}); - -describe("FN-3911 chat session list layout", () => { - const css = loadAllAppCss(); - - it("reserves right padding on title and preview rows so text clears the delete button", async () => { - const titleMatch = css.match(/\.chat-session-title\s*\{([^}]*)\}/); - const previewMatch = css.match(/\.chat-session-preview\s*\{([^}]*)\}/); - expect(titleMatch).toBeTruthy(); - expect(previewMatch).toBeTruthy(); - expect(titleMatch?.[1]).toMatch(/padding-right:\s*calc\(var\(--space-md\)\s*\*\s*3\)/); - expect(previewMatch?.[1]).toMatch(/padding-right:\s*calc\(var\(--space-md\)\s*\*\s*3\)/); - }); - - it("FN-4385: keeps mobile title/preview clearance matched to compact delete button", async () => { - expect(css).toMatch( - /@media\s*\(max-width:\s*768px\)[\s\S]*?\.chat-session-title,\s*\.chat-session-preview\s*\{\s*padding-right:\s*calc\(var\(--space-md\)\s*\*\s*3\);\s*\}/, - ); - }); -}); - -describe("Chat Session Delete Button CSS", () => { - const css = loadAllAppCss(); - - it(".chat-session-delete-btn exists with opacity: 0", async () => { - const match = css.match(/\.chat-session-delete-btn\s*\{([^}]*)\}/); - expect(match).toBeTruthy(); - expect(match![1]).toContain("opacity: 0"); - }); - - it(".chat-session-item:hover .chat-session-delete-btn has opacity: 1", async () => { - const match = css.match(/\.chat-session-item:hover\s*\.chat-session-delete-btn\s*\{([^}]*)\}/); - expect(match).toBeTruthy(); - expect(match![1]).toContain("opacity: 1"); - }); - - it("FN-4352: mobile delete button stays visible without min-size inflation", async () => { - const mobileRegex = /@media[^{]*\(max-width:\s*768px\)[^{]*\{([\s\S]*?)\n\}/g; - let match; - let deleteRule = ""; - while ((match = mobileRegex.exec(css)) !== null) { - const mediaContent = match[1]; - if (mediaContent.includes(".chat-session-delete-btn")) { - deleteRule = mediaContent.match(/\.chat-session-delete-btn\s*\{([^}]*)\}/)?.[1] ?? ""; - if (deleteRule) break; - } - } - - expect(deleteRule).toContain("opacity: 1"); - expect(deleteRule).not.toContain("min-width:"); - expect(deleteRule).not.toContain("min-height:"); - }); -}); - -describe("ChatView CSS — mobile thread switcher", () => { - const css = loadAllAppCss(); - - it("includes mobile session switcher trigger and dropdown tokenized contracts", async () => { - const triggerMatch = css.match(/\.chat-mobile-session-trigger\s*\{([^}]*)\}/); - const triggerIconMatch = css.match(/\.chat-mobile-session-trigger\s*>\s*svg\s*\{([^}]*)\}/); - const dropdownMatch = css.match(/\.chat-mobile-session-dropdown\s*\{([^}]*)\}/); - const optionMatch = css.match(/\.chat-mobile-session-option\s*\{([^}]*)\}/); - const optionTitleMatch = css.match(/\.chat-mobile-session-option-title\s*\{([^}]*)\}/); - expect(triggerMatch).toBeTruthy(); - expect(triggerIconMatch).toBeTruthy(); - expect(dropdownMatch).toBeTruthy(); - expect(optionMatch).toBeTruthy(); - expect(optionTitleMatch).toBeTruthy(); - expect(triggerMatch?.[1]).toContain("min-height: calc(var(--space-lg) * 2 + var(--space-xs))"); - expect(triggerMatch?.[1]).toContain("min-width: 0"); - expect(triggerMatch?.[1]).toContain("padding: var(--space-xs) var(--space-sm)"); - expect(triggerMatch?.[1]).toContain("font: inherit"); - expect(triggerMatch?.[1]).toContain("line-height: normal"); - expect(triggerMatch?.[1]).toContain("text-align: left"); - expect(triggerIconMatch?.[1]).toContain("width: var(--icon-size-md)"); - expect(triggerIconMatch?.[1]).toContain("height: var(--icon-size-md)"); - expect(dropdownMatch?.[1]).toContain("background: var(--surface)"); - expect(dropdownMatch?.[1]).toContain("border: 1px solid var(--border)"); - expect(optionMatch?.[1]).toContain("min-height: calc(var(--space-lg) * 2.25)"); - expect(optionMatch?.[1]).toContain("align-items: flex-start"); - expect(optionMatch?.[1]).toContain("line-height: normal"); - expect(optionTitleMatch?.[1]).toContain("display: block"); - expect(optionTitleMatch?.[1]).toContain("line-height: normal"); - expect(optionTitleMatch?.[1]).toContain("white-space: normal"); - expect(optionTitleMatch?.[1]).toContain("overflow-wrap: anywhere"); - }); - - it("keeps mobile override for header identity overflow visible so dropdown can render", async () => { - expect(css).toMatch(/@media\s*\(max-width:\s*768px\)[\s\S]*?\.chat-thread-header-identity\s*\{[^}]*overflow:\s*visible;/); - }); -}); - -describe("ChatView CSS — nested flexbox scrolling fix", () => { - const css = loadAllAppCss(); - - it(".chat-session-list has min-height: 0 for proper vertical scrolling", async () => { - const match = css.match(/\.chat-session-list\s*\{([^}]*)\}/); - expect(match).toBeTruthy(); - expect(match![1]).toContain("min-height: 0"); - }); - - it(".chat-thread has min-height: 0 for proper vertical scrolling", async () => { - const match = css.match(/\.chat-thread\s*\{([^}]*)\}/); - expect(match).toBeTruthy(); - expect(match![1]).toContain("min-height: 0"); - }); - - it(".chat-messages has min-height: 0 for proper vertical scrolling", async () => { - const match = css.match(/\.chat-messages\s*\{([^}]*)\}/); - expect(match).toBeTruthy(); - expect(match![1]).toContain("min-height: 0"); - }); -}); - diff --git a/packages/engine/src/__tests__/notifier.runtime.test.ts b/packages/engine/src/__tests__/notifier.runtime.test.ts new file mode 100644 index 0000000000..084581bfd7 --- /dev/null +++ b/packages/engine/src/__tests__/notifier.runtime.test.ts @@ -0,0 +1,810 @@ +/* +FNXC:EngineTests 2026-06-25-17:44: +Notifier runtime suite split extracts the later NtfyNotifier reconfiguration, error, deduplication, runtime wiring, URL, stop, edge-case, and event-filtering describe blocks from notifier.test.ts so both sibling suites stay under MAX_LINES without weakening assertions. +*/ + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import type { MergeResult } from "@fusion/core"; +import { NtfyNotifier, notifyFallbackUsed } from "../notifier.js"; +import { NotificationService } from "../notification/notification-service.js"; +import { MockTaskStore, createTask, flushAsyncWork } from "./notifier.test-harness.js"; + +vi.mock("../logger.js", () => ({ + schedulerLog: { log: vi.fn(), error: vi.fn() }, +})); + +describe("NtfyNotifier runtime behaviors", () => { + let store: MockTaskStore; + let notifier: NtfyNotifier; + let fetchMock: ReturnType; + + beforeEach(async () => { + store = new MockTaskStore(); + fetchMock = vi.fn(); + global.fetch = fetchMock; + }); + + afterEach(() => { + if (notifier) { + notifier.stop(); + } + vi.restoreAllMocks(); + }); + + describe("runtime reconfiguration", () => { + it("starts sending notifications when enabled at runtime", async () => { + store.setSettings({ ntfyEnabled: false, ntfyTopic: "test-topic" }); + notifier = new NtfyNotifier(store); + await notifier.start(); + + // Initially disabled + store.triggerTaskMoved(createTask("FN-001", "Test Task"), "in-progress", "in-review"); + await flushAsyncWork(); + expect(fetchMock).not.toHaveBeenCalled(); + + // Enable at runtime + fetchMock.mockResolvedValue({ ok: true }); + store.setSettings({ ntfyEnabled: true }); + + store.triggerTaskMoved(createTask("FN-002", "Test Task 2"), "in-progress", "in-review"); + await flushAsyncWork(); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("stops sending notifications when disabled at runtime", async () => { + store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic" }); + fetchMock.mockResolvedValue({ ok: true }); + notifier = new NtfyNotifier(store); + await notifier.start(); + + // Initially enabled + store.triggerTaskMoved(createTask("FN-001", "Test Task"), "in-progress", "in-review"); + await flushAsyncWork(); + expect(fetchMock).toHaveBeenCalledTimes(1); + + // Disable at runtime + store.setSettings({ ntfyEnabled: false }); + + store.triggerTaskMoved(createTask("FN-002", "Test Task 2"), "in-progress", "in-review"); + await flushAsyncWork(); + expect(fetchMock).toHaveBeenCalledTimes(1); // No new calls + }); + + it("uses updated topic when changed at runtime", async () => { + store.setSettings({ ntfyEnabled: true, ntfyTopic: "old-topic" }); + fetchMock.mockResolvedValue({ ok: true }); + notifier = new NtfyNotifier(store); + await notifier.start(); + + store.triggerTaskMoved(createTask("FN-001", "Test Task"), "in-progress", "in-review"); + await flushAsyncWork(); + expect(fetchMock).toHaveBeenCalledWith("https://ntfy.sh/old-topic", expect.any(Object)); + + // Change topic + store.setSettings({ ntfyTopic: "new-topic" }); + + store.triggerTaskMoved(createTask("FN-002", "Test Task 2"), "in-progress", "in-review"); + await flushAsyncWork(); + expect(fetchMock).toHaveBeenLastCalledWith("https://ntfy.sh/new-topic", expect.any(Object)); + }); + }); + + describe("error handling", () => { + it("catches and logs fetch errors without throwing", async () => { + store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic" }); + fetchMock.mockRejectedValue(new Error("Network error")); + + notifier = new NtfyNotifier(store); + await notifier.start(); + + // Should not throw + store.triggerTaskMoved(createTask("FN-001", "Test Task"), "in-progress", "in-review"); + await flushAsyncWork(); + + expect(fetchMock).toHaveBeenCalled(); + }); + + it("handles HTTP error responses without throwing", async () => { + store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic" }); + fetchMock.mockResolvedValue({ ok: false, status: 500, statusText: "Server Error" }); + + notifier = new NtfyNotifier(store); + await notifier.start(); + + // Should not throw + store.triggerTaskMoved(createTask("FN-001", "Test Task"), "in-progress", "in-review"); + await flushAsyncWork(); + + expect(fetchMock).toHaveBeenCalled(); + }); + }); + + describe("deduplication", () => { + beforeEach(() => { + store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic" }); + fetchMock.mockResolvedValue({ ok: true }); + }); + + it("prevents duplicate notifications for the same event type", async () => { + notifier = new NtfyNotifier(store); + await notifier.start(); + + const task = createTask("FN-001", "Test Task"); + + // Multiple in-review events for the same task + store.triggerTaskMoved(task, "in-progress", "in-review"); + store.triggerTaskMoved(task, "in-progress", "in-review"); + store.triggerTaskMoved(task, "in-progress", "in-review"); + + await flushAsyncWork(); + + // Should only send one notification due to deduplication + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("prevents duplicate awaiting-approval notifications for the same task", async () => { + notifier = new NtfyNotifier(store); + await notifier.start(); + + const task = createTask("FN-004", "Approval Task", "awaiting-approval"); + + store.triggerTaskUpdated(task); + store.triggerTaskUpdated(task); + store.triggerTaskUpdated(task); + + await flushAsyncWork(); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledWith( + "https://ntfy.sh/test-topic", + expect.objectContaining({ + headers: expect.objectContaining({ + "Title": "Plan needs approval for FN-004", + }), + }), + ); + }); + + it("prevents duplicate awaiting-user-review notifications for the same task", async () => { + notifier = new NtfyNotifier(store); + await notifier.start(); + + const task = createTask("FN-005", "User Review Task", "awaiting-user-review"); + + store.triggerTaskUpdated(task); + store.triggerTaskUpdated(task); + store.triggerTaskUpdated(task); + + await flushAsyncWork(); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledWith( + "https://ntfy.sh/test-topic", + expect.objectContaining({ + headers: expect.objectContaining({ + "Title": "User review needed for FN-005", + }), + }), + ); + }); + + it("allows different event types for the same task", async () => { + notifier = new NtfyNotifier(store); + await notifier.start(); + + const task = createTask("FN-001", "Test Task"); + + // First: in-review notification + store.triggerTaskMoved(task, "in-progress", "in-review"); + await flushAsyncWork(); + expect(fetchMock).toHaveBeenCalledTimes(1); + + // Second: merged notification (different event type - should be allowed) + const mergeResult: MergeResult = { + task, + branch: "fusion/fn-001", + merged: true, + worktreeRemoved: true, + branchDeleted: true, + }; + store.triggerTaskMerged(mergeResult); + await flushAsyncWork(); + + // Should have two notifications now + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it("allows awaiting-approval alongside other event types for the same task", async () => { + notifier = new NtfyNotifier(store); + await notifier.start(); + + const task = createTask("FN-005", "Approval + Failure"); + + store.triggerTaskUpdated({ ...task, status: "awaiting-approval" }); + await flushAsyncWork(); + expect(fetchMock).toHaveBeenCalledTimes(1); + + store.triggerTaskUpdated({ ...task, status: "failed" }); + await flushAsyncWork(); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it("sends notification only once on merge when task:moved and task:merged both fire", async () => { + notifier = new NtfyNotifier(store); + await notifier.start(); + + const task = createTask("FN-001", "Test Task"); + const mergeResult: MergeResult = { + task, + branch: "fusion/fn-001", + merged: true, + worktreeRemoved: true, + branchDeleted: true, + }; + + // completeTask() emits task:moved to done before task:merged + store.triggerTaskMoved(task, "in-review", "done"); + store.triggerTaskMerged(mergeResult); + + await flushAsyncWork(); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledWith( + "https://ntfy.sh/test-topic", + expect.objectContaining({ + method: "POST", + headers: expect.objectContaining({ + "Title": "Task FN-001 merged", + "Priority": "default", + }), + body: 'Task "Test Task" has been merged to main', + }) + ); + }); + + it("prevents duplicate task:merged events for the same task", async () => { + notifier = new NtfyNotifier(store); + await notifier.start(); + + const task = createTask("FN-001", "Test Task"); + const mergeResult: MergeResult = { + task, + branch: "fusion/fn-001", + merged: true, + worktreeRemoved: true, + branchDeleted: true, + }; + + // Multiple merged events for the same task + store.triggerTaskMerged(mergeResult); + store.triggerTaskMerged(mergeResult); + store.triggerTaskMerged(mergeResult); + + await flushAsyncWork(); + + // Should only send one notification + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("emits a single merged notification when notifier shares the same already-started NotificationService (ProjectEngine wiring)", async () => { + const sharedService = new NotificationService(store, { projectId: "proj-1" }); + await sharedService.start(); + + notifier = new NtfyNotifier(store, { projectId: "proj-1" }, sharedService); + await notifier.start(); + + const task = createTask("FN-777", "Single Merge Notification"); + const mergeResult: MergeResult = { + task, + branch: "fusion/fn-777", + merged: true, + worktreeRemoved: true, + branchDeleted: true, + }; + + store.triggerTaskMerged(mergeResult); + await flushAsyncWork(); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledWith( + "https://ntfy.sh/test-topic", + expect.objectContaining({ + headers: expect.objectContaining({ + Title: "Task FN-777 merged", + }), + }), + ); + + await sharedService.stop(); + }); + + it("dispatches and deduplicates fallback-used notifications", async () => { + notifier = new NtfyNotifier(store); + await notifier.start(); + + await notifyFallbackUsed({ + primaryModel: "anthropic/claude-sonnet-4-5", + fallbackModel: "openai/gpt-4o", + triggerPoint: "session-creation", + taskId: "FN-900", + taskTitle: "Fallback task", + }); + await notifyFallbackUsed({ + primaryModel: "anthropic/claude-sonnet-4-5", + fallbackModel: "openai/gpt-4o", + triggerPoint: "session-creation", + taskId: "FN-900", + taskTitle: "Fallback task", + }); + await flushAsyncWork(); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledWith( + "https://ntfy.sh/test-topic", + expect.objectContaining({ + body: expect.stringContaining("switched from anthropic/claude-sonnet-4-5 to openai/gpt-4o"), + }), + ); + }); + + it("allows notifications for different tasks independently", async () => { + notifier = new NtfyNotifier(store); + await notifier.start(); + + const task1 = createTask("FN-001", "Test Task 1"); + const task2 = createTask("FN-002", "Test Task 2"); + + store.triggerTaskMoved(task1, "in-progress", "in-review"); + store.triggerTaskMoved(task2, "in-progress", "in-review"); + + await flushAsyncWork(); + + // Different tasks should each get their own notification + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + }); + + describe("dashboard runtime wiring", () => { + /** + * These tests simulate the pattern used in packages/cli/src/commands/dashboard.ts + * where the NtfyNotifier is constructed with an optional projectId resolved + * from the central project registry. When a registered project is found, + * deep links include ?project=...&task=...; when no project is registered + * (legacy / single-project mode), links fall back to ?task=... only. + */ + beforeEach(() => { + fetchMock.mockResolvedValue({ ok: true }); + }); + + it("produces project-aware deep links when constructed with registered project ID", async () => { + store.setSettings({ + ntfyEnabled: true, + ntfyTopic: "test-topic", + ntfyDashboardHost: "http://localhost:3000", + }); + + // Simulates: const notifier = new NtfyNotifier(store, { projectId: registered.id }); + notifier = new NtfyNotifier(store, { projectId: "proj_abc123" }); + await notifier.start(); + + store.triggerTaskMoved(createTask("FN-001", "Test Task"), "in-progress", "in-review"); + await flushAsyncWork(); + + expect(fetchMock).toHaveBeenCalledWith( + "https://ntfy.sh/test-topic", + expect.objectContaining({ + headers: expect.objectContaining({ + "Click": "http://localhost:3000/?project=proj_abc123&task=FN-001", + }), + }), + ); + }); + + it("produces task-only deep links when no project ID is available (legacy mode)", async () => { + store.setSettings({ + ntfyEnabled: true, + ntfyTopic: "test-topic", + ntfyDashboardHost: "http://localhost:3000", + }); + + // Simulates: const notifier = new NtfyNotifier(store); // no projectId + notifier = new NtfyNotifier(store); + await notifier.start(); + + store.triggerTaskMoved(createTask("FN-001", "Test Task"), "in-progress", "in-review"); + await flushAsyncWork(); + + expect(fetchMock).toHaveBeenCalledWith( + "https://ntfy.sh/test-topic", + expect.objectContaining({ + headers: expect.objectContaining({ + "Click": "http://localhost:3000/?task=FN-001", + }), + }), + ); + // Verify no "project=" in the URL + const callArgs = fetchMock.mock.calls[0][1] as { headers: Record }; + expect(callArgs.headers["Click"]).not.toContain("project="); + }); + + it("produces project-aware deep links for all notification event types", async () => { + store.setSettings({ + ntfyEnabled: true, + ntfyTopic: "test-topic", + ntfyDashboardHost: "https://fusion.example.com", + }); + + notifier = new NtfyNotifier(store, { projectId: "proj_xyz" }); + await notifier.start(); + + // in-review event + store.triggerTaskMoved(createTask("FN-001", "Task A"), "in-progress", "in-review"); + await flushAsyncWork(); + + // merged event + const mergeResult: MergeResult = { + task: createTask("FN-001", "Task A"), + branch: "fusion/fn-001", + merged: true, + worktreeRemoved: true, + branchDeleted: true, + }; + store.triggerTaskMerged(mergeResult); + await flushAsyncWork(); + + // Verify both calls include project + const calls = fetchMock.mock.calls; + for (const call of calls) { + const headers = call[1].headers as Record; + expect(headers["Click"]).toContain("project=proj_xyz"); + } + }); + }); + + describe("custom base URL", () => { + it("uses custom ntfy base URL when provided in notifier options", async () => { + store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic" }); + fetchMock.mockResolvedValue({ ok: true }); + + notifier = new NtfyNotifier(store, { ntfyBaseUrl: "https://my-ntfy.example.com" }); + await notifier.start(); + + store.triggerTaskMoved(createTask("FN-001", "Test Task"), "in-progress", "in-review"); + + await flushAsyncWork(); + + expect(fetchMock).toHaveBeenCalledWith( + "https://my-ntfy.example.com/test-topic", + expect.any(Object) + ); + }); + + it("uses ntfyBaseUrl from settings when configured", async () => { + store.setSettings({ + ntfyEnabled: true, + ntfyTopic: "test-topic", + ntfyBaseUrl: "https://ntfy.internal.example///", + }); + fetchMock.mockResolvedValue({ ok: true }); + + notifier = new NtfyNotifier(store); + await notifier.start(); + + store.triggerTaskMoved(createTask("FN-101", "Configured URL Task"), "in-progress", "in-review"); + await flushAsyncWork(); + + expect(fetchMock).toHaveBeenCalledWith( + "https://ntfy.internal.example/test-topic", + expect.any(Object), + ); + }); + + it("falls back to default ntfy.sh when settings ntfyBaseUrl is blank", async () => { + store.setSettings({ + ntfyEnabled: true, + ntfyTopic: "test-topic", + ntfyBaseUrl: " ", + }); + fetchMock.mockResolvedValue({ ok: true }); + + notifier = new NtfyNotifier(store); + await notifier.start(); + + store.triggerTaskMoved(createTask("FN-102", "Blank URL Task"), "in-progress", "in-review"); + await flushAsyncWork(); + + expect(fetchMock).toHaveBeenCalledWith( + "https://ntfy.sh/test-topic", + expect.any(Object), + ); + }); + + it("applies updated ntfyBaseUrl from settings changes at runtime", async () => { + store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic" }); + fetchMock.mockResolvedValue({ ok: true }); + + notifier = new NtfyNotifier(store); + await notifier.start(); + + store.triggerTaskMoved(createTask("FN-103", "Before Update"), "in-progress", "in-review"); + await flushAsyncWork(); + expect(fetchMock).toHaveBeenLastCalledWith("https://ntfy.sh/test-topic", expect.any(Object)); + + store.setSettings({ ntfyBaseUrl: "https://ntfy.changed.example" }); + store.triggerTaskMoved(createTask("FN-104", "After Update"), "in-progress", "in-review"); + await flushAsyncWork(); + + expect(fetchMock).toHaveBeenLastCalledWith( + "https://ntfy.changed.example/test-topic", + expect.any(Object), + ); + }); + }); + + describe("stop()", () => { + it("stops listening to events after stop() is called", async () => { + store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic" }); + fetchMock.mockResolvedValue({ ok: true }); + + notifier = new NtfyNotifier(store); + await notifier.start(); + + store.triggerTaskMoved(createTask("FN-001", "Test Task"), "in-progress", "in-review"); + await flushAsyncWork(); + expect(fetchMock).toHaveBeenCalledTimes(1); + + notifier.stop(); + + store.triggerTaskMoved(createTask("FN-002", "Test Task 2"), "in-progress", "in-review"); + await flushAsyncWork(); + + // Should not increase after stop + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + }); + + describe("edge cases", () => { + it("allows in-review and failed notifications for the same task", async () => { + store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic" }); + fetchMock.mockResolvedValue({ ok: true }); + notifier = new NtfyNotifier(store); + await notifier.start(); + + const task = createTask("FN-001", "Test Task"); + + // First: in-review notification + store.triggerTaskMoved(task, "in-progress", "in-review"); + await flushAsyncWork(); + expect(fetchMock).toHaveBeenCalledTimes(1); + + // Second: failed notification (different event type - should be allowed) + const failedTask = { ...task, status: "failed" }; + store.triggerTaskUpdated(failedTask); + await flushAsyncWork(); + + // Should have two notifications + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it("does not notify on task:moved to columns other than in-review", async () => { + store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic" }); + fetchMock.mockResolvedValue({ ok: true }); + notifier = new NtfyNotifier(store); + await notifier.start(); + + // Move to todo - should not notify + store.triggerTaskMoved(createTask("FN-001", "Test Task"), "triage", "todo"); + await flushAsyncWork(); + + // Move to in-progress - should not notify + store.triggerTaskMoved(createTask("FN-002", "Test Task 2"), "todo", "in-progress"); + await flushAsyncWork(); + + // Move to done - should not notify (merged notification comes from task:merged) + store.triggerTaskMoved(createTask("FN-003", "Test Task 3"), "in-review", "done"); + await flushAsyncWork(); + + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("does not notify on task:updated when status is neither failed nor awaiting-approval", async () => { + store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic" }); + fetchMock.mockResolvedValue({ ok: true }); + notifier = new NtfyNotifier(store); + await notifier.start(); + + const task = createTask("FN-001", "Test Task", "in-progress"); + store.triggerTaskUpdated(task); + await flushAsyncWork(); + + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("handles empty topic gracefully", async () => { + store.setSettings({ ntfyEnabled: true, ntfyTopic: "" }); + fetchMock.mockResolvedValue({ ok: true }); + notifier = new NtfyNotifier(store); + await notifier.start(); + + store.triggerTaskMoved(createTask("FN-001", "Test Task"), "in-progress", "in-review"); + await flushAsyncWork(); + + // Empty topic should be treated as no topic + expect(fetchMock).not.toHaveBeenCalled(); + }); + }); + + describe("event filtering", () => { + beforeEach(() => { + store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic" }); + fetchMock.mockResolvedValue({ ok: true }); + }); + + it("does not send in-review notification when 'in-review' is not in ntfyEvents", async () => { + store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic", ntfyEvents: ["merged", "failed", "awaiting-approval"] }); + notifier = new NtfyNotifier(store); + await notifier.start(); + + store.triggerTaskMoved(createTask("FN-001", "Test Task"), "in-progress", "in-review"); + await flushAsyncWork(); + + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("does not send merged notification when 'merged' is not in ntfyEvents", async () => { + store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic", ntfyEvents: ["in-review", "failed", "awaiting-approval"] }); + notifier = new NtfyNotifier(store); + await notifier.start(); + + const mergeResult: MergeResult = { + task: createTask("FN-001", "Test Task"), + branch: "fusion/fn-001", + merged: true, + worktreeRemoved: true, + branchDeleted: true, + }; + store.triggerTaskMerged(mergeResult); + await flushAsyncWork(); + + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("does not send failed notification when 'failed' is not in ntfyEvents", async () => { + store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic", ntfyEvents: ["in-review", "merged", "awaiting-approval"] }); + notifier = new NtfyNotifier(store); + await notifier.start(); + + const failedTask = createTask("FN-001", "Test Task", "failed"); + store.triggerTaskUpdated(failedTask); + await flushAsyncWork(); + + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("does not send awaiting-approval notification when 'awaiting-approval' is not in ntfyEvents", async () => { + store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic", ntfyEvents: ["in-review", "merged", "failed"] }); + notifier = new NtfyNotifier(store); + await notifier.start(); + + const awaitingApprovalTask = createTask("FN-006", "Approval Task", "awaiting-approval"); + store.triggerTaskUpdated(awaitingApprovalTask); + await flushAsyncWork(); + + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("does not send awaiting-user-review notification when 'awaiting-user-review' is not in ntfyEvents", async () => { + store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic", ntfyEvents: ["in-review", "merged", "failed", "awaiting-approval"] }); + notifier = new NtfyNotifier(store); + await notifier.start(); + + const awaitingUserReviewTask = createTask("FN-007", "User Review Task", "awaiting-user-review"); + store.triggerTaskUpdated(awaitingUserReviewTask); + await flushAsyncWork(); + + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("sends notification for enabled events while others are disabled", async () => { + store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic", ntfyEvents: ["in-review"] }); + notifier = new NtfyNotifier(store); + await notifier.start(); + + // in-review - should send + store.triggerTaskMoved(createTask("FN-001", "Test Task"), "in-progress", "in-review"); + await flushAsyncWork(); + expect(fetchMock).toHaveBeenCalledTimes(1); + + // merged - should NOT send + const mergeResult: MergeResult = { + task: createTask("FN-002", "Test Task 2"), + branch: "fusion/fn-002", + merged: true, + worktreeRemoved: true, + branchDeleted: true, + }; + store.triggerTaskMerged(mergeResult); + await flushAsyncWork(); + expect(fetchMock).toHaveBeenCalledTimes(1); // Still 1, no new call + + // failed - should NOT send + const failedTask = createTask("FN-003", "Test Task 3", "failed"); + store.triggerTaskUpdated(failedTask); + await flushAsyncWork(); + expect(fetchMock).toHaveBeenCalledTimes(1); // Still 1, no new call + + // awaiting-approval - should NOT send + const awaitingApprovalTask = createTask("FN-004", "Test Task 4", "awaiting-approval"); + store.triggerTaskUpdated(awaitingApprovalTask); + await flushAsyncWork(); + expect(fetchMock).toHaveBeenCalledTimes(1); // Still 1, no new call + + // awaiting-user-review - should NOT send + const awaitingUserReviewTask = createTask("FN-005", "Test Task 5", "awaiting-user-review"); + store.triggerTaskUpdated(awaitingUserReviewTask); + await flushAsyncWork(); + expect(fetchMock).toHaveBeenCalledTimes(1); // Still 1, no new call + }); + + it("defaults to all events when ntfyEvents is undefined", async () => { + store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic", ntfyEvents: undefined }); + notifier = new NtfyNotifier(store); + await notifier.start(); + + store.triggerTaskMoved(createTask("FN-001", "Test Task"), "in-progress", "in-review"); + await flushAsyncWork(); + expect(fetchMock).toHaveBeenCalledTimes(1); + + const mergeResult: MergeResult = { + task: createTask("FN-002", "Test Task 2"), + branch: "fusion/fn-002", + merged: true, + worktreeRemoved: true, + branchDeleted: true, + }; + store.triggerTaskMerged(mergeResult); + await flushAsyncWork(); + expect(fetchMock).toHaveBeenCalledTimes(2); + + const failedTask = createTask("FN-003", "Test Task 3", "failed"); + store.triggerTaskUpdated(failedTask); + await flushAsyncWork(); + expect(fetchMock).toHaveBeenCalledTimes(3); + + const awaitingApprovalTask = createTask("FN-004", "Test Task 4", "awaiting-approval"); + store.triggerTaskUpdated(awaitingApprovalTask); + await flushAsyncWork(); + expect(fetchMock).toHaveBeenCalledTimes(4); + + const awaitingUserReviewTask = createTask("FN-005", "Test Task 5", "awaiting-user-review"); + store.triggerTaskUpdated(awaitingUserReviewTask); + await flushAsyncWork(); + expect(fetchMock).toHaveBeenCalledTimes(5); + }); + + it("updates notifications when ntfyEvents changes at runtime", async () => { + store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic", ntfyEvents: ["in-review", "merged", "failed", "awaiting-approval", "awaiting-user-review", "planning-awaiting-input"] }); + notifier = new NtfyNotifier(store); + await notifier.start(); + + // Initially all events enabled + store.triggerTaskMoved(createTask("FN-001", "Test Task"), "in-progress", "in-review"); + await flushAsyncWork(); + expect(fetchMock).toHaveBeenCalledTimes(1); + + // Disable in-review + store.setSettings({ ntfyEvents: ["merged", "failed", "awaiting-approval", "awaiting-user-review"] }); + + store.triggerTaskMoved(createTask("FN-002", "Test Task 2"), "in-progress", "in-review"); + await flushAsyncWork(); + expect(fetchMock).toHaveBeenCalledTimes(1); // No new call for in-review + + // Enable in-review again + store.setSettings({ ntfyEvents: ["in-review", "merged", "failed", "awaiting-approval", "awaiting-user-review", "planning-awaiting-input"] }); + + store.triggerTaskMoved(createTask("FN-003", "Test Task 3"), "in-progress", "in-review"); + await flushAsyncWork(); + expect(fetchMock).toHaveBeenCalledTimes(2); // New call for in-review + }); + }); +}); diff --git a/packages/engine/src/__tests__/notifier.test-harness.ts b/packages/engine/src/__tests__/notifier.test-harness.ts new file mode 100644 index 0000000000..593df3d424 --- /dev/null +++ b/packages/engine/src/__tests__/notifier.test-harness.ts @@ -0,0 +1,71 @@ +import { EventEmitter } from "node:events"; +import { expect, vi } from "vitest"; +import type { Task, Column, MergeResult, Settings } from "@fusion/core"; + +/* +FNXC:EngineTests 2026-06-25-17:44: +Shared notifier test harness for the FN-7035 suite split. MockTaskStore, createTask, and flushAsyncWork stay in one helper so notifier.test.ts and notifier.runtime.test.ts can split whole describe blocks under the line-count cap without duplicating event-store behavior. +*/ + +interface MockTaskStoreEvents { + "task:moved": [{ task: Task; from: Column; to: Column }]; + "task:updated": [Task]; + "task:merged": [MergeResult]; + "settings:updated": [{ settings: Settings; previous: Settings }]; +} + +export async function flushAsyncWork(): Promise { + await vi.waitFor(() => { + expect(true).toBe(true); + }); +} + +export class MockTaskStore extends EventEmitter { + private settings: Settings = { + maxConcurrent: 2, + maxWorktrees: 4, + pollIntervalMs: 15000, + groupOverlappingFiles: false, + autoMerge: true, + ntfyEnabled: false, + ntfyTopic: undefined, + failureNotificationMode: "all", + failureNotificationDelayMs: 0, + }; + + getSettings(): Settings { + return { ...this.settings }; + } + + setSettings(settings: Partial): void { + const previous = { ...this.settings }; + this.settings = { ...this.settings, ...settings }; + this.emit("settings:updated", { settings: this.settings, previous }); + } + + triggerTaskMoved(task: Task, from: Column, to: Column): void { + this.emit("task:moved", { task, from, to }); + } + + triggerTaskUpdated(task: Task): void { + this.emit("task:updated", task); + } + + triggerTaskMerged(result: MergeResult): void { + this.emit("task:merged", result); + } +} + +export const createTask = (id: string, title?: string, status?: string): Task => ({ + id, + title, + description: "Test task", + column: "in-progress", + dependencies: [], + steps: [], + currentStep: 0, + status, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + log: [], +}); diff --git a/packages/engine/src/__tests__/notifier.test.ts b/packages/engine/src/__tests__/notifier.test.ts index 7d955f6c16..e14322a584 100644 --- a/packages/engine/src/__tests__/notifier.test.ts +++ b/packages/engine/src/__tests__/notifier.test.ts @@ -1,73 +1,20 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { EventEmitter } from "node:events"; -import type { Task, Column, MergeResult, Settings } from "@fusion/core"; +import type { MergeResult } from "@fusion/core"; import { NtfyNotifier, DEFAULT_NTFY_EVENTS, buildNtfyClickUrl, isNtfyEventEnabled, resolveNtfyEvents, - notifyFallbackUsed, sendNtfyNotificationWithResult, } from "../notifier.js"; -import { NotificationService } from "../notification/notification-service.js"; import { NtfyNotificationProvider } from "../notification/ntfy-provider.js"; +import { MockTaskStore, createTask, flushAsyncWork } from "./notifier.test-harness.js"; -// Mock the logger vi.mock("../logger.js", () => ({ schedulerLog: { log: vi.fn(), error: vi.fn() }, })); -interface MockTaskStoreEvents { - "task:moved": [{ task: Task; from: Column; to: Column }]; - "task:updated": [Task]; - "task:merged": [MergeResult]; - "settings:updated": [{ settings: Settings; previous: Settings }]; -} - -async function flushAsyncWork(): Promise { - await vi.waitFor(() => { - expect(true).toBe(true); - }); -} - -class MockTaskStore extends EventEmitter { - private settings: Settings = { - maxConcurrent: 2, - maxWorktrees: 4, - pollIntervalMs: 15000, - groupOverlappingFiles: false, - autoMerge: true, - ntfyEnabled: false, - ntfyTopic: undefined, - failureNotificationMode: "all", - failureNotificationDelayMs: 0, - }; - - getSettings(): Settings { - return { ...this.settings }; - } - - setSettings(settings: Partial): void { - const previous = { ...this.settings }; - this.settings = { ...this.settings, ...settings }; - this.emit("settings:updated", { settings: this.settings, previous }); - } - - // Helper to trigger events - triggerTaskMoved(task: Task, from: Column, to: Column): void { - this.emit("task:moved", { task, from, to }); - } - - triggerTaskUpdated(task: Task): void { - this.emit("task:updated", task); - } - - triggerTaskMerged(result: MergeResult): void { - this.emit("task:merged", result); - } -} - describe("Ntfy notifier helpers", () => { it("includes mailbox message events in default events", () => { expect(DEFAULT_NTFY_EVENTS).toContain("planning-awaiting-input"); @@ -589,20 +536,6 @@ describe("NtfyNotifier", () => { vi.restoreAllMocks(); }); - const createTask = (id: string, title?: string, status?: string): Task => ({ - id, - title, - description: "Test task", - column: "in-progress", - dependencies: [], - steps: [], - currentStep: 0, - status, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - log: [], - }); - describe("when disabled", () => { it("does not send any notifications when ntfyEnabled is false", async () => { store.setSettings({ ntfyEnabled: false, ntfyTopic: "my-topic" }); @@ -1325,780 +1258,4 @@ describe("NtfyNotifier", () => { }); }); - describe("runtime reconfiguration", () => { - it("starts sending notifications when enabled at runtime", async () => { - store.setSettings({ ntfyEnabled: false, ntfyTopic: "test-topic" }); - notifier = new NtfyNotifier(store); - await notifier.start(); - - // Initially disabled - store.triggerTaskMoved(createTask("FN-001", "Test Task"), "in-progress", "in-review"); - await flushAsyncWork(); - expect(fetchMock).not.toHaveBeenCalled(); - - // Enable at runtime - fetchMock.mockResolvedValue({ ok: true }); - store.setSettings({ ntfyEnabled: true }); - - store.triggerTaskMoved(createTask("FN-002", "Test Task 2"), "in-progress", "in-review"); - await flushAsyncWork(); - expect(fetchMock).toHaveBeenCalledTimes(1); - }); - - it("stops sending notifications when disabled at runtime", async () => { - store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic" }); - fetchMock.mockResolvedValue({ ok: true }); - notifier = new NtfyNotifier(store); - await notifier.start(); - - // Initially enabled - store.triggerTaskMoved(createTask("FN-001", "Test Task"), "in-progress", "in-review"); - await flushAsyncWork(); - expect(fetchMock).toHaveBeenCalledTimes(1); - - // Disable at runtime - store.setSettings({ ntfyEnabled: false }); - - store.triggerTaskMoved(createTask("FN-002", "Test Task 2"), "in-progress", "in-review"); - await flushAsyncWork(); - expect(fetchMock).toHaveBeenCalledTimes(1); // No new calls - }); - - it("uses updated topic when changed at runtime", async () => { - store.setSettings({ ntfyEnabled: true, ntfyTopic: "old-topic" }); - fetchMock.mockResolvedValue({ ok: true }); - notifier = new NtfyNotifier(store); - await notifier.start(); - - store.triggerTaskMoved(createTask("FN-001", "Test Task"), "in-progress", "in-review"); - await flushAsyncWork(); - expect(fetchMock).toHaveBeenCalledWith("https://ntfy.sh/old-topic", expect.any(Object)); - - // Change topic - store.setSettings({ ntfyTopic: "new-topic" }); - - store.triggerTaskMoved(createTask("FN-002", "Test Task 2"), "in-progress", "in-review"); - await flushAsyncWork(); - expect(fetchMock).toHaveBeenLastCalledWith("https://ntfy.sh/new-topic", expect.any(Object)); - }); - }); - - describe("error handling", () => { - it("catches and logs fetch errors without throwing", async () => { - store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic" }); - fetchMock.mockRejectedValue(new Error("Network error")); - - notifier = new NtfyNotifier(store); - await notifier.start(); - - // Should not throw - store.triggerTaskMoved(createTask("FN-001", "Test Task"), "in-progress", "in-review"); - await flushAsyncWork(); - - expect(fetchMock).toHaveBeenCalled(); - }); - - it("handles HTTP error responses without throwing", async () => { - store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic" }); - fetchMock.mockResolvedValue({ ok: false, status: 500, statusText: "Server Error" }); - - notifier = new NtfyNotifier(store); - await notifier.start(); - - // Should not throw - store.triggerTaskMoved(createTask("FN-001", "Test Task"), "in-progress", "in-review"); - await flushAsyncWork(); - - expect(fetchMock).toHaveBeenCalled(); - }); - }); - - describe("deduplication", () => { - beforeEach(() => { - store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic" }); - fetchMock.mockResolvedValue({ ok: true }); - }); - - it("prevents duplicate notifications for the same event type", async () => { - notifier = new NtfyNotifier(store); - await notifier.start(); - - const task = createTask("FN-001", "Test Task"); - - // Multiple in-review events for the same task - store.triggerTaskMoved(task, "in-progress", "in-review"); - store.triggerTaskMoved(task, "in-progress", "in-review"); - store.triggerTaskMoved(task, "in-progress", "in-review"); - - await flushAsyncWork(); - - // Should only send one notification due to deduplication - expect(fetchMock).toHaveBeenCalledTimes(1); - }); - - it("prevents duplicate awaiting-approval notifications for the same task", async () => { - notifier = new NtfyNotifier(store); - await notifier.start(); - - const task = createTask("FN-004", "Approval Task", "awaiting-approval"); - - store.triggerTaskUpdated(task); - store.triggerTaskUpdated(task); - store.triggerTaskUpdated(task); - - await flushAsyncWork(); - - expect(fetchMock).toHaveBeenCalledTimes(1); - expect(fetchMock).toHaveBeenCalledWith( - "https://ntfy.sh/test-topic", - expect.objectContaining({ - headers: expect.objectContaining({ - "Title": "Plan needs approval for FN-004", - }), - }), - ); - }); - - it("prevents duplicate awaiting-user-review notifications for the same task", async () => { - notifier = new NtfyNotifier(store); - await notifier.start(); - - const task = createTask("FN-005", "User Review Task", "awaiting-user-review"); - - store.triggerTaskUpdated(task); - store.triggerTaskUpdated(task); - store.triggerTaskUpdated(task); - - await flushAsyncWork(); - - expect(fetchMock).toHaveBeenCalledTimes(1); - expect(fetchMock).toHaveBeenCalledWith( - "https://ntfy.sh/test-topic", - expect.objectContaining({ - headers: expect.objectContaining({ - "Title": "User review needed for FN-005", - }), - }), - ); - }); - - it("allows different event types for the same task", async () => { - notifier = new NtfyNotifier(store); - await notifier.start(); - - const task = createTask("FN-001", "Test Task"); - - // First: in-review notification - store.triggerTaskMoved(task, "in-progress", "in-review"); - await flushAsyncWork(); - expect(fetchMock).toHaveBeenCalledTimes(1); - - // Second: merged notification (different event type - should be allowed) - const mergeResult: MergeResult = { - task, - branch: "fusion/fn-001", - merged: true, - worktreeRemoved: true, - branchDeleted: true, - }; - store.triggerTaskMerged(mergeResult); - await flushAsyncWork(); - - // Should have two notifications now - expect(fetchMock).toHaveBeenCalledTimes(2); - }); - - it("allows awaiting-approval alongside other event types for the same task", async () => { - notifier = new NtfyNotifier(store); - await notifier.start(); - - const task = createTask("FN-005", "Approval + Failure"); - - store.triggerTaskUpdated({ ...task, status: "awaiting-approval" }); - await flushAsyncWork(); - expect(fetchMock).toHaveBeenCalledTimes(1); - - store.triggerTaskUpdated({ ...task, status: "failed" }); - await flushAsyncWork(); - expect(fetchMock).toHaveBeenCalledTimes(2); - }); - - it("sends notification only once on merge when task:moved and task:merged both fire", async () => { - notifier = new NtfyNotifier(store); - await notifier.start(); - - const task = createTask("FN-001", "Test Task"); - const mergeResult: MergeResult = { - task, - branch: "fusion/fn-001", - merged: true, - worktreeRemoved: true, - branchDeleted: true, - }; - - // completeTask() emits task:moved to done before task:merged - store.triggerTaskMoved(task, "in-review", "done"); - store.triggerTaskMerged(mergeResult); - - await flushAsyncWork(); - - expect(fetchMock).toHaveBeenCalledTimes(1); - expect(fetchMock).toHaveBeenCalledWith( - "https://ntfy.sh/test-topic", - expect.objectContaining({ - method: "POST", - headers: expect.objectContaining({ - "Title": "Task FN-001 merged", - "Priority": "default", - }), - body: 'Task "Test Task" has been merged to main', - }) - ); - }); - - it("prevents duplicate task:merged events for the same task", async () => { - notifier = new NtfyNotifier(store); - await notifier.start(); - - const task = createTask("FN-001", "Test Task"); - const mergeResult: MergeResult = { - task, - branch: "fusion/fn-001", - merged: true, - worktreeRemoved: true, - branchDeleted: true, - }; - - // Multiple merged events for the same task - store.triggerTaskMerged(mergeResult); - store.triggerTaskMerged(mergeResult); - store.triggerTaskMerged(mergeResult); - - await flushAsyncWork(); - - // Should only send one notification - expect(fetchMock).toHaveBeenCalledTimes(1); - }); - - it("emits a single merged notification when notifier shares the same already-started NotificationService (ProjectEngine wiring)", async () => { - const sharedService = new NotificationService(store, { projectId: "proj-1" }); - await sharedService.start(); - - notifier = new NtfyNotifier(store, { projectId: "proj-1" }, sharedService); - await notifier.start(); - - const task = createTask("FN-777", "Single Merge Notification"); - const mergeResult: MergeResult = { - task, - branch: "fusion/fn-777", - merged: true, - worktreeRemoved: true, - branchDeleted: true, - }; - - store.triggerTaskMerged(mergeResult); - await flushAsyncWork(); - - expect(fetchMock).toHaveBeenCalledTimes(1); - expect(fetchMock).toHaveBeenCalledWith( - "https://ntfy.sh/test-topic", - expect.objectContaining({ - headers: expect.objectContaining({ - Title: "Task FN-777 merged", - }), - }), - ); - - await sharedService.stop(); - }); - - it("dispatches and deduplicates fallback-used notifications", async () => { - notifier = new NtfyNotifier(store); - await notifier.start(); - - await notifyFallbackUsed({ - primaryModel: "anthropic/claude-sonnet-4-5", - fallbackModel: "openai/gpt-4o", - triggerPoint: "session-creation", - taskId: "FN-900", - taskTitle: "Fallback task", - }); - await notifyFallbackUsed({ - primaryModel: "anthropic/claude-sonnet-4-5", - fallbackModel: "openai/gpt-4o", - triggerPoint: "session-creation", - taskId: "FN-900", - taskTitle: "Fallback task", - }); - await flushAsyncWork(); - - expect(fetchMock).toHaveBeenCalledTimes(1); - expect(fetchMock).toHaveBeenCalledWith( - "https://ntfy.sh/test-topic", - expect.objectContaining({ - body: expect.stringContaining("switched from anthropic/claude-sonnet-4-5 to openai/gpt-4o"), - }), - ); - }); - - it("allows notifications for different tasks independently", async () => { - notifier = new NtfyNotifier(store); - await notifier.start(); - - const task1 = createTask("FN-001", "Test Task 1"); - const task2 = createTask("FN-002", "Test Task 2"); - - store.triggerTaskMoved(task1, "in-progress", "in-review"); - store.triggerTaskMoved(task2, "in-progress", "in-review"); - - await flushAsyncWork(); - - // Different tasks should each get their own notification - expect(fetchMock).toHaveBeenCalledTimes(2); - }); - }); - - describe("dashboard runtime wiring", () => { - /** - * These tests simulate the pattern used in packages/cli/src/commands/dashboard.ts - * where the NtfyNotifier is constructed with an optional projectId resolved - * from the central project registry. When a registered project is found, - * deep links include ?project=...&task=...; when no project is registered - * (legacy / single-project mode), links fall back to ?task=... only. - */ - beforeEach(() => { - fetchMock.mockResolvedValue({ ok: true }); - }); - - it("produces project-aware deep links when constructed with registered project ID", async () => { - store.setSettings({ - ntfyEnabled: true, - ntfyTopic: "test-topic", - ntfyDashboardHost: "http://localhost:3000", - }); - - // Simulates: const notifier = new NtfyNotifier(store, { projectId: registered.id }); - notifier = new NtfyNotifier(store, { projectId: "proj_abc123" }); - await notifier.start(); - - store.triggerTaskMoved(createTask("FN-001", "Test Task"), "in-progress", "in-review"); - await flushAsyncWork(); - - expect(fetchMock).toHaveBeenCalledWith( - "https://ntfy.sh/test-topic", - expect.objectContaining({ - headers: expect.objectContaining({ - "Click": "http://localhost:3000/?project=proj_abc123&task=FN-001", - }), - }), - ); - }); - - it("produces task-only deep links when no project ID is available (legacy mode)", async () => { - store.setSettings({ - ntfyEnabled: true, - ntfyTopic: "test-topic", - ntfyDashboardHost: "http://localhost:3000", - }); - - // Simulates: const notifier = new NtfyNotifier(store); // no projectId - notifier = new NtfyNotifier(store); - await notifier.start(); - - store.triggerTaskMoved(createTask("FN-001", "Test Task"), "in-progress", "in-review"); - await flushAsyncWork(); - - expect(fetchMock).toHaveBeenCalledWith( - "https://ntfy.sh/test-topic", - expect.objectContaining({ - headers: expect.objectContaining({ - "Click": "http://localhost:3000/?task=FN-001", - }), - }), - ); - // Verify no "project=" in the URL - const callArgs = fetchMock.mock.calls[0][1] as { headers: Record }; - expect(callArgs.headers["Click"]).not.toContain("project="); - }); - - it("produces project-aware deep links for all notification event types", async () => { - store.setSettings({ - ntfyEnabled: true, - ntfyTopic: "test-topic", - ntfyDashboardHost: "https://fusion.example.com", - }); - - notifier = new NtfyNotifier(store, { projectId: "proj_xyz" }); - await notifier.start(); - - // in-review event - store.triggerTaskMoved(createTask("FN-001", "Task A"), "in-progress", "in-review"); - await flushAsyncWork(); - - // merged event - const mergeResult: MergeResult = { - task: createTask("FN-001", "Task A"), - branch: "fusion/fn-001", - merged: true, - worktreeRemoved: true, - branchDeleted: true, - }; - store.triggerTaskMerged(mergeResult); - await flushAsyncWork(); - - // Verify both calls include project - const calls = fetchMock.mock.calls; - for (const call of calls) { - const headers = call[1].headers as Record; - expect(headers["Click"]).toContain("project=proj_xyz"); - } - }); - }); - - describe("custom base URL", () => { - it("uses custom ntfy base URL when provided in notifier options", async () => { - store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic" }); - fetchMock.mockResolvedValue({ ok: true }); - - notifier = new NtfyNotifier(store, { ntfyBaseUrl: "https://my-ntfy.example.com" }); - await notifier.start(); - - store.triggerTaskMoved(createTask("FN-001", "Test Task"), "in-progress", "in-review"); - - await flushAsyncWork(); - - expect(fetchMock).toHaveBeenCalledWith( - "https://my-ntfy.example.com/test-topic", - expect.any(Object) - ); - }); - - it("uses ntfyBaseUrl from settings when configured", async () => { - store.setSettings({ - ntfyEnabled: true, - ntfyTopic: "test-topic", - ntfyBaseUrl: "https://ntfy.internal.example///", - }); - fetchMock.mockResolvedValue({ ok: true }); - - notifier = new NtfyNotifier(store); - await notifier.start(); - - store.triggerTaskMoved(createTask("FN-101", "Configured URL Task"), "in-progress", "in-review"); - await flushAsyncWork(); - - expect(fetchMock).toHaveBeenCalledWith( - "https://ntfy.internal.example/test-topic", - expect.any(Object), - ); - }); - - it("falls back to default ntfy.sh when settings ntfyBaseUrl is blank", async () => { - store.setSettings({ - ntfyEnabled: true, - ntfyTopic: "test-topic", - ntfyBaseUrl: " ", - }); - fetchMock.mockResolvedValue({ ok: true }); - - notifier = new NtfyNotifier(store); - await notifier.start(); - - store.triggerTaskMoved(createTask("FN-102", "Blank URL Task"), "in-progress", "in-review"); - await flushAsyncWork(); - - expect(fetchMock).toHaveBeenCalledWith( - "https://ntfy.sh/test-topic", - expect.any(Object), - ); - }); - - it("applies updated ntfyBaseUrl from settings changes at runtime", async () => { - store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic" }); - fetchMock.mockResolvedValue({ ok: true }); - - notifier = new NtfyNotifier(store); - await notifier.start(); - - store.triggerTaskMoved(createTask("FN-103", "Before Update"), "in-progress", "in-review"); - await flushAsyncWork(); - expect(fetchMock).toHaveBeenLastCalledWith("https://ntfy.sh/test-topic", expect.any(Object)); - - store.setSettings({ ntfyBaseUrl: "https://ntfy.changed.example" }); - store.triggerTaskMoved(createTask("FN-104", "After Update"), "in-progress", "in-review"); - await flushAsyncWork(); - - expect(fetchMock).toHaveBeenLastCalledWith( - "https://ntfy.changed.example/test-topic", - expect.any(Object), - ); - }); - }); - - describe("stop()", () => { - it("stops listening to events after stop() is called", async () => { - store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic" }); - fetchMock.mockResolvedValue({ ok: true }); - - notifier = new NtfyNotifier(store); - await notifier.start(); - - store.triggerTaskMoved(createTask("FN-001", "Test Task"), "in-progress", "in-review"); - await flushAsyncWork(); - expect(fetchMock).toHaveBeenCalledTimes(1); - - notifier.stop(); - - store.triggerTaskMoved(createTask("FN-002", "Test Task 2"), "in-progress", "in-review"); - await flushAsyncWork(); - - // Should not increase after stop - expect(fetchMock).toHaveBeenCalledTimes(1); - }); - }); - - describe("edge cases", () => { - it("allows in-review and failed notifications for the same task", async () => { - store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic" }); - fetchMock.mockResolvedValue({ ok: true }); - notifier = new NtfyNotifier(store); - await notifier.start(); - - const task = createTask("FN-001", "Test Task"); - - // First: in-review notification - store.triggerTaskMoved(task, "in-progress", "in-review"); - await flushAsyncWork(); - expect(fetchMock).toHaveBeenCalledTimes(1); - - // Second: failed notification (different event type - should be allowed) - const failedTask = { ...task, status: "failed" }; - store.triggerTaskUpdated(failedTask); - await flushAsyncWork(); - - // Should have two notifications - expect(fetchMock).toHaveBeenCalledTimes(2); - }); - - it("does not notify on task:moved to columns other than in-review", async () => { - store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic" }); - fetchMock.mockResolvedValue({ ok: true }); - notifier = new NtfyNotifier(store); - await notifier.start(); - - // Move to todo - should not notify - store.triggerTaskMoved(createTask("FN-001", "Test Task"), "triage", "todo"); - await flushAsyncWork(); - - // Move to in-progress - should not notify - store.triggerTaskMoved(createTask("FN-002", "Test Task 2"), "todo", "in-progress"); - await flushAsyncWork(); - - // Move to done - should not notify (merged notification comes from task:merged) - store.triggerTaskMoved(createTask("FN-003", "Test Task 3"), "in-review", "done"); - await flushAsyncWork(); - - expect(fetchMock).not.toHaveBeenCalled(); - }); - - it("does not notify on task:updated when status is neither failed nor awaiting-approval", async () => { - store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic" }); - fetchMock.mockResolvedValue({ ok: true }); - notifier = new NtfyNotifier(store); - await notifier.start(); - - const task = createTask("FN-001", "Test Task", "in-progress"); - store.triggerTaskUpdated(task); - await flushAsyncWork(); - - expect(fetchMock).not.toHaveBeenCalled(); - }); - - it("handles empty topic gracefully", async () => { - store.setSettings({ ntfyEnabled: true, ntfyTopic: "" }); - fetchMock.mockResolvedValue({ ok: true }); - notifier = new NtfyNotifier(store); - await notifier.start(); - - store.triggerTaskMoved(createTask("FN-001", "Test Task"), "in-progress", "in-review"); - await flushAsyncWork(); - - // Empty topic should be treated as no topic - expect(fetchMock).not.toHaveBeenCalled(); - }); - }); - - describe("event filtering", () => { - beforeEach(() => { - store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic" }); - fetchMock.mockResolvedValue({ ok: true }); - }); - - it("does not send in-review notification when 'in-review' is not in ntfyEvents", async () => { - store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic", ntfyEvents: ["merged", "failed", "awaiting-approval"] }); - notifier = new NtfyNotifier(store); - await notifier.start(); - - store.triggerTaskMoved(createTask("FN-001", "Test Task"), "in-progress", "in-review"); - await flushAsyncWork(); - - expect(fetchMock).not.toHaveBeenCalled(); - }); - - it("does not send merged notification when 'merged' is not in ntfyEvents", async () => { - store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic", ntfyEvents: ["in-review", "failed", "awaiting-approval"] }); - notifier = new NtfyNotifier(store); - await notifier.start(); - - const mergeResult: MergeResult = { - task: createTask("FN-001", "Test Task"), - branch: "fusion/fn-001", - merged: true, - worktreeRemoved: true, - branchDeleted: true, - }; - store.triggerTaskMerged(mergeResult); - await flushAsyncWork(); - - expect(fetchMock).not.toHaveBeenCalled(); - }); - - it("does not send failed notification when 'failed' is not in ntfyEvents", async () => { - store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic", ntfyEvents: ["in-review", "merged", "awaiting-approval"] }); - notifier = new NtfyNotifier(store); - await notifier.start(); - - const failedTask = createTask("FN-001", "Test Task", "failed"); - store.triggerTaskUpdated(failedTask); - await flushAsyncWork(); - - expect(fetchMock).not.toHaveBeenCalled(); - }); - - it("does not send awaiting-approval notification when 'awaiting-approval' is not in ntfyEvents", async () => { - store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic", ntfyEvents: ["in-review", "merged", "failed"] }); - notifier = new NtfyNotifier(store); - await notifier.start(); - - const awaitingApprovalTask = createTask("FN-006", "Approval Task", "awaiting-approval"); - store.triggerTaskUpdated(awaitingApprovalTask); - await flushAsyncWork(); - - expect(fetchMock).not.toHaveBeenCalled(); - }); - - it("does not send awaiting-user-review notification when 'awaiting-user-review' is not in ntfyEvents", async () => { - store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic", ntfyEvents: ["in-review", "merged", "failed", "awaiting-approval"] }); - notifier = new NtfyNotifier(store); - await notifier.start(); - - const awaitingUserReviewTask = createTask("FN-007", "User Review Task", "awaiting-user-review"); - store.triggerTaskUpdated(awaitingUserReviewTask); - await flushAsyncWork(); - - expect(fetchMock).not.toHaveBeenCalled(); - }); - - it("sends notification for enabled events while others are disabled", async () => { - store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic", ntfyEvents: ["in-review"] }); - notifier = new NtfyNotifier(store); - await notifier.start(); - - // in-review - should send - store.triggerTaskMoved(createTask("FN-001", "Test Task"), "in-progress", "in-review"); - await flushAsyncWork(); - expect(fetchMock).toHaveBeenCalledTimes(1); - - // merged - should NOT send - const mergeResult: MergeResult = { - task: createTask("FN-002", "Test Task 2"), - branch: "fusion/fn-002", - merged: true, - worktreeRemoved: true, - branchDeleted: true, - }; - store.triggerTaskMerged(mergeResult); - await flushAsyncWork(); - expect(fetchMock).toHaveBeenCalledTimes(1); // Still 1, no new call - - // failed - should NOT send - const failedTask = createTask("FN-003", "Test Task 3", "failed"); - store.triggerTaskUpdated(failedTask); - await flushAsyncWork(); - expect(fetchMock).toHaveBeenCalledTimes(1); // Still 1, no new call - - // awaiting-approval - should NOT send - const awaitingApprovalTask = createTask("FN-004", "Test Task 4", "awaiting-approval"); - store.triggerTaskUpdated(awaitingApprovalTask); - await flushAsyncWork(); - expect(fetchMock).toHaveBeenCalledTimes(1); // Still 1, no new call - - // awaiting-user-review - should NOT send - const awaitingUserReviewTask = createTask("FN-005", "Test Task 5", "awaiting-user-review"); - store.triggerTaskUpdated(awaitingUserReviewTask); - await flushAsyncWork(); - expect(fetchMock).toHaveBeenCalledTimes(1); // Still 1, no new call - }); - - it("defaults to all events when ntfyEvents is undefined", async () => { - store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic", ntfyEvents: undefined }); - notifier = new NtfyNotifier(store); - await notifier.start(); - - store.triggerTaskMoved(createTask("FN-001", "Test Task"), "in-progress", "in-review"); - await flushAsyncWork(); - expect(fetchMock).toHaveBeenCalledTimes(1); - - const mergeResult: MergeResult = { - task: createTask("FN-002", "Test Task 2"), - branch: "fusion/fn-002", - merged: true, - worktreeRemoved: true, - branchDeleted: true, - }; - store.triggerTaskMerged(mergeResult); - await flushAsyncWork(); - expect(fetchMock).toHaveBeenCalledTimes(2); - - const failedTask = createTask("FN-003", "Test Task 3", "failed"); - store.triggerTaskUpdated(failedTask); - await flushAsyncWork(); - expect(fetchMock).toHaveBeenCalledTimes(3); - - const awaitingApprovalTask = createTask("FN-004", "Test Task 4", "awaiting-approval"); - store.triggerTaskUpdated(awaitingApprovalTask); - await flushAsyncWork(); - expect(fetchMock).toHaveBeenCalledTimes(4); - - const awaitingUserReviewTask = createTask("FN-005", "Test Task 5", "awaiting-user-review"); - store.triggerTaskUpdated(awaitingUserReviewTask); - await flushAsyncWork(); - expect(fetchMock).toHaveBeenCalledTimes(5); - }); - - it("updates notifications when ntfyEvents changes at runtime", async () => { - store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic", ntfyEvents: ["in-review", "merged", "failed", "awaiting-approval", "awaiting-user-review", "planning-awaiting-input"] }); - notifier = new NtfyNotifier(store); - await notifier.start(); - - // Initially all events enabled - store.triggerTaskMoved(createTask("FN-001", "Test Task"), "in-progress", "in-review"); - await flushAsyncWork(); - expect(fetchMock).toHaveBeenCalledTimes(1); - - // Disable in-review - store.setSettings({ ntfyEvents: ["merged", "failed", "awaiting-approval", "awaiting-user-review"] }); - - store.triggerTaskMoved(createTask("FN-002", "Test Task 2"), "in-progress", "in-review"); - await flushAsyncWork(); - expect(fetchMock).toHaveBeenCalledTimes(1); // No new call for in-review - - // Enable in-review again - store.setSettings({ ntfyEvents: ["in-review", "merged", "failed", "awaiting-approval", "awaiting-user-review", "planning-awaiting-input"] }); - - store.triggerTaskMoved(createTask("FN-003", "Test Task 3"), "in-progress", "in-review"); - await flushAsyncWork(); - expect(fetchMock).toHaveBeenCalledTimes(2); // New call for in-review - }); - }); }); diff --git a/scripts/check-file-line-count.mjs b/scripts/check-file-line-count.mjs index 8cb0eb7692..b153696adf 100644 --- a/scripts/check-file-line-count.mjs +++ b/scripts/check-file-line-count.mjs @@ -25,6 +25,9 @@ FN-6917 re-confirms the `pnpm test`-blocking premise is stale because FN-5048 le FNXC:CI 2026-06-25-00:00: FN-7013 re-confirms the `pnpm test`-blocking premise is stale: FN-5048 removed this guard from pretest and left it opt-in under `check:line-count` only. Sixty-one current violations were re-ratcheted after organic feature/test growth and eight stale baseline entries were tightened or pruned. `AgentLogViewer.test.tsx` and `merger-ai.ts` were temporarily grandfathered after crossing the hard cap as long-existing files, with focused split follow-ups FN-7028 and FN-7029. Wholesale god-file shrink/refactor remains the long-term direction and stays deferred to dedicated follow-ups. + +FNXC:CI 2026-06-25-17:44: +FN-7035 split the two new hard-cap crossers (`ChatView.core.test.tsx` and `notifier.test.ts`) into focused sibling suites rather than grandfathering them. Six existing grandfathered entries were re-ratcheted to current counts after organic test and feature growth; `store.ts` and `types.ts` drift was left out of scope for a follow-up. Wholesale god-file shrink remains long-term deferred work for dedicated refactors. */ // Repo-wide guard: hand-written source files may not exceed a hard line-count // cap (MAX_LINES). This stops the next god-file from being born while leaving diff --git a/scripts/line-count-baseline.json b/scripts/line-count-baseline.json index 07537c5e15..17f3d9c0ad 100644 --- a/scripts/line-count-baseline.json +++ b/scripts/line-count-baseline.json @@ -7,19 +7,19 @@ "packages/cli/src/commands/dashboard-tui/app.tsx": 4681, "packages/cli/src/commands/dashboard.ts": 3000, "packages/cli/src/extension.ts": 4704, - "packages/core/src/__tests__/agent-store.test.ts": 2997, + "packages/core/src/__tests__/agent-store.test.ts": 3003, "packages/core/src/__tests__/central-core.test.ts": 3263, "packages/core/src/__tests__/db.test.ts": 3606, - "packages/core/src/__tests__/mission-store.test.ts": 4519, + "packages/core/src/__tests__/mission-store.test.ts": 4525, "packages/core/src/__tests__/plugin-loader.test.ts": 2783, "packages/core/src/__tests__/store-settings.test.ts": 2249, "packages/core/src/agent-store.ts": 2946, "packages/core/src/central-core.ts": 3854, - "packages/core/src/db.ts": 5888, + "packages/core/src/db.ts": 5924, "packages/core/src/mission-store.ts": 4390, "packages/core/src/store.ts": 17358, "packages/core/src/types.ts": 7415, - "packages/dashboard/app/api/legacy.ts": 10821, + "packages/dashboard/app/api/legacy.ts": 10865, "packages/dashboard/app/components/AgentDetailView.tsx": 5400, "packages/dashboard/app/components/AgentsView.tsx": 2147, "packages/dashboard/app/components/ChatView.tsx": 4075, @@ -28,7 +28,7 @@ "packages/dashboard/app/components/MissionManager.tsx": 5042, "packages/dashboard/app/components/ModelOnboardingModal.tsx": 3212, "packages/dashboard/app/components/PlanningModeModal.tsx": 3531, - "packages/dashboard/app/components/QuickEntryBox.tsx": 2229, + "packages/dashboard/app/components/QuickEntryBox.tsx": 2288, "packages/dashboard/app/components/SettingsModal.tsx": 3505, "packages/dashboard/app/components/TaskCard.tsx": 2544, "packages/dashboard/app/components/TaskDetailModal.tsx": 4636, @@ -42,7 +42,7 @@ "packages/dashboard/app/components/__tests__/MailboxView.test.tsx": 2202, "packages/dashboard/app/components/__tests__/ModelOnboardingModal.test.tsx": 4679, "packages/dashboard/app/components/__tests__/PlanningModeModal.planning-flow.test.tsx": 3002, - "packages/dashboard/app/components/__tests__/QuickEntryBox.test.tsx": 4707, + "packages/dashboard/app/components/__tests__/QuickEntryBox.test.tsx": 4850, "packages/dashboard/app/components/__tests__/TaskCard.test.tsx": 5121, "packages/dashboard/app/components/__tests__/TaskChatTab.test.tsx": 2558, "packages/dashboard/app/components/__tests__/TaskDetailModal.inline-editing-and-integrations.test.tsx": 2917, From 79c602d9d40833bfa7e92f0ecd480b6e78e71405 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Thu, 25 Jun 2026 21:01:15 -0700 Subject: [PATCH 10/50] FN-7022: add MCP server configuration foundation Add core MCP configuration primitives for secure server declarations and resolution. - Add MCP server setting types, schema entries, validation, and project-over-global resolution. - Add secret-reference materialization seams plus Claude Desktop import/export helpers. - Cover MCP config behavior with core unit tests and document settings and secret handling. - Add a changeset for the published CLI package. Files changed: .changeset/fn-7022-mcp-core-foundation.md | 7 + docs/secrets.md | 3 +- docs/settings-reference.md | 23 ++ packages/core/src/__tests__/mcp-config.test.ts | 199 ++++++++++++++ packages/core/src/index.ts | 30 +- packages/core/src/mcp-config.ts | 366 +++++++++++++++++++++++++ packages/core/src/settings-schema.ts | 90 +++++- packages/core/src/settings-validation.ts | 172 +++++++++++- packages/core/src/types.ts | 62 +++++ 9 files changed, 947 insertions(+), 5 deletions(-) Fusion-Task-Id: FN-7022 Fusion-Task-Lineage: 7bafd0b8-e4a5-4bc2-9a2a-43bfb934845c --- .changeset/fn-7022-mcp-core-foundation.md | 7 + docs/secrets.md | 3 +- docs/settings-reference.md | 23 ++ .../core/src/__tests__/mcp-config.test.ts | 199 ++++++++++ packages/core/src/index.ts | 30 +- packages/core/src/mcp-config.ts | 366 ++++++++++++++++++ packages/core/src/settings-schema.ts | 90 ++++- packages/core/src/settings-validation.ts | 172 +++++++- packages/core/src/types.ts | 62 +++ 9 files changed, 947 insertions(+), 5 deletions(-) create mode 100644 .changeset/fn-7022-mcp-core-foundation.md create mode 100644 packages/core/src/__tests__/mcp-config.test.ts create mode 100644 packages/core/src/mcp-config.ts diff --git a/.changeset/fn-7022-mcp-core-foundation.md b/.changeset/fn-7022-mcp-core-foundation.md new file mode 100644 index 0000000000..47a7168d33 --- /dev/null +++ b/.changeset/fn-7022-mcp-core-foundation.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Add core MCP server settings model with project/global precedence and secret references. +category: feature +dev: New @fusion/core MCP config types, validators, resolveEffectiveMcpServers, secret-resolver seam, and Claude Desktop import/export. Secret material stored only as Fusion-managed secret references. diff --git a/docs/secrets.md b/docs/secrets.md index 4cb59c2985..ec3147b669 100644 --- a/docs/secrets.md +++ b/docs/secrets.md @@ -38,6 +38,7 @@ Threat-model baseline: - Secret plaintext is **not** stored in SQLite. - Ciphertext + nonce are persisted; plaintext exists only in process memory during create/reveal. - Secret values must never be logged. +- MCP server settings store only secret references for sensitive env/header/token fields; imports surface plaintext as secret-creation descriptors instead of persisting it in settings. See also: [Storage](./storage.md), [Multi-project](./multi-project.md), [Architecture](./architecture.md), [Settings reference](./settings-reference.md). @@ -133,7 +134,7 @@ Fusion can materialize env-exportable secrets into each acquired task worktree w - Fingerprint sidecar: successful writes persist `.fusion-secrets-env.fingerprint` containing `\n\n` (mode `0o600`) so teardown can verify file integrity before deletion. - Teardown cleanup: when a worktree is removed, Fusion deletes the managed env file only when the on-disk fingerprint still matches; edited files are preserved and only the sidecar is removed. -Settings shape is split by scope: project-level secrets settings are limited to `ProjectSettings.secretsEnv`, while cross-node sync passphrase state is stored only as the reserved `__sync_passphrase__` row in `secrets_global` and exposed read-only through `GlobalSettings.secretsSyncPassphraseConfigured` (`packages/core/src/types.ts`). Settings never carry the plaintext passphrase. +Settings shape is split by scope: project-level secrets settings include `ProjectSettings.secretsEnv` and MCP secret references in `ProjectSettings.mcpServers`, while cross-node sync passphrase state is stored only as the reserved `__sync_passphrase__` row in `secrets_global` and exposed read-only through `GlobalSettings.secretsSyncPassphraseConfigured` (`packages/core/src/types.ts`). Settings never carry plaintext passphrases or MCP credentials; MCP env/header/token fields use `{ secretRef, scope }` and materialize through `SecretsStore.revealSecret(...)` only at the runtime use seam. ### Test locations diff --git a/docs/settings-reference.md b/docs/settings-reference.md index b2e930774d..5461376501 100644 --- a/docs/settings-reference.md +++ b/docs/settings-reference.md @@ -122,8 +122,30 @@ Fusion automatically falls back to ntfy's JSON publish format when a notificatio | `researchGlobalUserAgent` | `string` | `"FusionResearchBot/1.0"` | User-Agent header for HTTP requests made by research providers. | | `experimentalFeatures` | `Record` | `{}` | Global-scoped experimental feature flags. Includes `experimentalFeatures.researchView`, which gates all Research surfaces and tools (dashboard view, engine task-session tools, and CLI `fn_research_*` tools), and `experimentalFeatures.evalsView`, which gates Evals surfaces (dashboard view, Settings → Scheduled Evals, and scheduled-eval cron execution). | | `remoteAccess` | `RemoteAccessSettings` | `{ activeProvider: null, providers: {...}, tokenStrategy: {...}, lifecycle: {...} }` | Global-scoped remote access provider + token strategy configuration used by Remote Access routes and tunnel lifecycle controls. | +| `mcpServers` | `McpServersSettings` | `{ enabled: false, servers: [] }` | Global MCP server declarations shared across projects. Project `mcpServers` can enable/disable the effective set, override a same-named global server, or disable a global server with a same-named `enabled:false` entry. Sensitive env/header/token values must be `{ secretRef, scope }` references to Fusion-managed secrets, never plaintext. | | `worktrunk` | `WorktrunkSettings` | `{ enabled: false, binaryPath: undefined, installedBinaryPath: undefined, onFailure: "fail" }` | Global defaults for worktrunk integration. Merged field-by-field with project `worktrunk` values; project values override global values for matching fields. | +### MCP server settings + +`mcpServers` is available in both global and project settings: + +```ts +type McpServersSettings = { + enabled?: boolean; + servers?: McpServerDefinition[]; +}; +``` + +Each server is named and uses one transport: + +- `stdio`: `{ name, enabled?, transport: "stdio", command, args?, env? }` +- `sse`: `{ name, enabled?, transport: "sse", url, headers? }` +- `streamable-http`: `{ name, enabled?, transport: "streamable-http", url, headers? }` + +Resolution uses project-over-global precedence by server name. The project-level `enabled` flag overrides the global flag when set; if the effective flag is false, no MCP servers are active. When enabled, global servers are loaded first, project servers with the same `name` replace them, and a project server with `enabled:false` removes the inherited server. + +Secret rule: `env` and `headers` maps are sensitive. Values must be Fusion secret references such as `{ "secretRef": "sec_...", "scope": "project" }` or `{ "secretRef": "sec_...", "scope": "global" }`. Write-boundary sanitizers and validators reject plaintext strings in these fields. Claude Desktop-style imports return `secretsToCreate` descriptors for plaintext env/header values and replace those values with secret refs in the imported definitions. + ### Notification providers (pluggable) Fusion now supports a provider-list notification model via `notificationProviders` while keeping legacy flat ntfy/webhook settings intact. @@ -324,6 +346,7 @@ Defaults from `DEFAULT_PROJECT_SETTINGS`; key scope from `PROJECT_SETTINGS_KEYS` | `unavailableNodePolicy` | `"block" \| "fallback-local"` | `"block"` | Project routing policy used during scheduler dispatch when a task resolves to a remote node and node health is known. `"block"` keeps the task in `todo` if the node is unhealthy; `"fallback-local"` reroutes dispatch to local execution. See [Architecture → Task Routing Architecture](./architecture.md#task-routing-architecture). | | `secretsAccessPolicy` | `"auto" \| "prompt" \| "deny"` | `undefined` | Project-level default secret access policy (overrides global default when present). | | `secretsEnv` | `{ enabled?: boolean; filename?: string; overwritePolicy?: "skip" \| "merge" \| "replace"; keyPrefix?: string; requireGitignored?: boolean }` | `undefined` | Per-project secrets `.env` materialization configuration. When `enabled`, the engine writes `secretsEnv.filename` (default `.env`) into each acquired task worktree from secrets marked `env_exportable=true`. `overwritePolicy` controls merge/skip/replace against an existing file; `requireGitignored` (default `true`) refuses to write a non-gitignored path; `keyPrefix` filters which exported keys are included. See [Secrets](./secrets.md#env-auto-write-into-worktrees). | +| `mcpServers` | `McpServersSettings` | `{ enabled: false, servers: [] }` | Project-scoped MCP server settings. Project entries override global entries by `name`; `enabled:false` on a same-named project entry disables the inherited global server. Sensitive env/header/token material must be Fusion secret references only. See [MCP server settings](#mcp-server-settings). | | `owningNodeHandoffPolicy` | `"block" \| "reassign-to-local" \| "reassign-any-healthy"` | `"reassign-to-local"` | Policy for tasks already checked out by an unavailable owning node. `"block"` parks, `"reassign-to-local"` takes over on local node, `"reassign-any-healthy"` makes takeover eligible on healthy peers. | | `groupOverlappingFiles` | `boolean` | `true` | Serialize execution when file scopes overlap. | diff --git a/packages/core/src/__tests__/mcp-config.test.ts b/packages/core/src/__tests__/mcp-config.test.ts new file mode 100644 index 0000000000..d2cbb83de6 --- /dev/null +++ b/packages/core/src/__tests__/mcp-config.test.ts @@ -0,0 +1,199 @@ +import { describe, expect, it } from "vitest"; +import { + exportMcpServersJson, + importMcpServersJson, + materializeMcpServerSecrets, + resolveEffectiveMcpServers, +} from "../mcp-config.js"; +import { + validateMcpServerDefinition, + validateMcpServerDefinitions, + validateMcpServerDefinitionsDetailed, +} from "../settings-validation.js"; +import type { McpServerDefinition } from "../types.js"; + +const projectSecret = { secretRef: "project-token", scope: "project" as const }; +const globalSecret = { secretRef: "global-token", scope: "global" as const }; + +describe("MCP core config", () => { + it("resolves project servers over global servers by name", () => { + const globalServer: McpServerDefinition = { + name: "github", + transport: "stdio", + command: "global-gh", + env: { TOKEN: globalSecret }, + }; + const projectServer: McpServerDefinition = { + name: "github", + transport: "stdio", + command: "project-gh", + args: ["serve"], + env: { TOKEN: projectSecret }, + }; + + expect( + resolveEffectiveMcpServers( + { mcpServers: { enabled: true, servers: [globalServer] } }, + { mcpServers: { enabled: true, servers: [projectServer] } }, + ), + ).toEqual([projectServer]); + }); + + it("lets a project disabled entry remove a global server", () => { + const globalServer: McpServerDefinition = { + name: "global-only", + transport: "stdio", + command: "global-command", + }; + const disabledProjectOverride: McpServerDefinition = { + name: "global-only", + enabled: false, + transport: "stdio", + command: "ignored", + }; + + expect( + resolveEffectiveMcpServers( + { mcpServers: { enabled: true, servers: [globalServer] } }, + { mcpServers: { enabled: true, servers: [disabledProjectOverride] } }, + ), + ).toEqual([]); + }); + + it("rejects plaintext sensitive env and header values while accepting secret refs", () => { + expect( + validateMcpServerDefinition({ + name: "bad-env", + transport: "stdio", + command: "node", + env: { TOKEN: "plaintext" }, + }), + ).toBeUndefined(); + + expect( + validateMcpServerDefinition({ + name: "bad-header", + transport: "sse", + url: "https://example.test/sse", + headers: { Authorization: "Bearer plaintext" }, + }), + ).toBeUndefined(); + + expect( + validateMcpServerDefinition({ + name: "good", + transport: "sse", + url: "https://example.test/sse", + headers: { Authorization: projectSecret }, + }), + ).toEqual({ + name: "good", + transport: "sse", + url: "https://example.test/sse", + headers: { Authorization: projectSecret }, + }); + }); + + it("validates required fields by transport and rejects duplicate names", () => { + expect(validateMcpServerDefinition({ name: "stdio", transport: "stdio" })).toBeUndefined(); + expect(validateMcpServerDefinition({ name: "sse", transport: "sse" })).toBeUndefined(); + expect(validateMcpServerDefinition({ name: "http", transport: "streamable-http" })).toBeUndefined(); + + const duplicateResult = validateMcpServerDefinitionsDetailed([ + { name: "dup", transport: "stdio", command: "one" }, + { name: "dup", transport: "stdio", command: "two" }, + ]); + expect(duplicateResult.value).toBeUndefined(); + expect(duplicateResult.errors.map((error) => error.code)).toContain("duplicate-name"); + expect( + validateMcpServerDefinitions([ + { name: "one", transport: "stdio", command: "one" }, + { name: "two", transport: "streamable-http", url: "https://example.test/mcp" }, + ]), + ).toHaveLength(2); + }); + + it("imports plaintext sensitive values as secret descriptors and round-trips exported refs", () => { + const imported = importMcpServersJson({ + mcpServers: { + github: { + command: "github-mcp-server", + args: ["stdio"], + env: { GITHUB_TOKEN: "ghp_secret" }, + }, + docs: { + transport: "streamable-http", + url: "https://docs.example.test/mcp", + headers: { Authorization: globalSecret }, + }, + }, + }); + + expect(imported.errors).toEqual([]); + expect(imported.secretsToCreate).toEqual([ + { + serverName: "github", + field: "env", + key: "GITHUB_TOKEN", + scope: "project", + suggestedKey: "mcp.github.env.GITHUB_TOKEN", + plaintextValue: "ghp_secret", + }, + ]); + expect(imported.definitions[0]).toMatchObject({ + name: "github", + transport: "stdio", + env: { GITHUB_TOKEN: { secretRef: "mcp.github.env.GITHUB_TOKEN", scope: "project" } }, + }); + + const original: McpServerDefinition[] = [ + { + name: "docs", + transport: "streamable-http", + url: "https://docs.example.test/mcp", + headers: { Authorization: globalSecret }, + }, + ]; + const roundTrip = importMcpServersJson(exportMcpServersJson(original)); + expect(roundTrip.errors).toEqual([]); + expect(roundTrip.secretsToCreate).toEqual([]); + expect(roundTrip.definitions).toEqual(original); + }); + + it("materializes secret refs through an injected reader and omits failed refs", async () => { + const calls: Array<{ id: string; scope: string; userId?: string | null }> = []; + const server: McpServerDefinition = { + name: "secure", + transport: "stdio", + command: "secure-mcp", + env: { + OK: { secretRef: "ok", scope: "project" }, + MISSING: { secretRef: "missing", scope: "global" }, + }, + }; + + const resolved = await materializeMcpServerSecrets( + server, + { + async revealSecret(id, scope, reader) { + calls.push({ id, scope, userId: reader.userId }); + if (id === "missing") throw new Error("not found"); + return { key: id, plaintextValue: "resolved-value" }; + }, + }, + { userId: "tester" }, + ); + + expect(calls).toEqual([ + { id: "ok", scope: "project", userId: "tester" }, + { id: "missing", scope: "global", userId: "tester" }, + ]); + expect(resolved.value).toMatchObject({ + name: "secure", + transport: "stdio", + env: { OK: "resolved-value" }, + }); + expect((resolved.value as Extract)?.env).not.toHaveProperty("MISSING"); + expect(resolved.errors).toHaveLength(1); + }); +}); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 4df0f26a9e..cd842db8b9 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,5 +1,5 @@ -export { COLUMNS, DEFAULT_COLUMN, isColumn, normalizeColumn, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, isGlobalSettingsKey, isProjectSettingsKey, isMergeRequestContractShadowEnabled, resolvePersistAgentThinkingLog, THINKING_LEVELS, THEME_MODES, COLOR_THEMES, SUPPORTED_LOCALES, DEFAULT_LOCALE, isLocale, WORKFLOW_STEP_TEMPLATES, AGENT_PERMISSIONS, PERMANENT_AGENT_ACTION_CATEGORIES, AGENT_PERMISSION_POLICY_ACTION_CATEGORIES, AGENT_PROVISIONING_APPROVAL_MODES, SANDBOX_PROVISIONING_APPROVAL_MODES, AGENT_PERMISSION_POLICY_PRESET_IDS, LEGACY_AGENT_PERMISSION_POLICY_ACTION_CATEGORY_ALIASES, APPROVAL_REQUEST_STATUSES, APPROVAL_REQUEST_AUDIT_EVENT_TYPES, normalizeApprovalRequestActionCategory, isValidApprovalRequestTransition, agentToConfigSnapshot, diffConfigSnapshots, isEphemeralAgent, hasAgentIdentity, CheckoutConflictError, DEFAULT_HEARTBEAT_PROCEDURE_PATH, getDefaultHeartbeatProcedurePath, EXECUTION_MODES, DEFAULT_EXECUTION_MODE, TASK_PRIORITIES, DEFAULT_TASK_PRIORITY, WORKFLOW_WORK_ITEM_KINDS, WORKFLOW_WORK_ITEM_STATES, HIGH_FANOUT_BLOCKER_TODO_THRESHOLD, STALE_HIGH_FANOUT_BLOCKER_AGE_THRESHOLD_MS, DASHBOARD_USER_ID, normalizeMessageParticipant, validateMessageMetadata, validateDockerNodeConfig, sanitizeDockerNodeConfigForResponse, normalizeMergeIntegrationWorktreeMode, normalizeMergeAdvanceAutoSyncMode, MERGE_ADVANCE_AUTO_SYNC_MODES, normalizeMergeConflictStrategy, normalizeMergeStrategyOverlapBehavior, normalizePostMergeAuditMode, POST_MERGE_AUDIT_MODES, normalizeMergeAuditAutoRecovery, MERGE_AUDIT_AUTO_RECOVERY_MODES, normalizeMergerMode, MERGER_MODES, normalizeAutoRecovery, AUTO_RECOVERY_MODES, buildResearchDocumentKey, REPO_OVERRIDE_RE, SHARED_STATE_SNAPSHOT_VERSION, sanitizeCliAgentSettings, sanitizeCliAgentsSettings, CLI_AGENT_ADAPTER_IDS, CLI_AGENT_AUTONOMY_MODES } from "./types.js"; -export type { Column, ColumnId, IssueInfo, IssueState, TaskSourceIssue, PrInfo, PrConflictState, PrConflictDiagnostics, PrCheckState, PrCheckStatus, PrStatus, BranchGroup, BranchGroupCreateInput, BranchGroupUpdate, BranchGroupPrState, Task, TaskTokenUsage, TaskTokenUsagePerModel, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, ArtifactType, Artifact, ArtifactCreateInput, ArtifactWithTask, TaskCreateInput, MeshReplicatedTaskCreatePayload, MeshReplicatedTaskApplyResult, TaskSource, SourceType, TaskDetail, RetrySummary, InboxTask, TodoList, TodoItem, TodoListCreateInput, TodoListUpdateInput, TodoItemCreateInput, TodoItemUpdateInput, TodoListWithItems, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, DistributedTaskIdReserveInput, DistributedTaskIdReserveResult, DistributedTaskIdCommitInput, DistributedTaskIdCommitResult, DistributedTaskIdAbortInput, DistributedTaskIdAbortResult, DistributedTaskIdStateInput, DistributedTaskIdStateResult, AutostashOrphanRecord, AutostashOutcome, MergeDetails, MergeResult, MergeIntegrationWorktreeMode, MergeAdvanceAutoSyncMode, MergeConflictStrategy, CanonicalMergeConflictStrategy, MergeStrategyOverlapBehavior, PostMergeAuditMode, MergeAuditAutoRecoveryMode, MergerMode, MergerSettings, AutoRecoveryMode, AutoRecoveryFailureClass, AutoRecoverySettings, DirectMergeCommitStrategy, Settings, GlobalSettings, ProjectSettings, SecretsEnvConfig, WebSearchBackend, ResearchEnabledSources, ResearchGlobalDefaults, ResearchProjectLimits, ResearchProjectSettings, SandboxBackendName, SandboxFailureMode, SandboxPolicy, SandboxProjectSettings, EvalFollowUpPolicy, EvalProjectSettings, ResolvedEvalSettings, SettingsScope, DaemonTokenSettings, TaskStep, StepStatus, TaskLogEntry, RunMutationContext, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, Locale, ExecutionMode, TaskPriority, MergeQueueEntry, MergeQueueEnqueueOptions, MergeQueueAcquireOptions, MergeQueueReleaseOutcome, MergeRequestState, MergeRequestRecord, MergeRequestWorkflowProjectionOptions, CompletionHandoffMarker, WorkflowWorkItem, WorkflowWorkItemDueFilter, WorkflowWorkItemKind, WorkflowWorkItemState, WorkflowWorkItemTransitionPatch, WorkflowWorkItemUpsertInput, HandoffEvidence, HandoffToReviewOptions, UnavailableNodePolicy, OwningNodeHandoffPolicy, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, GithubIssueAction, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepGateMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, OrgTreeNode, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, PermanentAgentActionCategory, PermanentAgentSensitiveActionCategory, PermanentAgentGatingContext, AgentPermissionPolicy, AgentPermissionPolicyRules, AgentPermissionPolicyActionCategory, AgentProvisioningApprovalMode, SandboxProvisioningApprovalMode, LegacyAgentPermissionPolicyActionCategory, ApprovalRequestActionCategoryInput, ApprovalRequestActionCategory, AgentPermissionPolicyDisposition, AgentPermissionPolicyPresetId, ApprovalRequestStatus, ApprovalRequestAuditEventType, ApprovalRequestActorSnapshot, ApprovalRequestTargetAction, ApprovalRequestAuditEvent, ApprovalRequest, ApprovalRequestCreateInput, ApprovalRequestDecisionInput, ApprovalRequestCompletionInput, ApprovalRequestListInput, TaskAssignSource, AgentAccessState, AgentHeartbeatConfig, AgentBudgetConfig, AgentBudgetStatus, InstructionsBundleConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, BlockedStateSnapshot, HeartbeatInvocationSource, AgentTaskSession, AgentRating, AgentRatingSummary, AgentRatingInput, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, ReflectionTrigger, ReflectionMetrics, AgentReflection, AgentPerformanceSummary, NtfyNotificationEvent, NotificationEvent, NotificationPayload, NotificationProviderConfig, CustomProvider, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, MessageMetadata, MessageReplyReference, Mailbox, CheckoutLease, CheckoutClaimPrecondition, TaskClaimRow, CentralClaimStore, RunAuditDomain, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter, AgentMemoryInclusionMode, HeartbeatPromptTemplate, HeartbeatScopeDisciplineMode, WorktrunkSettings, WorktrunkOnFailure, TaskBranchContext, CliAgentSettings } from "./types.js"; +export { COLUMNS, DEFAULT_COLUMN, isColumn, normalizeColumn, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, isGlobalSettingsKey, isProjectSettingsKey, isMergeRequestContractShadowEnabled, resolvePersistAgentThinkingLog, THINKING_LEVELS, THEME_MODES, COLOR_THEMES, SUPPORTED_LOCALES, DEFAULT_LOCALE, isLocale, WORKFLOW_STEP_TEMPLATES, AGENT_PERMISSIONS, PERMANENT_AGENT_ACTION_CATEGORIES, AGENT_PERMISSION_POLICY_ACTION_CATEGORIES, AGENT_PROVISIONING_APPROVAL_MODES, SANDBOX_PROVISIONING_APPROVAL_MODES, AGENT_PERMISSION_POLICY_PRESET_IDS, LEGACY_AGENT_PERMISSION_POLICY_ACTION_CATEGORY_ALIASES, APPROVAL_REQUEST_STATUSES, APPROVAL_REQUEST_AUDIT_EVENT_TYPES, normalizeApprovalRequestActionCategory, isValidApprovalRequestTransition, agentToConfigSnapshot, diffConfigSnapshots, isEphemeralAgent, hasAgentIdentity, CheckoutConflictError, DEFAULT_HEARTBEAT_PROCEDURE_PATH, getDefaultHeartbeatProcedurePath, EXECUTION_MODES, DEFAULT_EXECUTION_MODE, TASK_PRIORITIES, DEFAULT_TASK_PRIORITY, WORKFLOW_WORK_ITEM_KINDS, WORKFLOW_WORK_ITEM_STATES, HIGH_FANOUT_BLOCKER_TODO_THRESHOLD, STALE_HIGH_FANOUT_BLOCKER_AGE_THRESHOLD_MS, DASHBOARD_USER_ID, normalizeMessageParticipant, validateMessageMetadata, validateDockerNodeConfig, sanitizeDockerNodeConfigForResponse, normalizeMergeIntegrationWorktreeMode, normalizeMergeAdvanceAutoSyncMode, MERGE_ADVANCE_AUTO_SYNC_MODES, normalizeMergeConflictStrategy, normalizeMergeStrategyOverlapBehavior, normalizePostMergeAuditMode, POST_MERGE_AUDIT_MODES, normalizeMergeAuditAutoRecovery, MERGE_AUDIT_AUTO_RECOVERY_MODES, normalizeMergerMode, MERGER_MODES, normalizeAutoRecovery, AUTO_RECOVERY_MODES, buildResearchDocumentKey, REPO_OVERRIDE_RE, SHARED_STATE_SNAPSHOT_VERSION, sanitizeCliAgentSettings, sanitizeCliAgentsSettings, sanitizeMcpServers, CLI_AGENT_ADAPTER_IDS, CLI_AGENT_AUTONOMY_MODES, isMcpSecretRef } from "./types.js"; +export type { Column, ColumnId, IssueInfo, IssueState, TaskSourceIssue, PrInfo, PrConflictState, PrConflictDiagnostics, PrCheckState, PrCheckStatus, PrStatus, BranchGroup, BranchGroupCreateInput, BranchGroupUpdate, BranchGroupPrState, Task, TaskTokenUsage, TaskTokenUsagePerModel, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, ArtifactType, Artifact, ArtifactCreateInput, ArtifactWithTask, TaskCreateInput, MeshReplicatedTaskCreatePayload, MeshReplicatedTaskApplyResult, TaskSource, SourceType, TaskDetail, RetrySummary, InboxTask, TodoList, TodoItem, TodoListCreateInput, TodoListUpdateInput, TodoItemCreateInput, TodoItemUpdateInput, TodoListWithItems, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, DistributedTaskIdReserveInput, DistributedTaskIdReserveResult, DistributedTaskIdCommitInput, DistributedTaskIdCommitResult, DistributedTaskIdAbortInput, DistributedTaskIdAbortResult, DistributedTaskIdStateInput, DistributedTaskIdStateResult, AutostashOrphanRecord, AutostashOutcome, MergeDetails, MergeResult, MergeIntegrationWorktreeMode, MergeAdvanceAutoSyncMode, MergeConflictStrategy, CanonicalMergeConflictStrategy, MergeStrategyOverlapBehavior, PostMergeAuditMode, MergeAuditAutoRecoveryMode, MergerMode, MergerSettings, AutoRecoveryMode, AutoRecoveryFailureClass, AutoRecoverySettings, DirectMergeCommitStrategy, Settings, GlobalSettings, ProjectSettings, SecretsEnvConfig, WebSearchBackend, ResearchEnabledSources, ResearchGlobalDefaults, ResearchProjectLimits, ResearchProjectSettings, SandboxBackendName, SandboxFailureMode, SandboxPolicy, SandboxProjectSettings, EvalFollowUpPolicy, EvalProjectSettings, ResolvedEvalSettings, SettingsScope, DaemonTokenSettings, TaskStep, StepStatus, TaskLogEntry, RunMutationContext, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, Locale, ExecutionMode, TaskPriority, MergeQueueEntry, MergeQueueEnqueueOptions, MergeQueueAcquireOptions, MergeQueueReleaseOutcome, MergeRequestState, MergeRequestRecord, MergeRequestWorkflowProjectionOptions, CompletionHandoffMarker, WorkflowWorkItem, WorkflowWorkItemDueFilter, WorkflowWorkItemKind, WorkflowWorkItemState, WorkflowWorkItemTransitionPatch, WorkflowWorkItemUpsertInput, HandoffEvidence, HandoffToReviewOptions, UnavailableNodePolicy, OwningNodeHandoffPolicy, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, GithubIssueAction, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepGateMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, OrgTreeNode, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, PermanentAgentActionCategory, PermanentAgentSensitiveActionCategory, PermanentAgentGatingContext, AgentPermissionPolicy, AgentPermissionPolicyRules, AgentPermissionPolicyActionCategory, AgentProvisioningApprovalMode, SandboxProvisioningApprovalMode, LegacyAgentPermissionPolicyActionCategory, ApprovalRequestActionCategoryInput, ApprovalRequestActionCategory, AgentPermissionPolicyDisposition, AgentPermissionPolicyPresetId, ApprovalRequestStatus, ApprovalRequestAuditEventType, ApprovalRequestActorSnapshot, ApprovalRequestTargetAction, ApprovalRequestAuditEvent, ApprovalRequest, ApprovalRequestCreateInput, ApprovalRequestDecisionInput, ApprovalRequestCompletionInput, ApprovalRequestListInput, TaskAssignSource, AgentAccessState, AgentHeartbeatConfig, AgentBudgetConfig, AgentBudgetStatus, InstructionsBundleConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, BlockedStateSnapshot, HeartbeatInvocationSource, AgentTaskSession, AgentRating, AgentRatingSummary, AgentRatingInput, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, ReflectionTrigger, ReflectionMetrics, AgentReflection, AgentPerformanceSummary, NtfyNotificationEvent, NotificationEvent, NotificationPayload, NotificationProviderConfig, CustomProvider, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, MessageMetadata, MessageReplyReference, Mailbox, CheckoutLease, CheckoutClaimPrecondition, TaskClaimRow, CentralClaimStore, RunAuditDomain, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter, AgentMemoryInclusionMode, HeartbeatPromptTemplate, HeartbeatScopeDisciplineMode, WorktrunkSettings, WorktrunkOnFailure, TaskBranchContext, CliAgentSettings, McpSecretRef, McpSensitiveValue, McpStdioTransport, McpSseTransport, McpStreamableHttpTransport, McpTransport, McpServerDefinition, McpServersSettings } from "./types.js"; export { AGENT_VALID_TRANSITIONS, DUPLICATE_OF_METADATA_KEY, assertNotWorkspaceTaskMerge, isWorkspaceTask, WorkspaceTaskMergeError } from "./types.js"; export { resolveEntryPointBranchAssignment, @@ -37,6 +37,25 @@ export { validateWorktrunkSettings, DEFAULT_WORKTRUNK_SETTINGS, } from "./worktrunk-settings.js"; +export { + resolveEffectiveMcpServers, + materializeMcpServerSecrets, + materializeMcpServersSecrets, + importMcpServersJson, + exportMcpServersJson, +} from "./mcp-config.js"; +export type { + McpSecretReaderIdentity, + McpSecretReader, + ResolvedMcpStdioTransport, + ResolvedMcpSseTransport, + ResolvedMcpStreamableHttpTransport, + ResolvedMcpServerDefinition, + McpSecretResolutionError, + McpSecretResolutionResult, + McpSecretImportDescriptor, + McpServersImportResult, +} from "./mcp-config.js"; export { resolveAgentMemoryInclusionMode, type AgentMemoryInclusionModeSource, @@ -918,8 +937,15 @@ export { validateSandboxFailureMode, validateSandboxPolicy, validateSandboxProjectSettings, + validateMcpServerDefinition, + validateMcpServerDefinitionDetailed, + validateMcpServerDefinitions, + validateMcpServerDefinitionsDetailed, + validateMcpServersSettings, + validateMcpServersSettingsDetailed, validateUnavailableNodePolicy, } from "./settings-validation.js"; +export type { McpValidationError, McpValidationResult } from "./settings-validation.js"; export { parseSandboxPromptOverride, resolveSandboxBackend } from "./sandbox-prompt-override.js"; diff --git a/packages/core/src/mcp-config.ts b/packages/core/src/mcp-config.ts new file mode 100644 index 0000000000..aef147595a --- /dev/null +++ b/packages/core/src/mcp-config.ts @@ -0,0 +1,366 @@ +import type { + GlobalSettings, + McpSecretRef, + McpServerDefinition, + McpServersSettings, + McpStdioTransport, + McpSseTransport, + McpStreamableHttpTransport, + ProjectSettings, +} from "./types.js"; +import { isMcpSecretRef } from "./types.js"; +import type { SecretScope } from "./secrets-store.js"; +import { validateMcpServerDefinition } from "./settings-validation.js"; + +export interface McpSecretImportDescriptor { + serverName: string; + field: "env" | "headers" | "token"; + key: string; + scope: SecretScope; + suggestedKey: string; + plaintextValue: string; +} + +export interface McpServersImportResult { + definitions: McpServerDefinition[]; + secretsToCreate: McpSecretImportDescriptor[]; + errors: string[]; +} + +export type McpSecretReaderIdentity = { agentId?: string | null; userId?: string | null }; + +export interface McpSecretReader { + revealSecret( + id: string, + scope: SecretScope, + reader: McpSecretReaderIdentity, + ): Promise<{ key: string; plaintextValue: string }>; +} + +export interface ResolvedMcpStdioTransport extends Omit { + env?: Record; +} + +export interface ResolvedMcpSseTransport extends Omit { + headers?: Record; +} + +export interface ResolvedMcpStreamableHttpTransport extends Omit { + headers?: Record; +} + +export type ResolvedMcpServerDefinition = { + name: string; + enabled?: boolean; +} & (ResolvedMcpStdioTransport | ResolvedMcpSseTransport | ResolvedMcpStreamableHttpTransport); + +export interface McpSecretResolutionError { + serverName: string; + path: string; + secretRef: McpSecretRef; + message: string; +} + +export interface McpSecretResolutionResult { + value?: T; + errors: McpSecretResolutionError[]; +} + +function normalizeMcpServersSettings(settings?: McpServersSettings): McpServersSettings { + return { + enabled: settings?.enabled === true, + servers: Array.isArray(settings?.servers) ? settings.servers : [], + }; +} + +function validServers(settings?: McpServersSettings): McpServerDefinition[] { + return ( + normalizeMcpServersSettings(settings).servers + ?.map(validateMcpServerDefinition) + .filter((server): server is McpServerDefinition => Boolean(server)) ?? [] + ); +} + +/** + * FNXC:McpConfig 2026-06-25-00:00: + * Effective MCP configuration is project-over-global by server name. A project server with enabled:false removes the inherited global declaration, while a project enabled declaration replaces it. The resolver is pure and never throws so settings reads cannot break task scheduling. + */ +export function resolveEffectiveMcpServers( + globalSettings?: Pick | null, + projectSettings?: Pick | null, +): McpServerDefinition[] { + try { + const globalMcp = normalizeMcpServersSettings(globalSettings?.mcpServers); + const projectMcp = projectSettings?.mcpServers; + const effectiveEnabled = typeof projectMcp?.enabled === "boolean" ? projectMcp.enabled : globalMcp.enabled; + if (!effectiveEnabled) return []; + + const byName = new Map(); + for (const server of validServers(globalSettings?.mcpServers)) { + if (server.enabled === false) continue; + byName.set(server.name, server); + } + for (const server of validServers(projectMcp)) { + if (server.enabled === false) { + byName.delete(server.name); + continue; + } + byName.set(server.name, server); + } + return [...byName.values()].filter((server) => server.enabled !== false); + } catch { + return []; + } +} + +async function materializeSensitiveMap(params: { + serverName: string; + path: string; + values?: Record; + secrets: McpSecretReader; + reader: McpSecretReaderIdentity; +}): Promise | undefined>> { + const { values, secrets, reader, serverName, path } = params; + if (!values) return { value: undefined, errors: [] }; + const resolved: Record = {}; + const errors: McpSecretResolutionError[] = []; + for (const [key, value] of Object.entries(values)) { + if (!isMcpSecretRef(value)) { + errors.push({ + serverName, + path: `${path}.${key}`, + secretRef: { secretRef: "", scope: "project" }, + message: "MCP sensitive values must be secret references; plaintext was not materialized", + }); + continue; + } + try { + const revealed = await secrets.revealSecret(value.secretRef, value.scope, reader); + resolved[key] = revealed.plaintextValue; + } catch (error) { + errors.push({ + serverName, + path: `${path}.${key}`, + secretRef: value, + message: error instanceof Error ? error.message : String(error), + }); + } + } + return { value: Object.keys(resolved).length > 0 ? resolved : undefined, errors }; +} + +/** + * FNXC:McpConfig 2026-06-25-00:00: + * MCP secret materialization happens only at the use seam by calling the injected SecretsStore-compatible revealSecret method. Failed references are reported and omitted; the function never logs or returns unresolved secret material as plaintext. + */ +export async function materializeMcpServerSecrets( + server: McpServerDefinition, + secrets: McpSecretReader, + reader: McpSecretReaderIdentity, +): Promise> { + if (server.transport === "stdio") { + const env = await materializeSensitiveMap({ + serverName: server.name, + path: "env", + values: server.env, + secrets, + reader, + }); + return { + value: { + name: server.name, + ...(server.enabled !== undefined ? { enabled: server.enabled } : {}), + transport: "stdio", + command: server.command, + ...(server.args ? { args: server.args } : {}), + ...(env.value ? { env: env.value } : {}), + }, + errors: env.errors, + }; + } + + const headers = await materializeSensitiveMap({ + serverName: server.name, + path: "headers", + values: server.headers, + secrets, + reader, + }); + return { + value: { + name: server.name, + ...(server.enabled !== undefined ? { enabled: server.enabled } : {}), + transport: server.transport, + url: server.url, + ...(headers.value ? { headers: headers.value } : {}), + }, + errors: headers.errors, + }; +} + +export async function materializeMcpServersSecrets( + servers: McpServerDefinition[], + secrets: McpSecretReader, + reader: McpSecretReaderIdentity, +): Promise> { + const values: ResolvedMcpServerDefinition[] = []; + const errors: McpSecretResolutionError[] = []; + for (const server of servers) { + const resolved = await materializeMcpServerSecrets(server, secrets, reader); + if (resolved.value) values.push(resolved.value); + errors.push(...resolved.errors); + } + return { value: values, errors }; +} + +function parseMcpJson(json: string | unknown): { data?: unknown; error?: string } { + if (typeof json !== "string") return { data: json }; + try { + return { data: JSON.parse(json) as unknown }; + } catch (error) { + return { error: error instanceof Error ? error.message : String(error) }; + } +} + +function suggestedSecretKey(serverName: string, field: "env" | "headers" | "token", key: string): string { + const clean = (value: string): string => value.trim().replace(/[^A-Za-z0-9_.-]+/gu, "_").replace(/^_+|_+$/gu, ""); + return ["mcp", clean(serverName), clean(field), clean(key)].filter(Boolean).join("."); +} + +function importSensitiveMap(params: { + value: unknown; + serverName: string; + field: "env" | "headers"; + scope: SecretScope; + secretsToCreate: McpSecretImportDescriptor[]; + errors: string[]; +}): Record | undefined { + const { value, serverName, field, scope, secretsToCreate, errors } = params; + if (value === undefined) return undefined; + if (!value || typeof value !== "object" || Array.isArray(value)) { + errors.push(`${serverName}.${field} must be an object`); + return undefined; + } + const out: Record = {}; + for (const [key, raw] of Object.entries(value as Record)) { + if (isMcpSecretRef(raw)) { + out[key] = { secretRef: raw.secretRef.trim(), scope: raw.scope }; + continue; + } + if (typeof raw === "string") { + const secretRef = suggestedSecretKey(serverName, field, key); + out[key] = { secretRef, scope }; + secretsToCreate.push({ + serverName, + field, + key, + scope, + suggestedKey: secretRef, + plaintextValue: raw, + }); + continue; + } + errors.push(`${serverName}.${field}.${key} must be a string or MCP secret reference`); + } + return Object.keys(out).length > 0 ? out : undefined; +} + +/** + * Import Claude Desktop-style `{ mcpServers: { [name]: ... } }` JSON into Fusion + * definitions. Plain env/header strings are surfaced as secret creation + * descriptors and replaced with secret references; plaintext is never stored in + * the returned definitions. + */ +export function importMcpServersJson(json: string | unknown, options: { scope?: SecretScope } = {}): McpServersImportResult { + const parsed = parseMcpJson(json); + if (parsed.error) return { definitions: [], secretsToCreate: [], errors: [parsed.error] }; + const errors: string[] = []; + const secretsToCreate: McpSecretImportDescriptor[] = []; + const scope = options.scope ?? "project"; + const root = parsed.data; + if (!root || typeof root !== "object" || Array.isArray(root)) { + return { definitions: [], secretsToCreate, errors: ["MCP import data must be an object"] }; + } + const servers = (root as Record).mcpServers; + if (!servers || typeof servers !== "object" || Array.isArray(servers)) { + return { definitions: [], secretsToCreate, errors: ["MCP import data must contain an mcpServers object"] }; + } + + const definitions: McpServerDefinition[] = []; + const names = new Set(); + for (const [name, rawServer] of Object.entries(servers as Record)) { + if (!rawServer || typeof rawServer !== "object" || Array.isArray(rawServer)) { + errors.push(`${name} must be an object`); + continue; + } + const raw = rawServer as Record; + const enabled = typeof raw.enabled === "boolean" ? raw.enabled : undefined; + const base = { name: typeof raw.name === "string" && raw.name.trim() ? raw.name.trim() : name, ...(enabled !== undefined ? { enabled } : {}) }; + const transport = typeof raw.transport === "string" ? raw.transport : typeof raw.command === "string" ? "stdio" : undefined; + let candidate: McpServerDefinition | undefined; + if (transport === "stdio") { + candidate = validateMcpServerDefinition({ + ...base, + transport: "stdio", + command: raw.command, + args: raw.args, + env: importSensitiveMap({ value: raw.env, serverName: base.name, field: "env", scope, secretsToCreate, errors }), + }); + } else if (transport === "sse" || transport === "streamable-http") { + candidate = validateMcpServerDefinition({ + ...base, + transport, + url: raw.url, + headers: importSensitiveMap({ value: raw.headers, serverName: base.name, field: "headers", scope, secretsToCreate, errors }), + }); + } else { + errors.push(`${name}.transport must be stdio, sse, or streamable-http`); + } + if (!candidate) { + errors.push(`${name} is not a valid MCP server definition`); + continue; + } + if (names.has(candidate.name)) { + errors.push(`Duplicate MCP server name: ${candidate.name}`); + continue; + } + names.add(candidate.name); + definitions.push(candidate); + } + return { definitions, secretsToCreate, errors }; +} + +function exportSensitiveMap(values: Record | undefined): Record | undefined { + if (!values) return undefined; + const out: Record = {}; + for (const [key, value] of Object.entries(values)) { + if (isMcpSecretRef(value)) out[key] = { secretRef: value.secretRef, scope: value.scope }; + } + return Object.keys(out).length > 0 ? out : undefined; +} + +/** Export Fusion MCP definitions as JSON-safe `mcpServers` data with secret refs preserved and never resolved. */ +export function exportMcpServersJson(definitions: McpServerDefinition[]): { mcpServers: Record } { + const mcpServers: Record = {}; + for (const definition of definitions) { + const server = validateMcpServerDefinition(definition); + if (!server) continue; + if (server.transport === "stdio") { + mcpServers[server.name] = { + transport: "stdio", + ...(server.enabled !== undefined ? { enabled: server.enabled } : {}), + command: server.command, + ...(server.args ? { args: server.args } : {}), + ...(server.env ? { env: exportSensitiveMap(server.env) } : {}), + }; + continue; + } + mcpServers[server.name] = { + transport: server.transport, + ...(server.enabled !== undefined ? { enabled: server.enabled } : {}), + url: server.url, + ...(server.headers ? { headers: exportSensitiveMap(server.headers) } : {}), + }; + } + return { mcpServers }; +} diff --git a/packages/core/src/settings-schema.ts b/packages/core/src/settings-schema.ts index 817a576160..0de95f06e8 100644 --- a/packages/core/src/settings-schema.ts +++ b/packages/core/src/settings-schema.ts @@ -1,5 +1,5 @@ import { DEFAULT_MAX_AUTO_MERGE_RETRIES } from "./in-review-stall.js"; -import type { CliAgentSettings, GlobalSettings, ProjectSettings, Settings } from "./types.js"; +import type { CliAgentSettings, GlobalSettings, McpSecretRef, McpServerDefinition, ProjectSettings, Settings } from "./types.js"; export interface MergeRequestContractShadowSettingsSource { mergeRequestContractShadowEnabled?: boolean; @@ -200,6 +200,10 @@ export const DEFAULT_GLOBAL_SETTINGS = { researchGlobalMaxSearchResults: 10, researchGlobalFetchTimeoutMs: 30_000, researchGlobalUserAgent: "FusionResearchBot/1.0", + mcpServers: { + enabled: false, + servers: [], + }, remoteAccess: { activeProvider: null, providers: { @@ -295,6 +299,10 @@ export const DEFAULT_PROJECT_SETTINGS = { owningNodeHandoffPolicy: "reassign-to-local", defaultNodeId: undefined, secretsEnv: undefined, + mcpServers: { + enabled: false, + servers: [], + }, worktreeInitCommand: undefined, /* FNXC:WorktreeCopyFiles 2026-06-24-00:00: @@ -698,3 +706,83 @@ export function sanitizeCliAgentsSettings(value: unknown): Record; + if (typeof input.secretRef !== "string") return undefined; + const secretRef = input.secretRef.trim(); + if (!secretRef || (input.scope !== "project" && input.scope !== "global")) return undefined; + return { secretRef, scope: input.scope }; +} + +function sanitizeMcpSensitiveMap(value: unknown): Record | undefined { + if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; + const out: Record = {}; + for (const [rawKey, rawValue] of Object.entries(value as Record)) { + const key = rawKey.trim(); + if (!key) continue; + const ref = sanitizeMcpSecretRef(rawValue); + if (ref) out[key] = ref; + } + return Object.keys(out).length > 0 ? out : undefined; +} + +function sanitizeMcpServerDefinition(value: unknown): McpServerDefinition | undefined { + if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; + const input = value as Record; + if (typeof input.name !== "string") return undefined; + const name = input.name.trim(); + if (!name) return undefined; + const enabled = typeof input.enabled === "boolean" ? input.enabled : undefined; + const base = { name, ...(enabled !== undefined ? { enabled } : {}) }; + + if (input.transport === "stdio") { + if (typeof input.command !== "string" || input.command.trim().length === 0) return undefined; + const args = sanitizeStringArray(input.args); + const env = sanitizeMcpSensitiveMap(input.env); + return { + ...base, + transport: "stdio", + command: input.command.trim(), + ...(args ? { args } : {}), + ...(env ? { env } : {}), + }; + } + + if (input.transport === "sse" || input.transport === "streamable-http") { + if (typeof input.url !== "string" || input.url.trim().length === 0) return undefined; + const headers = sanitizeMcpSensitiveMap(input.headers); + return { + ...base, + transport: input.transport, + url: input.url.trim(), + ...(headers ? { headers } : {}), + }; + } + + return undefined; +} + +/** + * Sanitize MCP settings at the write boundary. Malformed server declarations are + * dropped, duplicate names collapse to the last valid declaration, and sensitive + * env/header values survive only as Fusion secret references. Pure — no I/O. + */ +export function sanitizeMcpServers(value: unknown): { enabled?: boolean; servers: McpServerDefinition[] } { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return { enabled: false, servers: [] }; + } + const input = value as Record; + const byName = new Map(); + if (Array.isArray(input.servers)) { + for (const rawServer of input.servers) { + const server = sanitizeMcpServerDefinition(rawServer); + if (server) byName.set(server.name, server); + } + } + return { + enabled: typeof input.enabled === "boolean" ? input.enabled : false, + servers: [...byName.values()], + }; +} diff --git a/packages/core/src/settings-validation.ts b/packages/core/src/settings-validation.ts index 2ca592b6a9..a9e050b092 100644 --- a/packages/core/src/settings-validation.ts +++ b/packages/core/src/settings-validation.ts @@ -4,13 +4,16 @@ import type { HeartbeatPromptTemplate, HeartbeatScopeDisciplineMode, Locale, + McpSensitiveValue, + McpServerDefinition, + McpServersSettings, SandboxBackendName, SandboxFailureMode, SandboxPolicy, SandboxProjectSettings, UnavailableNodePolicy, } from "./types.js"; -import { isLocale } from "./types.js"; +import { isLocale, isMcpSecretRef } from "./types.js"; const UNAVAILABLE_NODE_POLICIES: readonly UnavailableNodePolicy[] = ["block", "fallback-local"] as const; const DIRECT_MERGE_COMMIT_STRATEGIES: readonly DirectMergeCommitStrategy[] = ["auto", "always-squash", "always-rebase"] as const; @@ -204,3 +207,170 @@ export function validateSandboxProjectSettings(value: unknown): SandboxProjectSe ...(failureMode !== undefined ? { failureMode } : {}), }; } + +export interface McpValidationError { + path: string; + code: + | "invalid-shape" + | "invalid-name" + | "duplicate-name" + | "invalid-transport" + | "missing-command" + | "missing-url" + | "invalid-args" + | "invalid-sensitive-map" + | "plaintext-secret"; + message: string; +} + +export interface McpValidationResult { + value?: T; + errors: McpValidationError[]; +} + +function mcpError(path: string, code: McpValidationError["code"], message: string): McpValidationError { + return { path, code, message }; +} + +function validateMcpStringArray(value: unknown, path: string): McpValidationResult { + if (value === undefined) return { value: undefined, errors: [] }; + if (!Array.isArray(value) || !value.every((entry) => typeof entry === "string" && entry.trim().length > 0)) { + return { errors: [mcpError(path, "invalid-args", "Expected an array of non-empty strings")] }; + } + return { value: value.map((entry) => entry.trim()), errors: [] }; +} + +function validateMcpSensitiveMap( + value: unknown, + path: string, +): McpValidationResult | undefined> { + if (value === undefined) return { value: undefined, errors: [] }; + if (!value || typeof value !== "object" || Array.isArray(value)) { + return { errors: [mcpError(path, "invalid-sensitive-map", "Expected an object whose values are secret references")] }; + } + const out: Record = {}; + const errors: McpValidationError[] = []; + for (const [key, entry] of Object.entries(value as Record)) { + if (!key.trim()) { + errors.push(mcpError(`${path}.${key}`, "invalid-sensitive-map", "Sensitive field names must be non-empty")); + continue; + } + if (typeof entry === "string") { + errors.push(mcpError(`${path}.${key}`, "plaintext-secret", "Sensitive MCP values must be Fusion secret references, never plaintext strings")); + continue; + } + if (!isMcpSecretRef(entry)) { + errors.push(mcpError(`${path}.${key}`, "invalid-sensitive-map", "Sensitive MCP values must be { secretRef, scope } objects")); + continue; + } + out[key.trim()] = { secretRef: entry.secretRef.trim(), scope: entry.scope }; + } + return errors.length > 0 ? { errors } : { value: out, errors: [] }; +} + +export function validateMcpServerDefinitionDetailed(value: unknown, path = "server"): McpValidationResult { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return { errors: [mcpError(path, "invalid-shape", "MCP server definition must be an object")] }; + } + const input = value as Record; + const errors: McpValidationError[] = []; + if (typeof input.name !== "string" || input.name.trim().length === 0) { + errors.push(mcpError(`${path}.name`, "invalid-name", "MCP server name is required")); + } + const enabled = typeof input.enabled === "boolean" ? input.enabled : undefined; + + if (input.transport === "stdio") { + if (typeof input.command !== "string" || input.command.trim().length === 0) { + errors.push(mcpError(`${path}.command`, "missing-command", "stdio MCP servers require a command")); + } + const args = validateMcpStringArray(input.args, `${path}.args`); + const env = validateMcpSensitiveMap(input.env, `${path}.env`); + errors.push(...args.errors, ...env.errors); + if (errors.length > 0) return { errors }; + return { + value: { + name: (input.name as string).trim(), + ...(enabled !== undefined ? { enabled } : {}), + transport: "stdio", + command: (input.command as string).trim(), + ...(args.value ? { args: args.value } : {}), + ...(env.value ? { env: env.value } : {}), + }, + errors: [], + }; + } + + if (input.transport === "sse" || input.transport === "streamable-http") { + if (typeof input.url !== "string" || input.url.trim().length === 0) { + errors.push(mcpError(`${path}.url`, "missing-url", `${input.transport} MCP servers require a url`)); + } + const headers = validateMcpSensitiveMap(input.headers, `${path}.headers`); + errors.push(...headers.errors); + if (errors.length > 0) return { errors }; + return { + value: { + name: (input.name as string).trim(), + ...(enabled !== undefined ? { enabled } : {}), + transport: input.transport, + url: (input.url as string).trim(), + ...(headers.value ? { headers: headers.value } : {}), + }, + errors: [], + }; + } + + errors.push(mcpError(`${path}.transport`, "invalid-transport", "MCP transport must be stdio, sse, or streamable-http")); + return { errors }; +} + +/** Returns a normalized MCP server definition, or undefined with rejection details available from validateMcpServerDefinitionDetailed. */ +export function validateMcpServerDefinition(value: unknown): McpServerDefinition | undefined { + return validateMcpServerDefinitionDetailed(value).value; +} + +export function validateMcpServerDefinitionsDetailed(value: unknown, path = "servers"): McpValidationResult { + if (!Array.isArray(value)) { + return { errors: [mcpError(path, "invalid-shape", "MCP servers must be an array")] }; + } + const errors: McpValidationError[] = []; + const out: McpServerDefinition[] = []; + const names = new Set(); + value.forEach((entry, index) => { + const result = validateMcpServerDefinitionDetailed(entry, `${path}.${index}`); + errors.push(...result.errors); + if (!result.value) return; + if (names.has(result.value.name)) { + errors.push(mcpError(`${path}.${index}.name`, "duplicate-name", `Duplicate MCP server name: ${result.value.name}`)); + return; + } + names.add(result.value.name); + out.push(result.value); + }); + return errors.length > 0 ? { errors } : { value: out, errors: [] }; +} + +/** Returns unique normalized MCP server definitions, otherwise undefined. */ +export function validateMcpServerDefinitions(value: unknown): McpServerDefinition[] | undefined { + return validateMcpServerDefinitionsDetailed(value).value; +} + +export function validateMcpServersSettingsDetailed(value: unknown, path = "mcpServers"): McpValidationResult { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return { errors: [mcpError(path, "invalid-shape", "MCP settings must be an object")] }; + } + const input = value as Record; + const servers = input.servers === undefined ? { value: [], errors: [] } : validateMcpServerDefinitionsDetailed(input.servers, `${path}.servers`); + if (servers.errors.length > 0) return { errors: servers.errors }; + return { + value: { + enabled: typeof input.enabled === "boolean" ? input.enabled : undefined, + servers: servers.value ?? [], + }, + errors: [], + }; +} + +/** Returns normalized MCP settings, otherwise undefined. */ +export function validateMcpServersSettings(value: unknown): McpServersSettings | undefined { + return validateMcpServersSettingsDetailed(value).value; +} diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 40af737a30..b1f06a1b3f 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -5,6 +5,7 @@ import type { StalePausedReviewSignal } from "./stale-paused-review.js"; import type { StalePausedTodoSignal } from "./stale-paused-todo.js"; import type { StalledReviewSignal } from "./stalled-review-detector.js"; import type { TaskAgeStalenessSignal } from "./task-age-staleness.js"; +import type { SecretScope } from "./secrets-store.js"; export { computeCapacityRisk, @@ -3100,6 +3101,58 @@ export interface WorktrunkSettings { installedBinaryPath?: string; } +/** + * FNXC:McpConfig 2026-06-25-00:00: + * MCP servers are trusted once enabled because downstream runtime slices may launch local commands or connect to operator-provided URLs. Store only declarations here; sensitive env, header, and token material MUST be represented as Fusion-managed secret references, never inline plaintext. + */ +export interface McpSecretRef { + secretRef: string; + scope: SecretScope; +} + +export function isMcpSecretRef(value: unknown): value is McpSecretRef { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const candidate = value as Record; + return ( + typeof candidate.secretRef === "string" && + candidate.secretRef.trim().length > 0 && + (candidate.scope === "project" || candidate.scope === "global") + ); +} + +export type McpSensitiveValue = McpSecretRef | string; + +export interface McpStdioTransport { + transport: "stdio"; + command: string; + args?: string[]; + env?: Record; +} + +export interface McpSseTransport { + transport: "sse"; + url: string; + headers?: Record; +} + +export interface McpStreamableHttpTransport { + transport: "streamable-http"; + url: string; + headers?: Record; +} + +export type McpTransport = McpStdioTransport | McpSseTransport | McpStreamableHttpTransport; + +export type McpServerDefinition = { + name: string; + enabled?: boolean; +} & McpTransport; + +export interface McpServersSettings { + enabled?: boolean; + servers?: McpServerDefinition[]; +} + export interface GlobalSettings { /** Theme mode preference: dark, light, or system (follows OS). Default: "dark". */ themeMode?: ThemeMode; @@ -3492,6 +3545,10 @@ export interface GlobalSettings { * Stores both provider configs, active provider selection, token strategy, * and lifecycle restart metadata for remote tunnel orchestration. */ remoteAccess?: RemoteAccessProjectSettings; + /** Global defaults for user-configurable MCP servers. + * Project-level `mcpServers` entries override by server name and may disable + * a global server without deleting the global declaration. */ + mcpServers?: McpServersSettings; /** Global defaults for worktrunk integration. * Merged with project-level `worktrunk` field-by-field in `getSettings()`/ * `getSettingsFast()` so partial project overrides inherit unspecified fields. */ @@ -3792,6 +3849,10 @@ export interface ProjectSettings { researchSettings?: ResearchProjectSettings; /** Optional per-project `.env` materialization settings for exportable secrets. */ secretsEnv?: SecretsEnvSettings; + /** Project-scoped MCP server overrides. + * Entries override global server declarations by name; `enabled: false` on a + * same-named entry disables that server for this project. */ + mcpServers?: McpServersSettings; /** Sandbox command-execution settings. * When omitted, runtime behavior is preserved via native passthrough defaults. */ sandbox?: SandboxProjectSettings; @@ -4584,6 +4645,7 @@ export { resolvePersistAgentThinkingLog, sanitizeCliAgentSettings, sanitizeCliAgentsSettings, + sanitizeMcpServers, CLI_AGENT_ADAPTER_IDS, CLI_AGENT_AUTONOMY_MODES, } from "./settings-schema.js"; From b6b5583f0125a3ca2ce56ac11a095687cfe0496d Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Thu, 25 Jun 2026 21:05:33 -0700 Subject: [PATCH 11/50] FN-7039: apply execution-lane models to workflow steps Route workflow and automation prompt steps through the execution-lane model hierarchy. - Use executor session model resolution for task workflow prompt steps while preserving per-step overrides. - Use execution settings model resolution for scheduled and manual AI-prompt automation runs. - Document the model precedence and add regression coverage plus a patch changeset. Files changed: .changeset/fn-7039-workflow-execution-model.md | 7 + docs/settings-reference.md | 2 + .../core/src/__tests__/model-resolution.test.ts | 12 ++ packages/dashboard/src/routes.ts | 6 +- .../engine/src/__tests__/executor-test-helpers.ts | 11 +- .../__tests__/executor-workflow-step-model.test.ts | 234 +++++++++++++++++++++ packages/engine/src/cron-runner.ts | 7 +- packages/engine/src/executor.ts | 23 +- 8 files changed, 285 insertions(+), 17 deletions(-) Fusion-Task-Id: FN-7039 Fusion-Task-Lineage: f27c4be8-7793-4212-b9ee-679f50193406 --- .../fn-7039-workflow-execution-model.md | 7 + docs/settings-reference.md | 2 + .../src/__tests__/model-resolution.test.ts | 12 + packages/dashboard/src/routes.ts | 6 +- .../src/__tests__/executor-test-helpers.ts | 11 +- .../executor-workflow-step-model.test.ts | 234 ++++++++++++++++++ packages/engine/src/cron-runner.ts | 7 +- packages/engine/src/executor.ts | 23 +- 8 files changed, 285 insertions(+), 17 deletions(-) create mode 100644 .changeset/fn-7039-workflow-execution-model.md create mode 100644 packages/engine/src/__tests__/executor-workflow-step-model.test.ts diff --git a/.changeset/fn-7039-workflow-execution-model.md b/.changeset/fn-7039-workflow-execution-model.md new file mode 100644 index 0000000000..69cacabf14 --- /dev/null +++ b/.changeset/fn-7039-workflow-execution-model.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Workflow and automation steps now use the configured project Execution model instead of the default. +category: fix +dev: Workflow/AI-prompt step model resolution now consults the execution lane (resolveExecutorSessionModel / resolveExecutionSettingsModel) instead of resolveProjectDefaultModel, fixing executeWorkflowStep (executor.ts), cron-runner.ts, and dashboard routes.ts. FN-7039. diff --git a/docs/settings-reference.md b/docs/settings-reference.md index 5461376501..0127bbe983 100644 --- a/docs/settings-reference.md +++ b/docs/settings-reference.md @@ -877,6 +877,8 @@ Z.ai's built-in provider uses the existing `zai` auth entry / `ZAI_API_KEY` envi 6. Assigned durable agent runtime model (`runtimeConfig.model` or `runtimeConfig.modelProvider` + `runtimeConfig.modelId`) when both provider and model ID are set and no task/lane/default pair is configured 7. Automatic provider/model resolution +Workflow prompt steps and scheduled/manual AI-prompt automation steps use the same executor lane before falling back to project/global defaults; explicit step-level `modelProvider` + `modelId` values still take precedence for that individual step. + ### Heartbeat model (durable agents) Heartbeat sessions for durable agents use this order: diff --git a/packages/core/src/__tests__/model-resolution.test.ts b/packages/core/src/__tests__/model-resolution.test.ts index c48ef1e1e5..94ff91521e 100644 --- a/packages/core/src/__tests__/model-resolution.test.ts +++ b/packages/core/src/__tests__/model-resolution.test.ts @@ -35,6 +35,18 @@ describe("model-resolution", () => { ).toEqual({ provider: "google", modelId: "gemini-2.5-pro" }); }); + it("selects the project execution lane over the base default for workflow-step callers", () => { + const resolved = resolveExecutionSettingsModel({ + executionProvider: "openai", + executionModelId: "gpt-4o", + defaultProvider: "anthropic", + defaultModelId: "claude-3-5-sonnet", + }); + + expect(resolved).toEqual({ provider: "openai", modelId: "gpt-4o" }); + expect(resolved).not.toEqual({ provider: "anthropic", modelId: "claude-3-5-sonnet" }); + }); + it("falls back from planning global to the project default override", () => { expect( resolvePlanningSettingsModel({ diff --git a/packages/dashboard/src/routes.ts b/packages/dashboard/src/routes.ts index 8cff6f4654..a6cff4824b 100644 --- a/packages/dashboard/src/routes.ts +++ b/packages/dashboard/src/routes.ts @@ -31,7 +31,7 @@ import { readAgentMemoryFile, resolvePlanningSettingsModel, resolvePluginEntryPath, - resolveProjectDefaultModel, + resolveExecutionSettingsModel, resolveTitleSummarizerSettingsModel, writeAgentMemoryFile, } from "@fusion/core"; @@ -5121,7 +5121,9 @@ async function executeAiPromptStep( } const settings = await taskStore.getSettings(); - const defaultModel = resolveProjectDefaultModel(settings); + // Resolve model: step override → project execution lane → global execution lane → project default override → global default + // FNXC:ModelResolution 2026-06-25-12:00: FN-7039 requires manual AI-prompt workflow runs to use execution-lane settings before default settings because these runs have no task/runtime model context. + const defaultModel = resolveExecutionSettingsModel(settings); const modelProvider = step.modelProvider?.trim() || defaultModel.provider; const modelId = step.modelId?.trim() || defaultModel.modelId; let responseText = ""; diff --git a/packages/engine/src/__tests__/executor-test-helpers.ts b/packages/engine/src/__tests__/executor-test-helpers.ts index 8a17d1e403..9eb42edaa9 100644 --- a/packages/engine/src/__tests__/executor-test-helpers.ts +++ b/packages/engine/src/__tests__/executor-test-helpers.ts @@ -79,10 +79,8 @@ vi.mock("../agent-session-helpers.js", async () => { settings: Record | undefined, assignedAgentRuntimeConfig?: Record, ) => { - const model = typeof assignedAgentRuntimeConfig?.model === "string" ? assignedAgentRuntimeConfig.model : ""; - const slash = model.indexOf("/"); - if (slash > 0 && slash < model.length - 1) { - return { provider: model.slice(0, slash), modelId: model.slice(slash + 1) }; + if (settings?.testMode === true || (typeof settings?.defaultProvider === "string" && settings.defaultProvider.trim().toLowerCase() === "mock")) { + return { provider: "mock", modelId: "scripted" }; } if (taskModelProvider && taskModelId) return { provider: taskModelProvider, modelId: taskModelId }; if (typeof settings?.executionProvider === "string" && typeof settings?.executionModelId === "string") { @@ -97,6 +95,11 @@ vi.mock("../agent-session-helpers.js", async () => { if (typeof settings?.defaultProvider === "string" && typeof settings?.defaultModelId === "string") { return { provider: settings.defaultProvider as string, modelId: settings.defaultModelId as string }; } + const model = typeof assignedAgentRuntimeConfig?.model === "string" ? assignedAgentRuntimeConfig.model : ""; + const slash = model.indexOf("/"); + if (slash > 0 && slash < model.length - 1) { + return { provider: model.slice(0, slash), modelId: model.slice(slash + 1) }; + } return { provider: undefined, modelId: undefined }; }, }; diff --git a/packages/engine/src/__tests__/executor-workflow-step-model.test.ts b/packages/engine/src/__tests__/executor-workflow-step-model.test.ts new file mode 100644 index 0000000000..93b66376ef --- /dev/null +++ b/packages/engine/src/__tests__/executor-workflow-step-model.test.ts @@ -0,0 +1,234 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import "./executor-test-helpers.js"; +import { TaskExecutor } from "../executor.js"; +import { + createMockStore, + mockedCreateFnAgent, + mockedExecSync, + resetExecutorMocks, +} from "./executor-test-helpers.js"; + +type CapturedSession = { + defaultProvider?: string; + defaultModelId?: string; +}; + +function captureSession(output = '{"verdict":"APPROVE","notes":""}'): { last?: CapturedSession } { + const holder: { last?: CapturedSession } = {}; + mockedCreateFnAgent.mockImplementation(async (opts: any) => { + holder.last = { + defaultProvider: opts.defaultProvider, + defaultModelId: opts.defaultModelId, + }; + + const listeners: Array<(event: any) => void> = []; + const session: any = { + state: {}, + subscribe: (fn: (event: any) => void) => { + listeners.push(fn); + return () => {}; + }, + prompt: vi.fn(async () => { + for (const fn of listeners) { + fn({ + type: "message_update", + assistantMessageEvent: { + type: "text_delta", + partial: output, + contentIndex: 0, + delta: output, + }, + }); + } + }), + dispose: vi.fn(), + }; + return { session }; + }); + return holder; +} + +function quietGit() { + mockedExecSync.mockImplementation(() => Buffer.from("")); +} + +function makeExecutor(store: ReturnType) { + const agentStore = { getAgent: vi.fn().mockResolvedValue(null), createAgent: vi.fn() }; + return new TaskExecutor(store as any, "/tmp/test", { agentStore } as any); +} + +function baseTask(overrides: Record = {}) { + return { + id: "FN-MODEL-1", + title: "Model resolution", + description: "verify model resolution", + column: "in-progress" as const, + worktree: "/tmp/wt", + branch: "fusion/fn-model-1", + baseCommitSha: "abc123", + dependencies: [], + steps: [{ name: "s", status: "in-progress" as const }], + currentStep: 0, + log: [], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + ...overrides, + }; +} + +function workflowStep(overrides: Record = {}) { + const now = new Date().toISOString(); + return { + id: "step:model", + name: "Model Step", + description: "", + mode: "prompt" as const, + phase: "pre-merge" as const, + gateMode: "advisory" as const, + prompt: "Check the model.", + toolMode: "readonly" as const, + enabled: true, + createdAt: now, + updatedAt: now, + ...overrides, + }; +} + +async function runStepWithSettings( + settings: Record, + options: { + task?: Record; + step?: Record; + } = {}, +) { + const store = createMockStore(); + store.getSettings.mockResolvedValue(settings); + const executor = makeExecutor(store); + const captured = captureSession(); + + await (executor as any).executeWorkflowStep( + baseTask(options.task), + workflowStep(options.step), + "/tmp/wt", + settings, + undefined, + ); + + return captured.last; +} + +describe("executor workflow-step model resolution", () => { + beforeEach(() => { + resetExecutorMocks(); + quietGit(); + }); + + it("uses the project execution lane instead of the global default when the step has no override", async () => { + const captured = await runStepWithSettings({ + executionProvider: "openai", + executionModelId: "gpt-4o", + defaultProvider: "anthropic", + defaultModelId: "claude-3-5-sonnet", + }); + + expect(captured).toMatchObject({ + defaultProvider: "openai", + defaultModelId: "gpt-4o", + }); + expect(captured).not.toMatchObject({ + defaultProvider: "anthropic", + defaultModelId: "claude-3-5-sonnet", + }); + }); + + it("keeps step and task overrides ahead of execution-lane settings", async () => { + await expect( + runStepWithSettings( + { + executionProvider: "project-exec-provider", + executionModelId: "project-exec-model", + }, + { + step: { + modelProvider: "step-provider", + modelId: "step-model", + }, + }, + ), + ).resolves.toMatchObject({ + defaultProvider: "step-provider", + defaultModelId: "step-model", + }); + + await expect( + runStepWithSettings( + { + executionProvider: "project-exec-provider", + executionModelId: "project-exec-model", + }, + { + task: { + modelProvider: "task-provider", + modelId: "task-model", + }, + }, + ), + ).resolves.toMatchObject({ + defaultProvider: "task-provider", + defaultModelId: "task-model", + }); + }); + + it("falls through the execution hierarchy without mixing partial pairs", async () => { + await expect( + runStepWithSettings({ + executionProvider: "partial-project-provider", + executionGlobalProvider: "global-exec-provider", + executionGlobalModelId: "global-exec-model", + defaultProvider: "global-default-provider", + defaultModelId: "global-default-model", + }), + ).resolves.toMatchObject({ + defaultProvider: "global-exec-provider", + defaultModelId: "global-exec-model", + }); + + await expect( + runStepWithSettings({ + executionGlobalModelId: "partial-global-model", + defaultProviderOverride: "project-default-provider", + defaultModelIdOverride: "project-default-model", + defaultProvider: "global-default-provider", + defaultModelId: "global-default-model", + }), + ).resolves.toMatchObject({ + defaultProvider: "project-default-provider", + defaultModelId: "project-default-model", + }); + + await expect( + runStepWithSettings({ + defaultProvider: "global-default-provider", + defaultModelId: "global-default-model", + }), + ).resolves.toMatchObject({ + defaultProvider: "global-default-provider", + defaultModelId: "global-default-model", + }); + }); + + it("forces mock/scripted for workflow steps when test mode is active", async () => { + await expect( + runStepWithSettings({ + testMode: true, + executionProvider: "project-exec-provider", + executionModelId: "project-exec-model", + defaultProvider: "anthropic", + defaultModelId: "claude-3-5-sonnet", + }), + ).resolves.toMatchObject({ + defaultProvider: "mock", + defaultModelId: "scripted", + }); + }); +}); diff --git a/packages/engine/src/cron-runner.ts b/packages/engine/src/cron-runner.ts index 1a6b8c4186..38e223c685 100644 --- a/packages/engine/src/cron-runner.ts +++ b/packages/engine/src/cron-runner.ts @@ -1,7 +1,7 @@ import { exec } from "node:child_process"; import { - resolveProjectDefaultModel, + resolveExecutionSettingsModel, runScheduledEvalBatch, resolveTaskEvaluationSettings, isEvalsExperimentalEnabled, @@ -855,9 +855,10 @@ export class CronRunner { }; } - // Resolve model: step override → project default override → global default + // Resolve model: step override → project execution lane → global execution lane → project default override → global default + // FNXC:ModelResolution 2026-06-25-12:00: FN-7039 requires scheduled AI-prompt automation steps to use execution-lane settings before default settings because these steps have no task/runtime model context. const settings = await this.store.getSettings(); - const defaultModel = resolveProjectDefaultModel(settings); + const defaultModel = resolveExecutionSettingsModel(settings); const modelProvider = step.modelProvider?.trim() || defaultModel.provider; const modelId = step.modelId?.trim() || defaultModel.modelId; diff --git a/packages/engine/src/executor.ts b/packages/engine/src/executor.ts index df189829e6..2e7a5d853f 100644 --- a/packages/engine/src/executor.ts +++ b/packages/engine/src/executor.ts @@ -53,7 +53,6 @@ import { resolveAgentPrompt, resolvePersistAgentThinkingLog, resolveEffectiveAgentPermissionPolicy, - resolveProjectDefaultModel, resolveAgentMemoryInclusionMode, loadWorkspaceConfig, type WorkspaceConfig, @@ -13423,13 +13422,21 @@ You have access to the file system to review changes.${verdictBlock}`; }); // Determine primary model and an explicit fallback. The workflow step's - // own override takes precedence; otherwise we use the project default - // override before falling through to the global default. The - // fallback is the per-step override's missing-counterpart settings, then - // the global validator/fallback pair, then the executor's `fallbackProvider`. - const defaultModel = resolveProjectDefaultModel(settings); - const primaryProvider = workflowStep.modelProvider || defaultModel.provider; - const primaryModelId = workflowStep.modelId || defaultModel.modelId; + // own override takes precedence; otherwise use the canonical executor + // hierarchy: task override → project execution lane → global execution lane + // → project default override → global default. The fallback is the per-step + // override's missing-counterpart settings, then the global validator/fallback + // pair, then the executor's `fallbackProvider`. + // FNXC:ModelResolution 2026-06-25-12:00: FN-7039 requires workflow steps to inherit project execution-lane model settings before default settings so configured Execution models reach step sessions unless the step itself overrides them. + const assignedRuntimeConfig = await this.getAssignedAgentRuntimeConfig(task.assignedAgentId); + const executorModel = resolveExecutorSessionModel( + task.modelProvider, + task.modelId, + settings, + assignedRuntimeConfig, + ); + const primaryProvider = workflowStep.modelProvider || executorModel.provider; + const primaryModelId = workflowStep.modelId || executorModel.modelId; const useOverride = !!(workflowStep.modelProvider && workflowStep.modelId); type ModelTuple = { provider?: string; modelId?: string }; From 2cff1864c96c44418cbd29d4d08e2a10f7b5cd36 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Thu, 25 Jun 2026 21:42:22 -0700 Subject: [PATCH 12/50] fix: bound @fusion/core affected lane + tighten changed watchdog under engine kill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three changes to make `pnpm test` reliably minimal and fail gracefully: - @fusion/core is now a memory-envelope/wide-fan-out package (was unguarded). It's the hub nearly everything imports (~354 test files), so a core source edit made `vitest --changed` expand to ~the whole core suite and blow past the engine's 15-min verification kill -> SIGKILL + task restart. Adding it to SCOPED_AFFECTED_MEMORY_ENVELOPES applies the wide-fan-out guard (run only directly-changed core tests, else delegate) and the bounded env. core is NOT gate-covered, so delegation warns loudly rather than false-greens. - Lower CLASS_BUDGET_BANDS.changed ceiling 20min -> 13min so the script watchdog fails a runaway local lane itself (exit 124, no restart) BEFORE the engine's 15-min kill restarts the whole task. A tightening, not a timeout-widening. Guard test pins ceiling < 900_000ms. - Raise scoped-affected worker fan-out 1 -> 4 (operator decision). Was 1 only for OOM safety (FN-6854/FN-6874); the fan-out guard now bounds the set so the hundreds-of-files OOM driver no longer reaches these workers. Heap stays 6144MB/worker (~4x6GB on the lane) — revisit if a RAM-constrained CI runner OOMs. Trades FN-5048 worker-knob guidance for throughput, scoped to the bounded affected lanes only. Tests: test-changed 117/117, watchdog 15/15, eslint clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../__tests__/run-vitest-watchdog.test.mjs | 18 ++++++ scripts/__tests__/test-changed.test.mjs | 63 ++++++++++++++++--- scripts/lib/run-vitest-watchdog.mjs | 15 ++++- scripts/test-changed.mjs | 43 ++++++++++++- 4 files changed, 128 insertions(+), 11 deletions(-) diff --git a/scripts/__tests__/run-vitest-watchdog.test.mjs b/scripts/__tests__/run-vitest-watchdog.test.mjs index 191dbf0ec2..65ba64f730 100644 --- a/scripts/__tests__/run-vitest-watchdog.test.mjs +++ b/scripts/__tests__/run-vitest-watchdog.test.mjs @@ -37,6 +37,24 @@ test("deriveBudgetMs: no fresh timing falls back to the per-class ceiling", () = ); }); +test("the `changed` ceiling must sit below the engine's 15-min verification kill", () => { + // FNXC:TestInfrastructure 2026-06-26-13:05: a stale/missing timings snapshot + // makes deriveBudgetMs return the `changed` ceiling. That ceiling MUST stay + // under VERIFICATION_TIMEOUT_WORKSPACE_MS (900_000 = 15min in + // packages/engine/src/verification-utils.ts) so the script watchdog fails a + // runaway local affected lane itself (exit 124, no task restart) instead of + // letting the engine SIGKILL `pnpm test` and restart the whole task. If this + // assertion ever trips, lower CLASS_BUDGET_BANDS.changed.ceiling — do not + // raise the engine kill. + const ENGINE_VERIFICATION_KILL_MS = 900_000; + const ceiling = deriveBudgetMs({ klass: "changed", expectedDurationMs: 1000, timingsFresh: false }); + assert.equal(ceiling, CLASS_BUDGET_BANDS.changed.ceiling); + assert.ok( + CLASS_BUDGET_BANDS.changed.ceiling < ENGINE_VERIFICATION_KILL_MS, + `changed ceiling ${CLASS_BUDGET_BANDS.changed.ceiling}ms must be < engine kill ${ENGINE_VERIFICATION_KILL_MS}ms`, + ); +}); + test("deriveBudgetMs: fresh timing tightens within the band", () => { // expected×multiplier between floor and ceiling → use the tightened value. // 300s × 3.5 = 1050s, which sits between the shard floor (15min) and diff --git a/scripts/__tests__/test-changed.test.mjs b/scripts/__tests__/test-changed.test.mjs index 62e85c4c0f..b0da039c2d 100644 --- a/scripts/__tests__/test-changed.test.mjs +++ b/scripts/__tests__/test-changed.test.mjs @@ -47,6 +47,11 @@ import { changedSourceFilesAffectingPackage, existingChangedTestFilesInPackage, GATE_COVERED_MEMORY_ENVELOPE_PACKAGES, + SCOPED_AFFECTED_MEMORY_ENVELOPES, + CORE_SCOPED_AFFECTED_PACKAGE, + CORE_SCOPED_AFFECTED_HEAP_MB, + CORE_SCOPED_AFFECTED_WORKERS, + createScopedAffectedMemoryEnvelopeEnv, } from "../test-changed.mjs"; import { deriveBudgetMs } from "../lib/run-vitest-watchdog.mjs"; @@ -289,7 +294,7 @@ function assertScopedAffectedEnv(env, { heapMb, workers }) { assert.equal(env.HOME, "/tmp/fusion-home"); } -test("partitionScopedAffectedPackages: isolates dashboard and engine into separate envelope groups", () => { +test("partitionScopedAffectedPackages: isolates core, dashboard, and engine into separate envelope groups", () => { assert.deepEqual(summarizeScopedAffectedGroups([DASHBOARD_SCOPED_AFFECTED_PACKAGE]), [ { packages: [DASHBOARD_SCOPED_AFFECTED_PACKAGE], @@ -298,19 +303,30 @@ test("partitionScopedAffectedPackages: isolates dashboard and engine into separa }, ]); - assert.deepEqual(summarizeScopedAffectedGroups(["@fusion/core", DASHBOARD_SCOPED_AFFECTED_PACKAGE]), [ - { packages: ["@fusion/core"], engineMemoryEnvelope: false, memoryEnvelopePackage: null }, + // FNXC:TestInfrastructure 2026-06-26-12:40: @fusion/core is now its own + // memory-envelope group (no longer a regular package), so the wide-fan-out + // guard and bounded heap/worker env apply. Group order follows + // SCOPED_AFFECTED_MEMORY_ENVELOPES key order: engine, dashboard, core. + assert.deepEqual(summarizeScopedAffectedGroups([CORE_SCOPED_AFFECTED_PACKAGE, DASHBOARD_SCOPED_AFFECTED_PACKAGE]), [ { packages: [DASHBOARD_SCOPED_AFFECTED_PACKAGE], engineMemoryEnvelope: false, memoryEnvelopePackage: DASHBOARD_SCOPED_AFFECTED_PACKAGE, }, + { + packages: [CORE_SCOPED_AFFECTED_PACKAGE], + engineMemoryEnvelope: false, + memoryEnvelopePackage: CORE_SCOPED_AFFECTED_PACKAGE, + }, ]); assert.deepEqual( - summarizeScopedAffectedGroups(["@fusion/core", DASHBOARD_SCOPED_AFFECTED_PACKAGE, ENGINE_SCOPED_AFFECTED_PACKAGE]), + summarizeScopedAffectedGroups([ + CORE_SCOPED_AFFECTED_PACKAGE, + DASHBOARD_SCOPED_AFFECTED_PACKAGE, + ENGINE_SCOPED_AFFECTED_PACKAGE, + ]), [ - { packages: ["@fusion/core"], engineMemoryEnvelope: false, memoryEnvelopePackage: null }, { packages: [ENGINE_SCOPED_AFFECTED_PACKAGE], engineMemoryEnvelope: true, @@ -321,14 +337,47 @@ test("partitionScopedAffectedPackages: isolates dashboard and engine into separa engineMemoryEnvelope: false, memoryEnvelopePackage: DASHBOARD_SCOPED_AFFECTED_PACKAGE, }, + { + packages: [CORE_SCOPED_AFFECTED_PACKAGE], + engineMemoryEnvelope: false, + memoryEnvelopePackage: CORE_SCOPED_AFFECTED_PACKAGE, + }, ], ); - assert.deepEqual(summarizeScopedAffectedGroups(["@fusion/core", "@runfusion/fusion"]), [ - { packages: ["@fusion/core", "@runfusion/fusion"], engineMemoryEnvelope: false, memoryEnvelopePackage: null }, + // A genuinely regular package stays in the shared regular group; core splits out. + assert.deepEqual(summarizeScopedAffectedGroups([CORE_SCOPED_AFFECTED_PACKAGE, "@runfusion/fusion"]), [ + { packages: ["@runfusion/fusion"], engineMemoryEnvelope: false, memoryEnvelopePackage: null }, + { + packages: [CORE_SCOPED_AFFECTED_PACKAGE], + engineMemoryEnvelope: false, + memoryEnvelopePackage: CORE_SCOPED_AFFECTED_PACKAGE, + }, ]); }); +test("@fusion/core is a wide-fan-out memory-envelope package but is NOT gate-covered", () => { + // It must be bounded (guard applies) ... + assert.ok( + Object.keys(SCOPED_AFFECTED_MEMORY_ENVELOPES).includes(CORE_SCOPED_AFFECTED_PACKAGE), + "core must be a memory-envelope package so the wide-fan-out guard runs only directly-changed core tests", + ); + // ... yet must NOT claim gate coverage (the merge gate runs no core suite), + // so a delegated core lane warns loudly instead of reporting a false green. + assert.ok( + !GATE_COVERED_MEMORY_ENVELOPE_PACKAGES.has(CORE_SCOPED_AFFECTED_PACKAGE), + "core is not covered by the merge gate; delegation must warn, not reassure", + ); +}); + +test("core scoped-affected env applies the bounded heap and single-worker envelope", () => { + const env = createScopedAffectedMemoryEnvelopeEnv(CORE_SCOPED_AFFECTED_PACKAGE, { + NODE_OPTIONS: "--trace-warnings", + HOME: "/tmp/fusion-home", + }); + assertScopedAffectedEnv(env, { heapMb: CORE_SCOPED_AFFECTED_HEAP_MB, workers: CORE_SCOPED_AFFECTED_WORKERS }); +}); + test("createDashboardScopedAffectedEnv: caps heap, preserves env, lowers workers, and leaves watchdog finite", () => { const env = createDashboardScopedAffectedEnv({ NODE_OPTIONS: "--trace-warnings", diff --git a/scripts/lib/run-vitest-watchdog.mjs b/scripts/lib/run-vitest-watchdog.mjs index 1294836a0d..9ee9c90ed6 100644 --- a/scripts/lib/run-vitest-watchdog.mjs +++ b/scripts/lib/run-vitest-watchdog.mjs @@ -52,8 +52,21 @@ export const CLASS_BUDGET_BANDS = { the two values are not coupled and may diverge. */ shard: { floor: 15 * MINUTE, ceiling: 30 * MINUTE }, + /* + FNXC:TestInfrastructure 2026-06-26-12:40: + The `changed` ceiling MUST sit below the engine's per-task verification kill + (`VERIFICATION_TIMEOUT_WORKSPACE_MS = 900_000` = 15min, verification-utils.ts). + This band is the bound for a local changed-file affected-lane invocation + (`pnpm test` in changed mode). When the timings snapshot is stale (the common + case), deriveBudgetMs returns this ceiling. At the old 20min ceiling the script + watchdog NEVER fired before the engine's 15min kill, so a runaway lane was + SIGKILLed by the engine and the whole task RESTARTED (stacked 15-min timeouts) + instead of failing the lane cleanly here (exit 124, no restart). 13min leaves + margin under the 15min kill so the script fails the lane itself first. Lowering + a ceiling is a tightening, not a timeout-widening appeasement. + */ // One local changed-file package invocation. - changed: { floor: 2 * MINUTE, ceiling: 20 * MINUTE }, + changed: { floor: 2 * MINUTE, ceiling: 13 * MINUTE }, // One dashboard quality lane (heap-managed). Matches the historical 15min. "dashboard-lane": { floor: 15 * MINUTE, ceiling: 30 * MINUTE }, }; diff --git a/scripts/test-changed.mjs b/scripts/test-changed.mjs index 027dd83ec6..b7df291da1 100644 --- a/scripts/test-changed.mjs +++ b/scripts/test-changed.mjs @@ -1317,13 +1317,45 @@ export function packageHasVitestConfig(pkgDir, projectRoot = rootDir) { return VITEST_CONFIG_BASENAMES.some((name) => existsSync(path.join(projectRoot, pkgDir, name))); } +/* +FNXC:TestInfrastructure 2026-06-26-13:05: +Scoped-affected worker fan-out was raised 1 -> 4 (operator decision). It was 1 +purely for OOM safety (FN-6854/FN-6874: heavy affected lanes OS-OOM-SIGKILLed +even at concurrency=1). Two things make 4 acceptable now: (1) the wide-fan-out +guard below bounds each heavy lane to a few directly-changed test files, so the +hundreds-of-files set that drove the OOM no longer reaches these workers; (2) the +heap cap stays 6144MB PER WORKER, so this lane can now use up to ~4x6GB ≈ 24GB — +fine on the 256GB host, but if a RAM-constrained CI runner OOM-SIGKILLs a heavy +lane again, lower this back toward 1 (or drop the per-worker heap) rather than +widening timeouts. This intentionally trades the FN-5048 "don't raise worker +knobs" guidance for throughput, scoped to the bounded affected lanes only. +*/ export const ENGINE_SCOPED_AFFECTED_PACKAGE = "@fusion/engine"; export const ENGINE_SCOPED_AFFECTED_HEAP_MB = "6144"; -export const ENGINE_SCOPED_AFFECTED_WORKERS = "1"; +export const ENGINE_SCOPED_AFFECTED_WORKERS = "4"; export const DASHBOARD_SCOPED_AFFECTED_PACKAGE = "@fusion/dashboard"; export const DASHBOARD_SCOPED_AFFECTED_HEAP_MB = "6144"; -export const DASHBOARD_SCOPED_AFFECTED_WORKERS = "1"; +export const DASHBOARD_SCOPED_AFFECTED_WORKERS = "4"; +export const CORE_SCOPED_AFFECTED_PACKAGE = "@fusion/core"; +export const CORE_SCOPED_AFFECTED_HEAP_MB = "6144"; +export const CORE_SCOPED_AFFECTED_WORKERS = "4"; +/* +FNXC:TestInfrastructure 2026-06-26-12:40: +`@fusion/core` is a memory-envelope/wide-fan-out package too — it was the +remaining `pnpm test` timeout path after engine/dashboard were bounded. core is +the hub nearly every package imports and has ~354 test files (db.test 21s, +mission-store 16s, ...). A non-test core SOURCE edit (e.g. store.ts/db.ts) makes +`vitest --changed` expand to ~the whole core suite at this real-git + +sqlite-heavy lane and blow past the engine's 15-min verification kill, which then +SIGKILLs `pnpm test` and RESTARTS the task — stacked 15-min timeouts. Listing +core here makes `partitionScopedAffectedPackages` treat it as its own +memory-envelope group so the wide-fan-out guard (run only directly-changed core +test files, else delegate) and the bounded heap/worker env both apply. core is +intentionally NOT in GATE_COVERED_MEMORY_ENVELOPE_PACKAGES (the gate runs no core +suite), so a delegated core lane emits the loud "not covered by gate; run +`pnpm test:full`" warning rather than a silent false-green. +*/ export const SCOPED_AFFECTED_MEMORY_ENVELOPES = Object.freeze({ [ENGINE_SCOPED_AFFECTED_PACKAGE]: Object.freeze({ packageName: ENGINE_SCOPED_AFFECTED_PACKAGE, @@ -1335,6 +1367,11 @@ export const SCOPED_AFFECTED_MEMORY_ENVELOPES = Object.freeze({ heapMb: DASHBOARD_SCOPED_AFFECTED_HEAP_MB, workers: DASHBOARD_SCOPED_AFFECTED_WORKERS, }), + [CORE_SCOPED_AFFECTED_PACKAGE]: Object.freeze({ + packageName: CORE_SCOPED_AFFECTED_PACKAGE, + heapMb: CORE_SCOPED_AFFECTED_HEAP_MB, + workers: CORE_SCOPED_AFFECTED_WORKERS, + }), }); /* @@ -1359,7 +1396,7 @@ export function createScopedAffectedMemoryEnvelopeEnv(packageName, env = process if (!envelope) return env; /* FNXC:TestInfrastructure 2026-06-21-11:24: - The engine affected lane can select hundreds of real-git-heavy files when `vitest --changed` sees a widely imported boundary. Run that scoped lane in its own memory envelope: cap Node old-space like the dashboard heap runner and lower Vitest worker fan-out to one process so the lane returns a real pass/fail verdict instead of an OS OOM SIGKILL. Keep watchdog timing outside this env so hangs still fail through `runWithWatchdog`. + The engine affected lane can select hundreds of real-git-heavy files when `vitest --changed` sees a widely imported boundary. Run that scoped lane in its own memory envelope: cap Node old-space like the dashboard heap runner and bound Vitest worker fan-out (see SCOPED_AFFECTED_WORKERS) so the lane returns a real pass/fail verdict instead of an OS OOM SIGKILL. Keep watchdog timing outside this env so hangs still fail through `runWithWatchdog`. FNXC:TestInfrastructure 2026-06-21-16:28: FN-6874 showed the dashboard changed-mode affected lane can OOM/SIGKILL even with `FUSION_TEST_CONCURRENCY=1 FUSION_TEST_WORKSPACE_CONCURRENCY=1`, so worker fan-out alone is not the failure mode. Give each heavy scoped package its own bounded heap envelope while preserving caller env and keeping the finite changed-class watchdog outside this env so hangs still fail instead of being masked. From c20c4b729403469aae8f3605f21a6fcdaa5893f1 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Thu, 25 Jun 2026 21:56:27 -0700 Subject: [PATCH 13/50] fix: stop global settings resetting via project-local central DBs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Production code constructed `new CentralCore(store.getFusionDir())`, pointing the central/global DB at the project's `.fusion/` instead of `~/.fusion/`. `resolveGlobalDir()` returns an explicit dir verbatim, so this spawned stray per-project `fusion-central.db` files seeded with default global settings (globalMaxConcurrent=4, empty secrets) that shadowed the real global DB whenever a read/write hit one of those paths — surfacing as intermittent "all my global settings reset". - Add TaskStore.getGlobalSettingsDir() (resolved global dir; undefined→~/.fusion) - Route the secrets store + secrets/proxy/node/secrets-sync/settings-sync dashboard routes through it instead of getFusionDir() - Add a resolveGlobalDir() guard that throws on a project-local `.fusion/` dir (basename `.fusion` with a `.git` parent); inert under VITEST - Regression tests in global-settings-guard.test.ts Co-Authored-By: Claude Opus 4.8 (1M context) --- ...lobal-settings-reset-project-central-db.md | 7 +++ .../__tests__/global-settings-guard.test.ts | 59 ++++++++++++++++++- packages/core/src/global-settings.ts | 25 +++++++- packages/core/src/store.ts | 11 +++- packages/dashboard/src/routes.ts | 2 +- .../src/routes/register-proxy-routes.ts | 6 +- .../register-secrets-sync-inbound-routes.ts | 4 +- .../routes/register-secrets-sync-routes.ts | 4 +- .../register-settings-sync-inbound-routes.ts | 7 ++- 9 files changed, 110 insertions(+), 15 deletions(-) create mode 100644 .changeset/fix-global-settings-reset-project-central-db.md diff --git a/.changeset/fix-global-settings-reset-project-central-db.md b/.changeset/fix-global-settings-reset-project-central-db.md new file mode 100644 index 0000000000..795e6a23f0 --- /dev/null +++ b/.changeset/fix-global-settings-reset-project-central-db.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Fix global settings (including the global concurrency cap) intermittently resetting to defaults. +category: fix +dev: Several production call sites built `new CentralCore(store.getFusionDir())`, pointing the central/global DB at the project's `.fusion/` instead of `~/.fusion/` and spawning stray per-project central DBs seeded with default global settings that shadowed real global state. Added `TaskStore.getGlobalSettingsDir()`, routed the secrets store plus the secrets/proxy/node/secrets-sync/settings-sync dashboard routes through it, and added a `resolveGlobalDir()` guard that throws on a project-local `.fusion/` dir (parent is a git repo) so the regression can't silently recur. Existing stray DBs were operator-quarantined. diff --git a/packages/core/src/__tests__/global-settings-guard.test.ts b/packages/core/src/__tests__/global-settings-guard.test.ts index 10610d3699..6f0f7f884d 100644 --- a/packages/core/src/__tests__/global-settings-guard.test.ts +++ b/packages/core/src/__tests__/global-settings-guard.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { mkdtempSync, rmSync } from "node:fs"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { resolveGlobalDir } from "../global-settings.js"; @@ -68,3 +68,60 @@ describe("resolveGlobalDir() VITEST guard", () => { }); }); }); + +/* +FNXC:GlobalDirGuard 2026-06-25-22:30: +Regression for the "all my global settings reset" bug: production code that passed a project's `.fusion/` dir (e.g. store.getFusionDir()) to CentralCore/global stores spun up stray per-project central DBs seeded with default global settings that shadowed ~/.fusion. resolveGlobalDir() must refuse a project-local `.fusion/` dir (named `.fusion` inside a git repo) while still accepting the real home global dir and arbitrary non-repo custom dirs. Guard is intentionally inert under VITEST, so these tests clear VITEST to exercise it. +*/ +describe("resolveGlobalDir() project-local .fusion guard", () => { + it("throws when handed a project-local .fusion dir inside a git repo", () => { + withVitestEnv(undefined, () => { + withTempHome((homeDir) => { + const projectRoot = join(homeDir, "code", "my-project"); + mkdirSync(join(projectRoot, ".git"), { recursive: true }); + const projectFusionDir = join(projectRoot, ".fusion"); + mkdirSync(projectFusionDir, { recursive: true }); + + expect(() => resolveGlobalDir(projectFusionDir)).toThrow( + /refusing project-local '\.fusion' directory/, + ); + }); + }); + }); + + it("also catches a git-worktree project (.git file, not dir)", () => { + withVitestEnv(undefined, () => { + withTempHome((homeDir) => { + const worktreeRoot = join(homeDir, "worktrees", "feature"); + mkdirSync(worktreeRoot, { recursive: true }); + writeFileSync(join(worktreeRoot, ".git"), "gitdir: /somewhere/.git/worktrees/feature\n"); + const worktreeFusionDir = join(worktreeRoot, ".fusion"); + mkdirSync(worktreeFusionDir, { recursive: true }); + + expect(() => resolveGlobalDir(worktreeFusionDir)).toThrow( + /refusing project-local '\.fusion' directory/, + ); + }); + }); + }); + + it("allows the real home global dir", () => { + withVitestEnv(undefined, () => { + withTempHome((homeDir) => { + const homeGlobal = join(homeDir, ".fusion"); + expect(resolveGlobalDir(homeGlobal)).toBe(homeGlobal); + }); + }); + }); + + it("allows a custom non-repo global dir (no .git parent)", () => { + withVitestEnv(undefined, () => { + withTempHome((homeDir) => { + const customDir = join(homeDir, "custom-global", ".fusion"); + mkdirSync(customDir, { recursive: true }); + // Parent has no `.git`, so it is not a project worktree. + expect(resolveGlobalDir(customDir)).toBe(customDir); + }); + }); + }); +}); diff --git a/packages/core/src/global-settings.ts b/packages/core/src/global-settings.ts index 02a1474c14..877408d7d4 100644 --- a/packages/core/src/global-settings.ts +++ b/packages/core/src/global-settings.ts @@ -14,7 +14,7 @@ */ import { homedir } from "node:os"; -import { dirname, join } from "node:path"; +import { basename, dirname, join } from "node:path"; import { mkdir, readFile, writeFile, rename, chmod } from "node:fs/promises"; import { existsSync, mkdirSync, renameSync } from "node:fs"; import type { GlobalSettings } from "./types.js"; @@ -90,7 +90,28 @@ export function resolveGlobalDir(dir?: string): string { ); } - if (hasExplicitDir) return dir; + if (hasExplicitDir) { + /* + FNXC:GlobalDirGuard 2026-06-25-22:10: + Production code must never point the central/global store at a project's `.fusion/` directory. Doing so silently spins up a stray per-project central DB seeded with DEFAULT global settings (globalMaxConcurrent=4, empty global secrets, default centralSettings), which then shadows the real `~/.fusion/fusion-central.db` and manifests as "all my global settings reset". Root cause was call sites passing `store.getFusionDir()` instead of the resolved global dir. + Guard heuristic: a project `.fusion` dir is named `.fusion` and lives inside a git repo (its parent has a `.git` dir or worktree file), whereas the home global dir's parent (the home dir) is not a repo. We only flag dirs that differ from the home-resolved global dir, so legitimately-threaded global dirs and test temp dirs are unaffected. Skipped under VITEST (tests pass explicit temp dirs by design). + */ + if (process.env.VITEST !== "true") { + const homeGlobalDir = resolveGlobalDirForHome(getHomeDir()); + const looksLikeProjectFusionDir = + dir !== homeGlobalDir && + basename(dir) === ".fusion" && + existsSync(join(dirname(dir), ".git")); + if (looksLikeProjectFusionDir) { + throw new Error( + `resolveGlobalDir(): refusing project-local '.fusion' directory '${dir}' for the central/global store. ` + + "This would create a stray per-project central database seeded with default global settings and silently reset them. " + + "Pass the resolved global dir (or omit the argument so it defaults to ~/.fusion); see TaskStore.getGlobalSettingsDir().", + ); + } + } + return dir; + } return resolveGlobalDirForHome(getHomeDir()); } diff --git a/packages/core/src/store.ts b/packages/core/src/store.ts index 9b4eeb08fd..2cc55e1299 100644 --- a/packages/core/src/store.ts +++ b/packages/core/src/store.ts @@ -16651,6 +16651,14 @@ ${stepsSection}`; return this.fusionDir; } + /* + FNXC:GlobalDirGuard 2026-06-25-22:12: + The resolved GLOBAL settings dir (undefined → ~/.fusion). Distinct from getFusionDir() which is this project's `.fusion/`. Any CentralCore/global-store construction MUST use this, never getFusionDir(); passing the project dir spins up a stray per-project central DB that shadows ~/.fusion and silently resets global settings. + */ + getGlobalSettingsDir(): string | undefined { + return this.globalSettingsDir; + } + getTasksDir(): string { return this.tasksDir; } @@ -16673,7 +16681,8 @@ ${stepsSection}`; return this.secretsStore; } - const central = new CentralCore(this.getFusionDir()); + // FNXC:GlobalDirGuard 2026-06-25-22:13: Secrets live in the GLOBAL central DB (~/.fusion), not this project's `.fusion/`. Use the resolved global dir; passing getFusionDir() created a stray per-project central DB and reset global settings. + const central = new CentralCore(this.getGlobalSettingsDir()); await central.init(); this.secretsCentralCore = central; const centralDb = (central as unknown as { db: import("./central-db.js").CentralDatabase | null }).db; diff --git a/packages/dashboard/src/routes.ts b/packages/dashboard/src/routes.ts index a6cff4824b..9f84c03189 100644 --- a/packages/dashboard/src/routes.ts +++ b/packages/dashboard/src/routes.ts @@ -4463,7 +4463,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout // Node-aware proxying: route to remote node if nodeId is provided and not local if (nodeId) { const { CentralCore } = await import("@fusion/core"); - const central = new CentralCore(store.getFusionDir()); + const central = new CentralCore(store.getGlobalSettingsDir()); await central.init(); const localNodes = await central.listNodes(); diff --git a/packages/dashboard/src/routes/register-proxy-routes.ts b/packages/dashboard/src/routes/register-proxy-routes.ts index d14435afca..03a770ab87 100644 --- a/packages/dashboard/src/routes/register-proxy-routes.ts +++ b/packages/dashboard/src/routes/register-proxy-routes.ts @@ -34,7 +34,7 @@ async function proxyToRemoteNode( const timeoutMs = proxyOptions?.timeoutMs ?? 10_000; const { CentralCore } = await import("@fusion/core"); - const central = new CentralCore(store.getFusionDir()); + const central = new CentralCore(store.getGlobalSettingsDir()); try { await central.init(); @@ -187,7 +187,7 @@ export function registerProxyRoutes(router: Router, deps: ProxyRoutesDeps): void const nodeId = req.params.nodeId as string; const { CentralCore } = await import("@fusion/core"); - const central = new CentralCore(store.getFusionDir()); + const central = new CentralCore(store.getGlobalSettingsDir()); try { await central.init(); @@ -360,7 +360,7 @@ export function registerProxyRoutes(router: Router, deps: ProxyRoutesDeps): void const remainingPath = Array.isArray(splat) ? splat.join("/") : splat; const { CentralCore } = await import("@fusion/core"); - const central = new CentralCore(store.getFusionDir()); + const central = new CentralCore(store.getGlobalSettingsDir()); try { await central.init(); diff --git a/packages/dashboard/src/routes/register-secrets-sync-inbound-routes.ts b/packages/dashboard/src/routes/register-secrets-sync-inbound-routes.ts index e3716146cc..ea8c69c0e8 100644 --- a/packages/dashboard/src/routes/register-secrets-sync-inbound-routes.ts +++ b/packages/dashboard/src/routes/register-secrets-sync-inbound-routes.ts @@ -92,7 +92,7 @@ export const registerSecretsSyncInboundRoutes: ApiRouteRegistrar = (ctx) => { router.post("/secrets/sync-receive", async (req, res) => { try { const { CentralCore } = await import("@fusion/core"); - const central = new CentralCore(store.getFusionDir()); + const central = new CentralCore(store.getGlobalSettingsDir()); await central.init(); try { // Validate auth @@ -174,7 +174,7 @@ export const registerSecretsSyncInboundRoutes: ApiRouteRegistrar = (ctx) => { router.get("/secrets/sync-export", async (req, res) => { try { const { CentralCore } = await import("@fusion/core"); - const central = new CentralCore(store.getFusionDir()); + const central = new CentralCore(store.getGlobalSettingsDir()); await central.init(); try { // Validate auth diff --git a/packages/dashboard/src/routes/register-secrets-sync-routes.ts b/packages/dashboard/src/routes/register-secrets-sync-routes.ts index 9d9c9ec0cd..d2ed109f8b 100644 --- a/packages/dashboard/src/routes/register-secrets-sync-routes.ts +++ b/packages/dashboard/src/routes/register-secrets-sync-routes.ts @@ -52,7 +52,7 @@ export const registerSecretsSyncRoutes: ApiRouteRegistrar = (ctx) => { router.post("/nodes/:id/secrets/push", async (req, res) => { try { const { CentralCore } = await import("@fusion/core"); - const central = new CentralCore(store.getFusionDir()); + const central = new CentralCore(store.getGlobalSettingsDir()); await central.init(); try { const node = await central.getNode(req.params.id); @@ -110,7 +110,7 @@ export const registerSecretsSyncRoutes: ApiRouteRegistrar = (ctx) => { router.post("/nodes/:id/secrets/pull", async (req, res) => { try { const { CentralCore } = await import("@fusion/core"); - const central = new CentralCore(store.getFusionDir()); + const central = new CentralCore(store.getGlobalSettingsDir()); await central.init(); try { const node = await central.getNode(req.params.id); diff --git a/packages/dashboard/src/routes/register-settings-sync-inbound-routes.ts b/packages/dashboard/src/routes/register-settings-sync-inbound-routes.ts index 57a287dc1d..15a021ed4e 100644 --- a/packages/dashboard/src/routes/register-settings-sync-inbound-routes.ts +++ b/packages/dashboard/src/routes/register-settings-sync-inbound-routes.ts @@ -74,7 +74,8 @@ export const registerSettingsSyncInboundRoutes: ApiRouteRegistrar = (ctx) => { router.post("/settings/sync-receive", async (req, res) => { try { const { CentralCore } = await import("@fusion/core"); - const central = new CentralCore(store.getFusionDir()); + // FNXC:GlobalDirGuard 2026-06-25-22:20: Inbound settings sync writes GLOBAL central state, so it must use the resolved global dir (~/.fusion). Previously this (and the secrets/proxy/node routes) passed store.getFusionDir() — the project `.fusion/` — which created a stray per-project central DB seeded with default global settings, the root cause of intermittent "all my global settings reset". Mirror this requirement on every CentralCore construction in dashboard routes. + const central = new CentralCore(store.getGlobalSettingsDir()); await central.init(); // Validate auth - find local node and check apiKey @@ -181,7 +182,7 @@ export const registerSettingsSyncInboundRoutes: ApiRouteRegistrar = (ctx) => { router.post("/settings/auth-receive", async (req, res) => { try { const { CentralCore } = await import("@fusion/core"); - const central = new CentralCore(store.getFusionDir()); + const central = new CentralCore(store.getGlobalSettingsDir()); await central.init(); // Validate auth @@ -278,7 +279,7 @@ export const registerSettingsSyncInboundRoutes: ApiRouteRegistrar = (ctx) => { router.get("/settings/auth-export", async (req, res) => { try { const { CentralCore } = await import("@fusion/core"); - const central = new CentralCore(store.getFusionDir()); + const central = new CentralCore(store.getGlobalSettingsDir()); await central.init(); // Validate auth From 207d2b899a28aa8ffd9c1034c59c87f0805fb56d Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Thu, 25 Jun 2026 22:08:41 -0700 Subject: [PATCH 14/50] test: remove real-time waits from slow test files (FN-5048) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace real wall-clock waits and per-test rebuilds in the slowest test files with deterministic seams. No assertions weakened, no timeouts widened, no retries added — anti-pattern removal only. - insights-routes.test.ts: boot the server + store ONCE in beforeAll (was a full createServer + TaskStore.init per test x24), reset insight tables per test for isolation, drive the sweeper via fake timers. Test-execution time ~3.7s -> ~0.8s. - db.test.ts: convert the fixed 150ms write-lock hold to manual stdin signal- release; keeps the real OS-lock contention under test, removes 2x150ms dead wait. Fixed a real EPIPE on redundant release. 152 pass, non-flaky over 8 runs. - mission-store.test.ts / agent-store.test.ts: replace real setTimeout sleeps used only to force distinct timestamps with a controlled clock (vi.setSystemTime / injected renewedAt). agent-store assertions strengthened to pin exact values. - in-process-runtime.test.ts: fake the one real 25ms sleep, drop its inflated 30s per-test timeout. Honest note: the timestamp-sleep removals are small absolute wins (the headline per-file durations were full-suite shard contention, not in-file dead time) but eliminate the FN-5048 real-wait anti-pattern. workflow-routes.test.ts was evaluated for splitting and deliberately NOT split — measured A/B showed the split regressed wall-clock (the file is import/transform-bound, already amortized by installInMemoryDbSnapshot), so splitting only multiplies fixed import cost. Verified: core 612/612, dashboard 24/24, engine 78/78; typecheck + eslint clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../core/src/__tests__/agent-store.test.ts | 21 ++++- packages/core/src/__tests__/db.test.ts | 81 +++++++++++++---- .../core/src/__tests__/mission-store.test.ts | 89 ++++++++++++------- .../src/__tests__/insights-routes.test.ts | 66 +++++++++++--- .../__tests__/in-process-runtime.test.ts | 57 +++++++----- 5 files changed, 231 insertions(+), 83 deletions(-) diff --git a/packages/core/src/__tests__/agent-store.test.ts b/packages/core/src/__tests__/agent-store.test.ts index a4b08795d6..05b5e0572a 100644 --- a/packages/core/src/__tests__/agent-store.test.ts +++ b/packages/core/src/__tests__/agent-store.test.ts @@ -1897,12 +1897,27 @@ describe("AgentStore", () => { }); it("checkoutTask is idempotent for same agent/node/epoch and renews lease timestamp", async () => { - const first = await store.checkoutTask(holderId, taskId, { nodeId: "node-a", runId: "run-1", leaseEpoch: 0 }); - await new Promise((resolve) => setTimeout(resolve, 5)); + /* + FNXC:CheckoutLeasing 2026-06-25-21:49: + Lease-renewal ordering is asserted via the store's injectable `renewedAt` clock seam + (CheckoutClaimContext.renewedAt → AgentStore.checkoutTask), not a real setTimeout sleep. + Previously a real 5ms wait forced a distinct heartbeat timestamp between the two checkouts; + that wasted wall-clock time and added flake surface (FN-5048: do not add slow tests). + Two explicit, ordered ISO timestamps make the renewal assertion deterministic with zero waiting. + */ + const firstRenewedAt = "2026-01-01T00:00:00.000Z"; + const secondRenewedAt = "2026-01-01T00:00:00.005Z"; + const first = await store.checkoutTask(holderId, taskId, { + nodeId: "node-a", + runId: "run-1", + leaseEpoch: 0, + renewedAt: firstRenewedAt, + }); const second = await store.checkoutTask(holderId, taskId, { nodeId: "node-a", runId: "run-2", leaseEpoch: first.checkoutLeaseEpoch ?? 0, + renewedAt: secondRenewedAt, }); expect(second.checkedOutBy).toBe(holderId); @@ -1910,6 +1925,8 @@ describe("AgentStore", () => { expect(second.checkoutNodeId).toBe("node-a"); expect(second.checkoutRunId).toBe("run-2"); expect(second.checkoutLeaseEpoch).toBe(first.checkoutLeaseEpoch); + expect(first.checkoutLeaseRenewedAt).toBe(firstRenewedAt); + expect(second.checkoutLeaseRenewedAt).toBe(secondRenewedAt); expect(second.checkoutLeaseRenewedAt).not.toBe(first.checkoutLeaseRenewedAt); }); diff --git a/packages/core/src/__tests__/db.test.ts b/packages/core/src/__tests__/db.test.ts index fa7ed0da74..ff22b388ac 100644 --- a/packages/core/src/__tests__/db.test.ts +++ b/packages/core/src/__tests__/db.test.ts @@ -120,15 +120,39 @@ afterAll(() => { cleanupTmpDirsSync(); }); +/* +FNXC:CoreDB-LockTest 2026-06-25-21:55: +The write-lock contention helper spawns a real child process that takes a real +SQLite EXCLUSIVE/RESERVED lock — that real OS lock IS the thing under test, so it +must NOT be mocked. The child releases the lock ONLY on an explicit `RELEASE` +stdin message (signal release); there is no fixed wall-clock hold. + +History: a `releaseMode: "timer"` variant fired `setTimeout(release, holdMs)` in +the child to drop the lock after a FIXED real duration (150ms per test). Two +recovery tests used it to release the lock mid-retry, paying ~150ms of dead +wall-clock wait each. That timer was removed: the recovery path retries via +synchronous `sleepSync` (Atomics.wait) on the main thread, so the test cannot +release the lock from its own event loop while blocked. Instead the test sends +`signalRelease()` (a bare stdin write, no await) in the SAME synchronous tick +immediately before `transactionImmediate(...)`. The parent reaches its first +`BEGIN IMMEDIATE` before the child can schedule + read the pipe + COMMIT (a +cross-process IPC+WAL round trip), so attempt 0 deterministically contends with +the still-held lock; the child then commits during the parent's first +`sleepSync` window and the retry recovers. Lock held only as long as needed, +released deterministically, zero fixed sleeps. +*/ async function holdWriteLock( dbPath: string, - options?: { holdMs?: number; releaseMode?: "manual" | "timer" }, + options?: { releaseMode?: "manual" }, ): Promise<{ child: ChildProcessWithoutNullStreams; + // Fire-and-forget: tell the child to drop the lock WITHOUT awaiting its exit. + // Used to release mid-`transactionImmediate` retry, where the main thread is + // synchronously blocked in `sleepSync` and cannot await the child's exit. + signalRelease: () => void; release: () => Promise; }> { - const releaseMode = options?.releaseMode ?? "manual"; - const holdMs = options?.holdMs ?? 0; + void options; const script = ` const { DatabaseSync } = require("node:sqlite"); const db = new DatabaseSync(${JSON.stringify(dbPath)}); @@ -141,14 +165,10 @@ async function holdWriteLock( try { db.close(); } catch {} process.exit(0); }; - if (${JSON.stringify(releaseMode)} === "timer") { - setTimeout(release, ${holdMs}); - } else { - process.stdin.setEncoding("utf8"); - process.stdin.on("data", (chunk) => { - if (chunk.includes("RELEASE")) release(); - }); - } + process.stdin.setEncoding("utf8"); + process.stdin.on("data", (chunk) => { + if (chunk.includes("RELEASE")) release(); + }); `; const child = spawn(process.execPath, ["-e", script], { @@ -158,6 +178,14 @@ async function holdWriteLock( child.once("exit", () => { activeLockChildren.delete(child); }); + // FNXC:CoreDB-LockTest 2026-06-25-21:55: A RELEASE write inherently races the + // child's exit — once the child reads RELEASE it COMMITs and exits, closing its + // stdin, so a write that lands just after exit hits a closed pipe (EPIPE). + // That EPIPE is benign: it only means the lock was already released, which is + // the success condition. Swallow it so it never surfaces as an uncaught + // exception. This does NOT weaken the lock test — assertions run before any + // release and are untouched. + child.stdin.on("error", () => {}); const ready = new Promise((resolve, reject) => { let stderr = ""; @@ -179,17 +207,28 @@ async function holdWriteLock( await ready; + // Track whether RELEASE was already sent so `release()` (the cleanup path) + // does not redundantly re-write to a child that `signalRelease()` already told + // to exit — the redundant write is the EPIPE source removed above. + let released = false; + return { child, + signalRelease: () => { + if (released || child.exitCode !== null || child.killed) { + return; + } + released = true; + child.stdin.write("RELEASE\n"); + }, release: async () => { if (child.exitCode !== null || child.killed) { return; } - if (releaseMode === "timer") { - await once(child, "exit"); - return; + if (!released) { + released = true; + child.stdin.write("RELEASE\n"); } - child.stdin.write("RELEASE\n"); await once(child, "exit"); }, }; @@ -1012,10 +1051,14 @@ describe("Database", () => { it("recovers outermost immediate transactions after a transient writer lock", async () => { const dbPath = db.getPath(); db.exec("PRAGMA busy_timeout = 0"); - const lock = await holdWriteLock(dbPath, { releaseMode: "timer", holdMs: 150 }); + const lock = await holdWriteLock(dbPath, { releaseMode: "manual" }); let callbackCalls = 0; try { + // FNXC:CoreDB-LockTest 2026-06-25-21:55: signal release in the SAME tick as + // transactionImmediate so attempt 0 contends with the still-held lock and the + // child commits during the first sleepSync retry window (no fixed wall-clock hold). + lock.signalRelease(); db.transactionImmediate(() => { callbackCalls += 1; db.prepare( @@ -1036,10 +1079,14 @@ describe("Database", () => { it("preserves nested savepoint rollback semantics after recovering the outer immediate writer lock", async () => { const dbPath = db.getPath(); db.exec("PRAGMA busy_timeout = 0"); - const lock = await holdWriteLock(dbPath, { releaseMode: "timer", holdMs: 150 }); + const lock = await holdWriteLock(dbPath, { releaseMode: "manual" }); let callbackCalls = 0; try { + // FNXC:CoreDB-LockTest 2026-06-25-21:55: same signal-release-then-recover pattern as + // the recovery test above; verifies nested savepoint rollback survives the outer + // immediate-lock recovery without paying a fixed 150ms hold. + lock.signalRelease(); db.transactionImmediate(() => { callbackCalls += 1; db.prepare( diff --git a/packages/core/src/__tests__/mission-store.test.ts b/packages/core/src/__tests__/mission-store.test.ts index c74e57e1ac..0c4cdfd823 100644 --- a/packages/core/src/__tests__/mission-store.test.ts +++ b/packages/core/src/__tests__/mission-store.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach, afterEach, beforeAll, afterAll } from "vitest"; +import { describe, it, expect, beforeEach, afterEach, beforeAll, afterAll, vi } from "vitest"; import { MissionStore, deriveMilestoneAcceptanceCriteriaFromFeatures } from "../mission-store.js"; import { installInMemoryDbSnapshot, clearInMemoryDbSnapshot } from "./store-test-helpers.js"; import { GoalStore } from "../goal-store.js"; @@ -134,19 +134,33 @@ describe("MissionStore", () => { expect(result).toBeUndefined(); }); - it("lists missions ordered by createdAt desc", async () => { - const m1 = store.createMission({ title: "Mission 1" }); - await new Promise((r) => setTimeout(r, 10)); // Ensure different timestamps - const m2 = store.createMission({ title: "Mission 2" }); - await new Promise((r) => setTimeout(r, 10)); - const m3 = store.createMission({ title: "Mission 3" }); + // FNXC:CoreTests 2026-06-25-21:50: MissionStore stamps createdAt/updatedAt + // via new Date().toISOString() with no injectable clock seam, and ordering + // queries (ORDER BY createdAt DESC) have no tiebreak. Tests previously slept + // real wall-clock (setTimeout 5-10ms) just to force distinct timestamps — + // pure dead time (FN-5048). Drive the system clock with fake timers + + // setSystemTime instead: zero real waiting, deterministic ordering. Scoped + // per-test (useRealTimers in finally) so the file's real-async paths and the + // async afterEach db.close() keep real timers. + it("lists missions ordered by createdAt desc", () => { + vi.useFakeTimers(); + try { + vi.setSystemTime(new Date("2026-06-25T00:00:00.000Z")); + const m1 = store.createMission({ title: "Mission 1" }); + vi.setSystemTime(new Date("2026-06-25T00:00:00.010Z")); + const m2 = store.createMission({ title: "Mission 2" }); + vi.setSystemTime(new Date("2026-06-25T00:00:00.020Z")); + const m3 = store.createMission({ title: "Mission 3" }); - const list = store.listMissions(); + const list = store.listMissions(); - expect(list).toHaveLength(3); - expect(list[0].id).toBe(m3.id); // Newest first - expect(list[1].id).toBe(m2.id); - expect(list[2].id).toBe(m1.id); + expect(list).toHaveLength(3); + expect(list[0].id).toBe(m3.id); // Newest first + expect(list[1].id).toBe(m2.id); + expect(list[2].id).toBe(m1.id); + } finally { + vi.useRealTimers(); + } }); it("round-trips mission branchStrategy on create", () => { @@ -178,21 +192,29 @@ describe("MissionStore", () => { expect(store.getMission(mission.id)?.branchStrategy).toBeUndefined(); }); - it("updates a mission", async () => { - const mission = store.createMission({ title: "Original" }); - await new Promise((r) => setTimeout(r, 5)); // Ensure timestamp difference - const updated = store.updateMission(mission.id, { - title: "Updated", - status: "active", - }); + // FNXC:CoreTests 2026-06-25-21:50: real-sleep removed (FN-5048); advance the + // fake clock between create and update so updatedAt > createdAt deterministically. + it("updates a mission", () => { + vi.useFakeTimers(); + try { + vi.setSystemTime(new Date("2026-06-25T00:00:00.000Z")); + const mission = store.createMission({ title: "Original" }); + vi.setSystemTime(new Date("2026-06-25T00:00:00.005Z")); + const updated = store.updateMission(mission.id, { + title: "Updated", + status: "active", + }); - expect(updated.title).toBe("Updated"); - expect(updated.status).toBe("active"); - expect(updated.id).toBe(mission.id); - expect(updated.createdAt).toBe(mission.createdAt); - expect(new Date(updated.updatedAt).getTime()).toBeGreaterThan( - new Date(mission.updatedAt).getTime() - ); + expect(updated.title).toBe("Updated"); + expect(updated.status).toBe("active"); + expect(updated.id).toBe(mission.id); + expect(updated.createdAt).toBe(mission.createdAt); + expect(new Date(updated.updatedAt).getTime()).toBeGreaterThan( + new Date(mission.updatedAt).getTime() + ); + } finally { + vi.useRealTimers(); + } }); it("throws when updating non-existent mission", () => { @@ -540,7 +562,12 @@ describe("MissionStore", () => { }); }); - it("computes correct health for multiple missions with varying states", async () => { + // FNXC:CoreTests 2026-06-25-21:50: real-sleep removed (FN-5048); fake clock + // advanced between the two missions to keep their createdAt distinct/ordered. + it("computes correct health for multiple missions with varying states", () => { + vi.useFakeTimers(); + try { + vi.setSystemTime(new Date("2026-06-25T00:00:00.000Z")); // Mission 1: 1 milestone (active), 1 slice (active), 4 features (1 done, 2 in-flight, 1 failed) const m1 = store.createMission({ title: "Mission 1" }); store.updateMission(m1.id, { status: "active" }); @@ -562,7 +589,7 @@ describe("MissionStore", () => { const f1Failed = store.addFeature(sl1.id, { title: "F1-failed" }); store.linkFeatureToTask(f1Failed.id, "FN-FAILED-1"); - await new Promise((r) => setTimeout(r, 10)); + vi.setSystemTime(new Date("2026-06-25T00:00:00.010Z")); // Mission 2: 2 milestones (1 complete, 1 active), 0 features const m2 = store.createMission({ title: "Mission 2" }); @@ -617,6 +644,9 @@ describe("MissionStore", () => { autopilotEnabled: false, lastActivityAt: undefined, }); + } finally { + vi.useRealTimers(); + } }); it("counts failed tasks across missions correctly", () => { @@ -4520,6 +4550,3 @@ describe("MissionStore", () => { }); }); }); - -// vi import for vitest mocking -import { vi } from "vitest"; diff --git a/packages/dashboard/src/__tests__/insights-routes.test.ts b/packages/dashboard/src/__tests__/insights-routes.test.ts index ed9f38d16f..eb7d33e3a6 100644 --- a/packages/dashboard/src/__tests__/insights-routes.test.ts +++ b/packages/dashboard/src/__tests__/insights-routes.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { describe, it, expect, vi, beforeAll, beforeEach, afterEach, afterAll } from "vitest"; import express from "express"; import { mkdtempSync } from "node:fs"; import { rm } from "node:fs/promises"; @@ -72,6 +72,20 @@ vi.mock("../project-store-resolver.js", async () => { /* FNXC:DashboardTests 2026-06-14-09:58: FN-6444 rescues this route/API suite from the curated skip-list; awaited store closure and retrying temp cleanup prevent singleton/resource leakage from turning backfill coverage into a flaky orphan. + +FNXC:DashboardTests 2026-06-25-10:30 (FN-5048 — slowest dashboard file): +This suite previously paid a full TaskStore.init()/migrate + createServer() (the entire +2.4k-line Express app wiring) on EVERY test via beforeEach, and recreated a temp dir per +test with retry-prone cleanup — ~26.5s under full-suite pressure. The HTTP layer here is +synthetic (test-request.js calls app(req,res) directly; no real port), so the per-test +server boot bought nothing but cost. +Harness seam: boot storeA + the createServer() app ONCE in beforeAll, reuse across all +tests, tear down once in afterAll. Isolation is preserved by truncating the three insight +tables between tests (resetInsightTables) instead of rebuilding the store — assertions are +untouched, order-independence is real, not papered over. +Timer seam: any interval/sweep-driven path is driven with FAKE timers + advanceTimersByTimeAsync +(see "runs periodic sweep" below) so we never wait the real 5-minute DEFAULT_SWEEP_INTERVAL_MS; +afterEach restores real timers so non-timer tests are unaffected. */ describe("Insights routes", () => { let rootA: string; @@ -82,9 +96,9 @@ describe("Insights routes", () => { /* FNXC:DashboardTests 2026-06-25-09:55: Only the projectId-scoped resolution test touches the project-b store. Lazily - init storeB on first use instead of in beforeEach so the other 23 tests skip a - second full TaskStore.init()/migrate per test (FN-5048: avoid redundant per-test - setup, prefer narrow seams). + init storeB on first use instead of up front so the other 23 tests skip a + second full TaskStore.init()/migrate (FN-5048: avoid redundant setup, prefer + narrow seams). Created once for the suite; tables are truncated between tests. */ let rootB: string | null = null; let storeB: TaskStore | null = null; @@ -113,16 +127,26 @@ describe("Insights routes", () => { return app; } - beforeEach(async () => { - vi.clearAllMocks(); + /* + FNXC:DashboardTests 2026-06-25-10:30: + State-isolation seam for the shared beforeAll store. Truncates the three insight tables + (events first to satisfy the run FK) so each test sees a clean slate without paying a + fresh TaskStore.init(). This is the correctness contract that lets the server be booted once. + */ + function resetInsightTables(store: TaskStore) { + const db = store.getDatabase(); + db.prepare("DELETE FROM project_insight_run_events").run(); + db.prepare("DELETE FROM project_insight_runs").run(); + db.prepare("DELETE FROM project_insights").run(); + } + beforeAll(async () => { rootA = mkdtempSync(join(tmpdir(), "kb-insights-routes-a-")); - rootB = null; - storeB = null; - storeA = new TaskStoreClass(rootA, join(rootA, ".fusion-global-settings"), { inMemoryDb: true }); await storeA.init(); + // Resolver impl survives vi.clearAllMocks() (which only clears call history), so set + // it once. storeB is lazily created on first project-b request. resolverMocks.getOrCreateProjectStore.mockImplementation(async (projectId: string) => { if (projectId === "project-b") { return getStoreB(); @@ -131,7 +155,19 @@ describe("Insights routes", () => { }); app = createServer(storeA); + }); + beforeEach(() => { + vi.clearAllMocks(); + + // Reset shared store state between tests for order-independence. + resetInsightTables(storeA); + if (storeB) { + resetInsightTables(storeB); + } + + // Re-establish default mock behavior each test (clearAllMocks keeps impls, but + // individual tests override these — e.g. mockRejectedValue — so re-set the baseline). readWorkingMemorySpy.mockResolvedValue("memory notes"); readInsightsMemorySpy.mockResolvedValue(null); writeInsightsMemorySpy.mockResolvedValue(undefined); @@ -151,11 +187,14 @@ describe("Insights routes", () => { piMocks.promptWithFallback.mockResolvedValue(undefined); }); - afterEach(async () => { + afterEach(() => { vi.useRealTimers(); while (disposableRouters.length > 0) { disposableRouters.pop()?.__disposeSweeper?.(); } + }); + + afterAll(async () => { try { await storeA.close(); } catch { @@ -299,6 +338,9 @@ describe("Insights routes", () => { }); it("runs periodic sweep and recover later stale rows", async () => { + // FNXC:DashboardTests 2026-06-25-10:30 (FN-5048): drive the 5-minute sweep interval with + // fake timers + advanceTimersByTimeAsync so the periodic recovery is observed instantly + // rather than waiting real time. vi.useFakeTimers(); const insightsApp = createInsightsOnlyApp(storeA); @@ -308,7 +350,7 @@ describe("Insights routes", () => { first.id, ); - vi.advanceTimersByTime(DEFAULT_SWEEP_INTERVAL_MS + 100); + await vi.advanceTimersByTimeAsync(DEFAULT_SWEEP_INTERVAL_MS + 100); expect(storeA.getInsightStore().getRun(first.id)?.status).toBe("failed"); @@ -318,7 +360,7 @@ describe("Insights routes", () => { second.id, ); - vi.advanceTimersByTime(DEFAULT_SWEEP_INTERVAL_MS + 100); + await vi.advanceTimersByTimeAsync(DEFAULT_SWEEP_INTERVAL_MS + 100); expect(storeA.getInsightStore().getRun(second.id)?.status).toBe("failed"); const events = storeA.getInsightStore().listRunEvents(second.id); diff --git a/packages/engine/src/runtimes/__tests__/in-process-runtime.test.ts b/packages/engine/src/runtimes/__tests__/in-process-runtime.test.ts index f4c3aaf39a..950ae8b189 100644 --- a/packages/engine/src/runtimes/__tests__/in-process-runtime.test.ts +++ b/packages/engine/src/runtimes/__tests__/in-process-runtime.test.ts @@ -933,32 +933,47 @@ describe("InProcessRuntime", () => { }, 30000); it("does not wake executeHeartbeat for runtime ownership sync of durable assigned agents", async () => { - await runtime.start(); + /* + FNXC:TestInfrastructure 2026-06-26-21:52: + This negative-assertion test must verify executeHeartbeat is NOT woken by runtime ownership sync. + Previously it paid a real `await new Promise(r => setTimeout(r, 25))` wall-clock sleep to let any + erroneously-scheduled executeHeartbeat fire before asserting it did not — pure dead time on every run. + Per FN-5048 (prefer fake timers over real polling/time waits) we run under fake timers and advance the + window deterministically with advanceTimersByTimeAsync. The inflated 30000ms per-test timeout is removed + now that no real wait remains. vi.waitFor already coexists with fake timers elsewhere in this suite. + */ + vi.useFakeTimers(); + try { + await runtime.start(); - const monitor = runtime.getHeartbeatMonitor(); - expect(monitor).toBeDefined(); - const heartbeatMonitor = monitor!; - const executeResult = { id: "run-task-worker" } as Awaited>; - const executeSpy = vi - .spyOn(heartbeatMonitor, "executeHeartbeat") - .mockResolvedValue(executeResult); + const monitor = runtime.getHeartbeatMonitor(); + expect(monitor).toBeDefined(); + const heartbeatMonitor = monitor!; + const executeResult = { id: "run-task-worker" } as Awaited>; + const executeSpy = vi + .spyOn(heartbeatMonitor, "executeHeartbeat") + .mockResolvedValue(executeResult); - const store = getAgentStore(runtime); - const durable = await store.createAgent({ name: "Owned Exec", role: "executor" }); + const store = getAgentStore(runtime); + const durable = await store.createAgent({ name: "Owned Exec", role: "executor" }); - const executorOptions = mockExecutorCtor.mock.calls.at(-1)?.[0] as { - onStart?: (task: Task, worktreePath: string) => void; - }; - executorOptions.onStart?.({ id: "FN-2001", assignedAgentId: durable.id } as Task, join(testDir, "worktree-FN-2001")); + const executorOptions = mockExecutorCtor.mock.calls.at(-1)?.[0] as { + onStart?: (task: Task, worktreePath: string) => void; + }; + executorOptions.onStart?.({ id: "FN-2001", assignedAgentId: durable.id } as Task, join(testDir, "worktree-FN-2001")); - await vi.waitFor(async () => { - const updated = await store.getAgent(durable.id); - expect(updated?.taskId).toBe("FN-2001"); - }); + await vi.waitFor(async () => { + const updated = await store.getAgent(durable.id); + expect(updated?.taskId).toBe("FN-2001"); + }); - await new Promise((resolve) => setTimeout(resolve, 25)); - expect(executeSpy).not.toHaveBeenCalled(); - }, 30000); + // Drive the negative-assertion window deterministically instead of sleeping 25ms of real time. + await vi.advanceTimersByTimeAsync(25); + expect(executeSpy).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); it("cleans up durable execution owner on completion without deleting agent", async () => { vi.useFakeTimers(); From 84ec10d1a7225c56e26b09b78add15ca65e10b34 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Thu, 25 Jun 2026 22:18:56 -0700 Subject: [PATCH 15/50] fix(review): harden global-dir fix per code review Addresses code-review findings on the global-settings reset fix: - getGlobalSettingsDir() now returns a resolved `string` (was `string | undefined`), so the project-local `.fusion` guard fires at the getter call site instead of leaking CentralCore's undefined-default semantics. - getSecretsStore() passes the resolved global dir to MasterKeyManager so the master key co-locates with the global central DB and the path is exercisable under tests (a bare new MasterKeyManager() throws in VITEST). - resolveGlobalDir() guard gains an explicit FUSION_ALLOW_PROJECT_LOCAL_GLOBAL_DIR opt-out so a legitimately version-controlled custom global dir (dotfiles repo with a .git parent) is not hard-rejected. - Add a symptom-based regression test (store-secrets-store-global-dir) proving the secrets central DB lands in the global dir and never spawns a stray project-local fusion-central.db. - Add getGlobalSettingsDir() to route-test mock stores (CentralCore is mocked, so it mirrors getFusionDir()) and FNXC comments to the remaining route sites. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../store-secrets-store-global-dir.test.ts | 47 +++++++++++++++++++ packages/core/src/global-settings.ts | 8 +++- packages/core/src/store.ts | 14 ++++-- .../__tests__/browse-directory-routes.test.ts | 5 ++ .../src/__tests__/proxy-routes.test.ts | 5 ++ .../routes-nodes-sync-contract.test.ts | 5 ++ .../src/__tests__/routes-nodes-sync.test.ts | 5 ++ .../src/__tests__/routes-proxy.test.ts | 5 ++ packages/dashboard/src/routes.ts | 1 + .../src/routes/register-proxy-routes.ts | 1 + .../register-secrets-sync-inbound-routes.ts | 1 + .../routes/register-secrets-sync-routes.ts | 1 + 12 files changed, 91 insertions(+), 7 deletions(-) create mode 100644 packages/core/src/__tests__/store-secrets-store-global-dir.test.ts diff --git a/packages/core/src/__tests__/store-secrets-store-global-dir.test.ts b/packages/core/src/__tests__/store-secrets-store-global-dir.test.ts new file mode 100644 index 0000000000..2f6cd1c4ab --- /dev/null +++ b/packages/core/src/__tests__/store-secrets-store-global-dir.test.ts @@ -0,0 +1,47 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { existsSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { TaskStore } from "../store.js"; + +/* +FNXC:GlobalDirGuard 2026-06-25-23:05: +Symptom-based regression for the "all my global settings reset" bug. The root cause was getSecretsStore() (and dashboard routes) constructing CentralCore with `store.getFusionDir()` (the project's `.fusion/`), which created a stray per-project `fusion-central.db` seeded with default global state that shadowed the real global DB. These tests assert the INVARIANT directly: the secrets store's central DB lands in the resolved GLOBAL dir and NOT inside the project `.fusion/` dir, and that getGlobalSettingsDir() is distinct from getFusionDir(). Surface enumeration: this covers the store/secrets surface; the resolveGlobalDir guard surfaces are covered in global-settings-guard.test.ts. +*/ +describe("TaskStore.getSecretsStore() central DB location (global, not project-local)", () => { + let root: string; + let globalDir: string; + let store: TaskStore; + + beforeEach(async () => { + root = mkdtempSync(join(tmpdir(), "fn-secrets-global-dir-")); + globalDir = join(root, ".fusion-global-settings"); + store = new TaskStore(root, globalDir, { inMemoryDb: true }); + await store.init(); + }); + + afterEach(() => { + rmSync(root, { recursive: true, force: true }); + }); + + it("resolves getGlobalSettingsDir() to the global dir, distinct from getFusionDir()", () => { + expect(store.getGlobalSettingsDir()).toBe(globalDir); + expect(store.getFusionDir()).toBe(join(root, ".fusion")); + expect(store.getGlobalSettingsDir()).not.toBe(store.getFusionDir()); + }); + + it("creates the secrets central DB in the global dir and never in the project .fusion/", async () => { + await store.getSecretsStore(); + + // The central DB must live in the resolved global dir... + expect(existsSync(join(globalDir, "fusion-central.db"))).toBe(true); + // ...and must NOT have spawned a stray per-project central DB (the original bug). + expect(existsSync(join(store.getFusionDir(), "fusion-central.db"))).toBe(false); + }); + + it("returns a stable singleton secrets store across calls", async () => { + const a = await store.getSecretsStore(); + const b = await store.getSecretsStore(); + expect(a).toBe(b); + }); +}); diff --git a/packages/core/src/global-settings.ts b/packages/core/src/global-settings.ts index 877408d7d4..e4187e09e1 100644 --- a/packages/core/src/global-settings.ts +++ b/packages/core/src/global-settings.ts @@ -95,8 +95,11 @@ export function resolveGlobalDir(dir?: string): string { FNXC:GlobalDirGuard 2026-06-25-22:10: Production code must never point the central/global store at a project's `.fusion/` directory. Doing so silently spins up a stray per-project central DB seeded with DEFAULT global settings (globalMaxConcurrent=4, empty global secrets, default centralSettings), which then shadows the real `~/.fusion/fusion-central.db` and manifests as "all my global settings reset". Root cause was call sites passing `store.getFusionDir()` instead of the resolved global dir. Guard heuristic: a project `.fusion` dir is named `.fusion` and lives inside a git repo (its parent has a `.git` dir or worktree file), whereas the home global dir's parent (the home dir) is not a repo. We only flag dirs that differ from the home-resolved global dir, so legitimately-threaded global dirs and test temp dirs are unaffected. Skipped under VITEST (tests pass explicit temp dirs by design). + + FNXC:GlobalDirGuard 2026-06-25-22:55: + The heuristic is intentionally conservative but can't perfectly distinguish a project `.fusion` from a legitimately version-controlled custom global dir (e.g. a dotfiles repo with `~/dotfiles/.fusion` + `.git`). To avoid hard-crashing that rare setup, honor an explicit opt-out env var `FUSION_ALLOW_PROJECT_LOCAL_GLOBAL_DIR=true`. This is not reachable via normal production call sites (they resolve to ~/.fusion); it only matters for operators who deliberately configure a custom global dir inside a repo. */ - if (process.env.VITEST !== "true") { + if (process.env.VITEST !== "true" && process.env.FUSION_ALLOW_PROJECT_LOCAL_GLOBAL_DIR !== "true") { const homeGlobalDir = resolveGlobalDirForHome(getHomeDir()); const looksLikeProjectFusionDir = dir !== homeGlobalDir && @@ -106,7 +109,8 @@ export function resolveGlobalDir(dir?: string): string { throw new Error( `resolveGlobalDir(): refusing project-local '.fusion' directory '${dir}' for the central/global store. ` + "This would create a stray per-project central database seeded with default global settings and silently reset them. " + - "Pass the resolved global dir (or omit the argument so it defaults to ~/.fusion); see TaskStore.getGlobalSettingsDir().", + "Pass the resolved global dir (or omit the argument so it defaults to ~/.fusion); see TaskStore.getGlobalSettingsDir(). " + + "If this really is your intended global dir (e.g. a version-controlled dotfiles repo), set FUSION_ALLOW_PROJECT_LOCAL_GLOBAL_DIR=true to override.", ); } } diff --git a/packages/core/src/store.ts b/packages/core/src/store.ts index 2cc55e1299..f23cc4dfa4 100644 --- a/packages/core/src/store.ts +++ b/packages/core/src/store.ts @@ -146,7 +146,7 @@ import { normalizeTaskPriority } from "./task-priority.js"; import { validateBranchGroupBranchName, filterTasksByBranchGroup } from "./branch-assignment.js"; import { allowsAutoMergeProcessing } from "./task-merge.js"; import { canAgentTakeImplementationTaskForExplicitRouting } from "./agent-role-policy.js"; -import { GlobalSettingsStore } from "./global-settings.js"; +import { GlobalSettingsStore, resolveGlobalDir } from "./global-settings.js"; import { Database, SCHEMA_VERSION, toJson, toJsonNullable, fromJson } from "./db.js"; import { ArchiveDatabase } from "./archive-db.js"; import { detectLegacyData, migrateFromLegacy } from "./db-migrate.js"; @@ -16653,10 +16653,13 @@ ${stepsSection}`; /* FNXC:GlobalDirGuard 2026-06-25-22:12: - The resolved GLOBAL settings dir (undefined → ~/.fusion). Distinct from getFusionDir() which is this project's `.fusion/`. Any CentralCore/global-store construction MUST use this, never getFusionDir(); passing the project dir spins up a stray per-project central DB that shadows ~/.fusion and silently resets global settings. + The resolved GLOBAL settings dir. Distinct from getFusionDir() which is this project's `.fusion/`. Any CentralCore/global-store construction MUST use this, never getFusionDir(); passing the project dir spins up a stray per-project central DB that shadows ~/.fusion and silently resets global settings. + + FNXC:GlobalDirGuard 2026-06-25-22:50: + Returns a fully-RESOLVED absolute path (string), not the raw optional field. Resolving here (rather than leaking CentralCore's `undefined → ~/.fusion` default to every caller) makes the contract honest and fires the project-local `.fusion` guard at this call site instead of deferring it to CentralCore construction. Under VITEST `this.globalSettingsDir` is always set to a temp dir, so resolveGlobalDir returns it verbatim and never throws the no-explicit-dir test error. */ - getGlobalSettingsDir(): string | undefined { - return this.globalSettingsDir; + getGlobalSettingsDir(): string { + return resolveGlobalDir(this.globalSettingsDir); } getTasksDir(): string { @@ -16689,7 +16692,8 @@ ${stepsSection}`; if (!centralDb) { throw new Error("Central database unavailable for secrets store"); } - const masterKeyManager = new MasterKeyManager(); + // FNXC:GlobalDirGuard 2026-06-25-23:00: The master key is GLOBAL — pass the resolved global dir explicitly so it co-locates with the global central DB (matching prod ~/.fusion) and so getSecretsStore() is exercisable under tests (a bare new MasterKeyManager() throws under VITEST because resolveGlobalDir() requires an explicit dir there). + const masterKeyManager = new MasterKeyManager({ globalDir: this.getGlobalSettingsDir() }); const masterKeyProvider = () => masterKeyManager.getOrCreateKey(); this.secretsStore = new SecretsStore(this.db, centralDb, masterKeyProvider); return this.secretsStore; diff --git a/packages/dashboard/src/__tests__/browse-directory-routes.test.ts b/packages/dashboard/src/__tests__/browse-directory-routes.test.ts index d74fcbe858..bc05399869 100644 --- a/packages/dashboard/src/__tests__/browse-directory-routes.test.ts +++ b/packages/dashboard/src/__tests__/browse-directory-routes.test.ts @@ -71,6 +71,11 @@ class MockStoreForRoutes extends EventEmitter { return "/tmp/fn-944/.fusion"; } + // FNXC:GlobalDirGuard 2026-06-25-23:10: Routes resolve the global central dir via getGlobalSettingsDir(); mock mirrors getFusionDir() (CentralCore is mocked) so route behavior matches pre-change. + getGlobalSettingsDir(): string { + return this.getFusionDir(); + } + getDatabase() { return { exec: vi.fn(), diff --git a/packages/dashboard/src/__tests__/proxy-routes.test.ts b/packages/dashboard/src/__tests__/proxy-routes.test.ts index d1f9230a00..007f9b1cbf 100644 --- a/packages/dashboard/src/__tests__/proxy-routes.test.ts +++ b/packages/dashboard/src/__tests__/proxy-routes.test.ts @@ -47,6 +47,11 @@ class MockStore extends EventEmitter { return "/tmp/fn-test/.fusion"; } + // FNXC:GlobalDirGuard 2026-06-25-23:10: Routes resolve the global central dir via getGlobalSettingsDir(); mock mirrors getFusionDir() (CentralCore is mocked) so route behavior matches pre-change. + getGlobalSettingsDir(): string { + return this.getFusionDir(); + } + getDatabase() { return { exec: vi.fn(), diff --git a/packages/dashboard/src/__tests__/routes-nodes-sync-contract.test.ts b/packages/dashboard/src/__tests__/routes-nodes-sync-contract.test.ts index a07a817e66..dd2236f683 100644 --- a/packages/dashboard/src/__tests__/routes-nodes-sync-contract.test.ts +++ b/packages/dashboard/src/__tests__/routes-nodes-sync-contract.test.ts @@ -88,6 +88,11 @@ class MockStore extends EventEmitter { return "/tmp/fn-4755-test/.fusion"; } + // FNXC:GlobalDirGuard 2026-06-25-23:10: Routes resolve the global central dir via getGlobalSettingsDir(); mock mirrors getFusionDir() (CentralCore is mocked) so route behavior matches pre-change. + getGlobalSettingsDir(): string { + return this.getFusionDir(); + } + getDatabase() { return { exec: vi.fn(), diff --git a/packages/dashboard/src/__tests__/routes-nodes-sync.test.ts b/packages/dashboard/src/__tests__/routes-nodes-sync.test.ts index d38186bf1d..46e11323f1 100644 --- a/packages/dashboard/src/__tests__/routes-nodes-sync.test.ts +++ b/packages/dashboard/src/__tests__/routes-nodes-sync.test.ts @@ -104,6 +104,11 @@ class MockStore extends EventEmitter { return "/tmp/fn-1821-test/.fusion"; } + // FNXC:GlobalDirGuard 2026-06-25-23:10: Routes resolve the global central dir via getGlobalSettingsDir(); mock mirrors getFusionDir() (CentralCore is mocked) so route behavior matches pre-change. + getGlobalSettingsDir(): string { + return this.getFusionDir(); + } + getDatabase() { return { exec: vi.fn(), diff --git a/packages/dashboard/src/__tests__/routes-proxy.test.ts b/packages/dashboard/src/__tests__/routes-proxy.test.ts index d54c6e28ef..7047d0512c 100644 --- a/packages/dashboard/src/__tests__/routes-proxy.test.ts +++ b/packages/dashboard/src/__tests__/routes-proxy.test.ts @@ -30,6 +30,11 @@ class MockStore extends EventEmitter { return "/tmp/fn-1806/.fusion"; } + // FNXC:GlobalDirGuard 2026-06-25-23:10: Routes resolve the global central dir via getGlobalSettingsDir(); mock mirrors getFusionDir() (CentralCore is mocked) so route behavior matches pre-change. + getGlobalSettingsDir(): string { + return this.getFusionDir(); + } + getDatabase() { return { exec: vi.fn(), diff --git a/packages/dashboard/src/routes.ts b/packages/dashboard/src/routes.ts index 9f84c03189..0c6c7a3b96 100644 --- a/packages/dashboard/src/routes.ts +++ b/packages/dashboard/src/routes.ts @@ -4463,6 +4463,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout // Node-aware proxying: route to remote node if nodeId is provided and not local if (nodeId) { const { CentralCore } = await import("@fusion/core"); + // FNXC:GlobalDirGuard 2026-06-25-22:40: Node-aware proxy lookup uses GLOBAL central state — use getGlobalSettingsDir(), never getFusionDir() (project .fusion/), which spawns a stray per-project central DB and resets global settings. const central = new CentralCore(store.getGlobalSettingsDir()); await central.init(); diff --git a/packages/dashboard/src/routes/register-proxy-routes.ts b/packages/dashboard/src/routes/register-proxy-routes.ts index 03a770ab87..3e1a7400eb 100644 --- a/packages/dashboard/src/routes/register-proxy-routes.ts +++ b/packages/dashboard/src/routes/register-proxy-routes.ts @@ -34,6 +34,7 @@ async function proxyToRemoteNode( const timeoutMs = proxyOptions?.timeoutMs ?? 10_000; const { CentralCore } = await import("@fusion/core"); + // FNXC:GlobalDirGuard 2026-06-25-22:40: Node proxy state is GLOBAL — use getGlobalSettingsDir(), never getFusionDir() (project .fusion/), which spawns a stray per-project central DB and resets global settings. See register-settings-sync-inbound-routes.ts for full rationale. const central = new CentralCore(store.getGlobalSettingsDir()); try { diff --git a/packages/dashboard/src/routes/register-secrets-sync-inbound-routes.ts b/packages/dashboard/src/routes/register-secrets-sync-inbound-routes.ts index ea8c69c0e8..930ab7d14f 100644 --- a/packages/dashboard/src/routes/register-secrets-sync-inbound-routes.ts +++ b/packages/dashboard/src/routes/register-secrets-sync-inbound-routes.ts @@ -92,6 +92,7 @@ export const registerSecretsSyncInboundRoutes: ApiRouteRegistrar = (ctx) => { router.post("/secrets/sync-receive", async (req, res) => { try { const { CentralCore } = await import("@fusion/core"); + // FNXC:GlobalDirGuard 2026-06-25-22:40: Inbound secrets sync writes GLOBAL central state — use getGlobalSettingsDir(), never getFusionDir() (project .fusion/), which spawns a stray per-project central DB and resets global settings. See register-settings-sync-inbound-routes.ts for full rationale. const central = new CentralCore(store.getGlobalSettingsDir()); await central.init(); try { diff --git a/packages/dashboard/src/routes/register-secrets-sync-routes.ts b/packages/dashboard/src/routes/register-secrets-sync-routes.ts index d2ed109f8b..dadb30299e 100644 --- a/packages/dashboard/src/routes/register-secrets-sync-routes.ts +++ b/packages/dashboard/src/routes/register-secrets-sync-routes.ts @@ -52,6 +52,7 @@ export const registerSecretsSyncRoutes: ApiRouteRegistrar = (ctx) => { router.post("/nodes/:id/secrets/push", async (req, res) => { try { const { CentralCore } = await import("@fusion/core"); + // FNXC:GlobalDirGuard 2026-06-25-22:40: Secrets-sync node state is GLOBAL — use getGlobalSettingsDir(), never getFusionDir() (project .fusion/), which spawns a stray per-project central DB and resets global settings. See register-settings-sync-inbound-routes.ts for full rationale. const central = new CentralCore(store.getGlobalSettingsDir()); await central.init(); try { From 1d860ec310a62c069f6b2389fe52a06e3422efa6 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Thu, 25 Jun 2026 22:19:24 -0700 Subject: [PATCH 16/50] feat: global concurrency slider (footer + dashboard) and scoped settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a "Global Max Concurrent" slider to the footer engine menu and the Command Center Concurrency card, and group the Scheduling settings by Global vs Project scope so the global cap isn't mistaken for a per-project setting (clearer on mobile). Both sliders are backed by a single shared `useGlobalConcurrency` hook (module-level store) so they read/write one source of truth and revalidate after every PUT /api/global-concurrency — fixing the last-writer-wins and stale-clobber races a per-component cache would cause. The hook treats a fetch error as non-interactive (slider disabled, not stuck at 1), surfaces a save-state indicator, and flushes a pending edit on menu close / unmount so a quick drag-then-dismiss is never silently dropped. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...l-concurrency-slider-and-scope-grouping.md | 7 + .../app/components/EngineControlMenu.css | 14 ++ .../app/components/EngineControlMenu.tsx | 45 +++++ .../app/components/SettingsModal.css | 49 +++++ .../command-center/CommandCenterControls.css | 12 ++ .../command-center/CommandCenterControls.tsx | 40 ++++ .../settings/sections/SchedulingSection.tsx | 22 +++ .../app/hooks/useGlobalConcurrency.ts | 182 ++++++++++++++++++ 8 files changed, 371 insertions(+) create mode 100644 .changeset/global-concurrency-slider-and-scope-grouping.md create mode 100644 packages/dashboard/app/hooks/useGlobalConcurrency.ts diff --git a/.changeset/global-concurrency-slider-and-scope-grouping.md b/.changeset/global-concurrency-slider-and-scope-grouping.md new file mode 100644 index 0000000000..4c02c88be9 --- /dev/null +++ b/.changeset/global-concurrency-slider-and-scope-grouping.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Adjust the global concurrency cap from the footer and dashboard; settings grouped by global vs project scope. +category: feature +dev: Added a Global Max Concurrent slider (wired to fetch/updateGlobalConcurrency) to EngineControlMenu (footer) and the dashboard CommandCenterControls Concurrency card, with debounced saves matching the existing project sliders. SchedulingSection now groups fields under labeled "Global — all projects" and "This project" subheadings with scope badges so the global cap is not mistaken for a per-project setting (clearer on mobile). diff --git a/packages/dashboard/app/components/EngineControlMenu.css b/packages/dashboard/app/components/EngineControlMenu.css index efe0ee9e0f..6738a0c859 100644 --- a/packages/dashboard/app/components/EngineControlMenu.css +++ b/packages/dashboard/app/components/EngineControlMenu.css @@ -71,6 +71,14 @@ font-weight: 500; } +/* FNXC:GlobalConcurrencyControls 2026-06-25-22:45: "All projects" scope caption sits between the global-cap title and its save-state indicator; muted so the save-state remains the emphasized signal. */ +.engine-control-menu__scope-caption { + margin-inline-start: auto; + color: var(--text-muted); + font-size: var(--font-size-xs); + font-weight: 500; +} + .engine-control-menu__save-state--saving { color: var(--color-warning); } @@ -84,6 +92,12 @@ color: var(--color-error); } +/* FNXC:GlobalConcurrencyControls 2026-06-25-14:10: The global cap is a cross-project setting; separate it visually from the per-project sliders below with a divider and an "All projects" caption. */ +.engine-control-menu__section--global { + padding-bottom: var(--space-sm); + border-bottom: 1px solid var(--border); +} + .engine-control-menu__slider { display: flex; flex-direction: column; diff --git a/packages/dashboard/app/components/EngineControlMenu.tsx b/packages/dashboard/app/components/EngineControlMenu.tsx index 250e343f8f..91574df108 100644 --- a/packages/dashboard/app/components/EngineControlMenu.tsx +++ b/packages/dashboard/app/components/EngineControlMenu.tsx @@ -5,6 +5,8 @@ import { DEFAULT_PROJECT_SETTINGS } from "@fusion/core"; import { Pause, Play, SlidersHorizontal, Square } from "lucide-react"; import { fetchConfig, fetchSettings, updateSettings } from "../api/legacy"; import { useAppSettings } from "../hooks/useAppSettings"; +// FNXC:GlobalConcurrencyControls 2026-06-25-22:45: Footer menu adopts the shared global-concurrency hook so it and the Command Center card read/write ONE source of truth (no more duplicated fetch/debounce/clobber logic). +import { useGlobalConcurrency } from "../hooks/useGlobalConcurrency"; export interface EngineControlMenuHandle { open: () => void; @@ -70,6 +72,8 @@ export const EngineControlMenu = forwardRef>({ status: "idle", data: null, error: null }); const [concurrencyDirty, setConcurrencyDirty] = useState(false); const [concurrencySaveState, setConcurrencySaveState] = useState<"idle" | "saving" | "saved" | "error">("idle"); + // FNXC:GlobalConcurrencyControls 2026-06-25-22:45: Fetch is gated on the menu being open; the hook flushes any pending debounced write when `open` flips false. + const gc = useGlobalConcurrency({ activeWhen: open }); const closeMenu = useCallback(() => setOpen(false), []); const openMenu = useCallback(() => setOpen(true), []); @@ -167,6 +171,16 @@ export const EngineControlMenu = forwardRef
+ {/* + FNXC:GlobalConcurrencyControls 2026-06-25-14:10: + Operators need to adjust the global cross-project concurrency cap from the footer engine menu and the dashboard Concurrency card, not just the Settings modal; global cap is distinct from per-project maxConcurrent and persists via the central /api/global-concurrency endpoint. + */} +
+
+ {t("settings.scheduling.globalMaxConcurrent", "Global Max Concurrent")} + {t("commandCenter.controls.scope.allProjects", "All projects")} + + {globalSaveLabel} + +
+ + {gc.status === "error" ?

{t("commandCenter.controls.concurrency.error", "Unable to load concurrency settings")}

: null} +
+
{t("commandCenter.controls.concurrency.title", "Concurrency")} diff --git a/packages/dashboard/app/components/SettingsModal.css b/packages/dashboard/app/components/SettingsModal.css index 8ab9bd73e1..5295ecd776 100644 --- a/packages/dashboard/app/components/SettingsModal.css +++ b/packages/dashboard/app/components/SettingsModal.css @@ -1778,6 +1778,55 @@ Settings section headings should preserve hierarchy through spacing and type onl color: var(--text-muted); } +/* +FNXC:SettingsScopeGrouping 2026-06-25-10:42: +Mobile settings must make clear which controls are global (all projects) vs project-scoped; group scheduling fields under labeled Global/This-project subheadings with a scope badge so operators don't mistake the global concurrency cap for a per-project setting. +The header row wraps so the badge drops below the heading on narrow widths instead of overflowing. +*/ +.settings-scope-group { + margin-top: var(--space-md); +} + +.settings-scope-group-header { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--space-sm); +} + +.settings-scope-group-header .settings-section-heading { + padding-bottom: 0; + margin-bottom: 0; +} + +.settings-scope-badge { + display: inline-flex; + align-items: center; + padding: 2px 8px; + border-radius: 10px; + font-size: 11px; + font-weight: 500; + white-space: nowrap; +} + +.settings-scope-badge--global { + background-color: color-mix(in srgb, var(--accent, #4a90e2) 18%, transparent); + color: var(--accent, #4a90e2); +} + +.settings-scope-badge--project { + background-color: color-mix(in srgb, var(--text-muted) 15%, transparent); + color: var(--text-muted); +} + +.settings-scope-caption { + display: block; + margin: var(--space-xs) 0 var(--space-md); + color: var(--text-muted); + font-size: 12px; + line-height: 1.4; +} + .settings-description { font-size: 13px; color: var(--text-dim); diff --git a/packages/dashboard/app/components/command-center/CommandCenterControls.css b/packages/dashboard/app/components/command-center/CommandCenterControls.css index d3e382a7a9..323b836712 100644 --- a/packages/dashboard/app/components/command-center/CommandCenterControls.css +++ b/packages/dashboard/app/components/command-center/CommandCenterControls.css @@ -90,6 +90,18 @@ font-size: 0.8125rem; } +/* FNXC:GlobalConcurrencyControls 2026-06-25-14:10: The global cap is a cross-project setting; span it full-width at the top of the Concurrency card and separate it from the per-project sliders with a divider plus an "Across all projects" caption. */ +.cc-controls-slider--global { + grid-column: 1 / -1; + padding-bottom: var(--space-md); + border-bottom: 1px solid var(--border); +} + +.cc-controls-slider-caption { + color: var(--text-muted); + font-size: 0.75rem; +} + .cc-controls-slider-label { display: flex; align-items: center; diff --git a/packages/dashboard/app/components/command-center/CommandCenterControls.tsx b/packages/dashboard/app/components/command-center/CommandCenterControls.tsx index ed750a6bf2..af4979e2c1 100644 --- a/packages/dashboard/app/components/command-center/CommandCenterControls.tsx +++ b/packages/dashboard/app/components/command-center/CommandCenterControls.tsx @@ -4,6 +4,8 @@ import { Power } from "lucide-react"; import { DEFAULT_PROJECT_SETTINGS, type ColorTheme, type ThemeMode } from "@fusion/core"; import { fetchConfig, fetchSettings, updateSettings } from "../../api/legacy"; import { useAppSettings } from "../../hooks/useAppSettings"; +// FNXC:GlobalConcurrencyControls 2026-06-25-22:45: Concurrency card adopts the shared global-concurrency hook so it and the footer EngineControlMenu read/write ONE source of truth (no more duplicated fetch/debounce/clobber logic). +import { useGlobalConcurrency } from "../../hooks/useGlobalConcurrency"; import { ThemeDropdown } from "../ThemeDropdown"; import type { TaskView } from "../../hooks/useViewState"; import "./CommandCenterControls.css"; @@ -79,6 +81,8 @@ export function CommandCenterControls({ projectId, colorTheme, themeMode, shadcn const [concurrencyState, setConcurrencyState] = useState>({ status: "loading", data: null, error: null }); const [concurrencyDirty, setConcurrencyDirty] = useState(false); const [concurrencySaveState, setConcurrencySaveState] = useState<"idle" | "saving" | "saved" | "error">("idle"); + // FNXC:GlobalConcurrencyControls 2026-06-25-22:45: No activeWhen — the card is mounted only while visible, so it fetches on mount and flushes pending writes on unmount via the shared hook. + const gc = useGlobalConcurrency(); useEffect(() => { let cancelled = false; @@ -145,6 +149,16 @@ export function CommandCenterControls({ projectId, colorTheme, themeMode, shadcn const effectiveGlobalPaused = globalPaused; const concurrencyValues = concurrencyState.data ?? DEFAULT_CONCURRENCY_VALUES; + // FNXC:GlobalConcurrencyControls 2026-06-25-22:45: Mirror the per-project slider save-state labels for the shared global cap. + const globalSaveLabel = gc.status === "loading" || gc.status === "idle" + ? t("commandCenter.controls.status.loading", "Loading…") + : gc.saveState === "saving" + ? t("commandCenter.controls.status.saving", "Saving…") + : gc.saveState === "saved" + ? t("commandCenter.controls.status.saved", "Saved") + : gc.saveState === "error" + ? t("commandCenter.controls.status.saveError", "Save failed") + : t("commandCenter.controls.status.ready", "Ready"); /* FNXC:CommandCenter 2026-06-20-00:20: @@ -239,6 +253,32 @@ export function CommandCenterControls({ projectId, colorTheme, themeMode, shadcn
+ {/* + FNXC:GlobalConcurrencyControls 2026-06-25-14:10: + Operators need to adjust the global cross-project concurrency cap from the footer engine menu and the dashboard Concurrency card, not just the Settings modal; global cap is distinct from per-project maxConcurrent and persists via the central /api/global-concurrency endpoint. + */} +