feat(FN-2289): merge fusion/fn-2289

This commit is contained in:
gsxdsm
2026-04-23 07:41:11 -07:00
parent 0a61d86358
commit a67966fd42
5 changed files with 514 additions and 12 deletions

View File

@@ -1157,17 +1157,19 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
{/* Thread */}
<div className="chat-thread">
{/* Header */}
<div className="chat-thread-header">
{isMobile && (
<button className="btn-icon" onClick={handleBack} data-testid="chat-back-btn">
<ChevronLeft size={16} />
</button>
)}
<Bot size={16} />
<span className="chat-thread-header-title">{threadHeaderTitle}</span>
{showThreadHeaderModelTag && <span className="chat-model-tag">{activeModelTag}</span>}
</div>
{/* Header - always rendered in desktop/tablet, only rendered in mobile when viewing a thread */}
{(activeSession || !isMobile) && (
<div className="chat-thread-header">
{isMobile && activeSession && (
<button className="btn-icon" onClick={handleBack} data-testid="chat-back-btn">
<ChevronLeft size={16} />
</button>
)}
<Bot size={16} />
<span className="chat-thread-header-title">{threadHeaderTitle}</span>
{showThreadHeaderModelTag && <span className="chat-model-tag">{activeModelTag}</span>}
</div>
)}
{/* Messages */}
<div className="chat-messages" ref={messagesContainerRef}>

View File

@@ -1744,6 +1744,149 @@ describe("ChatView sidebar structure", () => {
});
});
describe("ChatView mobile behavior", () => {
function ensureMatchMedia() {
if (!window.matchMedia) {
Object.defineProperty(window, "matchMedia", {
writable: true,
value: vi.fn(),
});
}
}
function mockMobileViewport() {
ensureMatchMedia();
Object.defineProperty(window, "innerWidth", { value: 375, configurable: true });
return vi.spyOn(window, "matchMedia").mockImplementation((query: string) => ({
matches: query === "(max-width: 768px)",
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
}));
}
function mockDesktopViewport() {
ensureMatchMedia();
Object.defineProperty(window, "innerWidth", { value: 1280, configurable: true });
return vi.spyOn(window, "matchMedia").mockImplementation((query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
}));
}
it("mobile mode: does not render thread header when no active session (list view)", () => {
const restoreMatchMedia = mockMobileViewport();
try {
setupMockChat({
sessions: [{ id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", updatedAt: "2026-04-08T00:00:00.000Z" }],
filteredSessions: [{ id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", updatedAt: "2026-04-08T00:00:00.000Z" }],
activeSession: null,
});
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
// Thread header should not be rendered when there's no active session
expect(document.querySelector(".chat-thread-header")).not.toBeInTheDocument();
// Back button should not be visible
expect(screen.queryByTestId("chat-back-btn")).not.toBeInTheDocument();
} finally {
restoreMatchMedia();
}
});
it("mobile mode: renders thread header with back button when session is active", () => {
const restoreMatchMedia = mockMobileViewport();
try {
setupMockChat({
activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", updatedAt: "2026-04-08T00:00:00.000Z" },
messages: [{ id: "msg-001", sessionId: "session-001", role: "assistant", content: "Hello", createdAt: "2026-04-08T00:00:00.000Z" }],
});
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
// Thread header should be rendered when there's an active session
expect(document.querySelector(".chat-thread-header")).toBeInTheDocument();
// Back button should be visible in mobile thread view
expect(screen.getByTestId("chat-back-btn")).toBeInTheDocument();
} finally {
restoreMatchMedia();
}
});
it("mobile mode: tapping back button calls selectSession with empty string to return to list", async () => {
const restoreMatchMedia = mockMobileViewport();
const selectSession = vi.fn();
try {
setupMockChat({
activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", updatedAt: "2026-04-08T00:00:00.000Z" },
messages: [{ id: "msg-001", sessionId: "session-001", role: "assistant", content: "Hello", createdAt: "2026-04-08T00:00:00.000Z" }],
selectSession,
});
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const backBtn = screen.getByTestId("chat-back-btn");
await userEvent.click(backBtn);
// Back button should trigger selectSession("") to return to list view
expect(selectSession).toHaveBeenCalledWith("");
} finally {
restoreMatchMedia();
}
});
it("desktop mode: renders thread header even without active session (shows empty state)", () => {
const restoreMatchMedia = mockDesktopViewport();
try {
setupMockChat({
sessions: [{ id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", updatedAt: "2026-04-08T00:00:00.000Z" }],
filteredSessions: [{ id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", updatedAt: "2026-04-08T00:00:00.000Z" }],
activeSession: null,
});
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
// Desktop mode: thread header should always be visible (even in empty state)
expect(document.querySelector(".chat-thread-header")).toBeInTheDocument();
// Back button should not be visible in desktop mode
expect(screen.queryByTestId("chat-back-btn")).not.toBeInTheDocument();
// Should show empty state
expect(screen.getByText("Start a new conversation")).toBeInTheDocument();
} finally {
restoreMatchMedia();
}
});
it("desktop mode: thread header is visible with active session", () => {
const restoreMatchMedia = mockDesktopViewport();
try {
setupMockChat({
activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", updatedAt: "2026-04-08T00:00:00.000Z" },
messages: [{ id: "msg-001", sessionId: "session-001", role: "assistant", content: "Hello", createdAt: "2026-04-08T00:00:00.000Z" }],
});
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
// Desktop mode: thread header should always be visible
expect(document.querySelector(".chat-thread-header")).toBeInTheDocument();
// Back button should not be visible in desktop mode
expect(screen.queryByTestId("chat-back-btn")).not.toBeInTheDocument();
} finally {
restoreMatchMedia();
}
});
});
describe("ChatView mobile CSS contract", () => {
const css = fs.readFileSync(stylesPath, "utf-8");

View File

@@ -4465,6 +4465,333 @@ describe("HeartbeatTriggerScheduler", () => {
});
});
// ── FN-2289 Regression: Idle-state timer persistence ─────────────────────────────────────
// These tests verify the fix for the defect where agent timers were unintentionally cleared
// when agents transitioned to "idle" state. The isTickableState() function must include "idle"
// as a valid state so that timers remain armed for agents between tasks.
describe("FN-2289: idle-state timer persistence", () => {
let eventStore: EventEmitter & {
getAgent: ReturnType<typeof vi.fn>;
getActiveHeartbeatRun: ReturnType<typeof vi.fn>;
getBudgetStatus: ReturnType<typeof vi.fn>;
};
beforeEach(() => {
vi.useFakeTimers();
eventStore = Object.assign(new EventEmitter(), {
getAgent: vi.fn().mockImplementation((agentId: string) => ({
id: agentId,
name: `Agent ${agentId}`,
role: "executor" as const,
state: "active" as const,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
metadata: {},
})),
getActiveHeartbeatRun: vi.fn().mockResolvedValue(null),
getBudgetStatus: vi.fn().mockResolvedValue(createBudgetStatus()),
});
scheduler = new HeartbeatTriggerScheduler(eventStore as unknown as AgentStore, callback);
scheduler.start();
});
afterEach(() => {
scheduler?.stop();
vi.useRealTimers();
});
it("timer remains armed when agent transitions to idle state (regression test for FN-2289)", async () => {
// Register agent with active state
scheduler.registerAgent("agent-001", { heartbeatIntervalMs: 5000 });
expect(scheduler.getRegisteredAgents()).toContain("agent-001");
// Simulate agent transitioning to idle state
(eventStore.getAgent as ReturnType<typeof vi.fn>).mockImplementation((agentId: string) => ({
id: agentId,
name: `Agent ${agentId}`,
role: "executor" as const,
state: "idle" as const, // Agent is now idle
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
metadata: {},
}));
eventStore.emit("agent:updated", { id: "agent-001", state: "idle", metadata: {} } as import("@fusion/core").Agent);
// Timer should still be registered
expect(scheduler.getRegisteredAgents()).toContain("agent-001");
// Timer should fire for idle agent
await vi.advanceTimersByTimeAsync(5000);
expect(callback).toHaveBeenCalledOnce();
expect(callback).toHaveBeenCalledWith("agent-001", "timer", {
wakeReason: "timer",
triggerDetail: "scheduled",
intervalMs: 5000,
});
});
it("timer fires correctly for idle agent at scheduled interval", async () => {
scheduler.registerAgent("agent-001", { heartbeatIntervalMs: 10000 });
expect(scheduler.getRegisteredAgents()).toContain("agent-001");
// Update agent to idle state
(eventStore.getAgent as ReturnType<typeof vi.fn>).mockImplementation((agentId: string) => ({
id: agentId,
name: `Agent ${agentId}`,
role: "executor" as const,
state: "idle" as const,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
metadata: {},
}));
eventStore.emit("agent:updated", { id: "agent-001", state: "idle", metadata: {} } as import("@fusion/core").Agent);
// Timer should still be armed
expect(scheduler.getRegisteredAgents()).toContain("agent-001");
// Advance time and verify multiple fires
await vi.advanceTimersByTimeAsync(30000);
expect(callback).toHaveBeenCalledTimes(3);
});
it("timer fires for agent transitioning from idle back to active", async () => {
// Start with idle state
(eventStore.getAgent as ReturnType<typeof vi.fn>).mockImplementation((agentId: string) => ({
id: agentId,
name: `Agent ${agentId}`,
role: "executor" as const,
state: "idle" as const,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
metadata: {},
}));
scheduler.registerAgent("agent-001", { heartbeatIntervalMs: 5000 });
// Timer should be armed even for idle agent
expect(scheduler.getRegisteredAgents()).toContain("agent-001");
// Advance time - timer should fire
await vi.advanceTimersByTimeAsync(5000);
expect(callback).toHaveBeenCalledOnce();
});
it("idle agent receives timer trigger with correct context", async () => {
scheduler.registerAgent("agent-001", { heartbeatIntervalMs: 15000 });
// Update to idle state
(eventStore.getAgent as ReturnType<typeof vi.fn>).mockImplementation((agentId: string) => ({
id: agentId,
name: `Agent ${agentId}`,
role: "executor" as const,
state: "idle" as const,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
metadata: {},
}));
eventStore.emit("agent:updated", { id: "agent-001", state: "idle", metadata: {} } as import("@fusion/core").Agent);
await vi.advanceTimersByTimeAsync(15000);
expect(callback).toHaveBeenCalledWith("agent-001", "timer", expect.objectContaining({
wakeReason: "timer",
triggerDetail: "scheduled",
intervalMs: 15000,
}));
});
it("timer is still armed after multiple idle state transitions", async () => {
scheduler.registerAgent("agent-001", { heartbeatIntervalMs: 10000 });
// First transition to idle
(eventStore.getAgent as ReturnType<typeof vi.fn>).mockImplementation((agentId: string) => ({
id: agentId,
name: `Agent ${agentId}`,
role: "executor" as const,
state: "idle" as const,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
metadata: {},
}));
eventStore.emit("agent:updated", { id: "agent-001", state: "idle", metadata: {} } as import("@fusion/core").Agent);
// Second transition (still idle, but emit update)
eventStore.emit("agent:updated", { id: "agent-001", state: "idle", metadata: {} } as import("@fusion/core").Agent);
// Third transition back to active
(eventStore.getAgent as ReturnType<typeof vi.fn>).mockImplementation((agentId: string) => ({
id: agentId,
name: `Agent ${agentId}`,
role: "executor" as const,
state: "active" as const,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
metadata: {},
}));
eventStore.emit("agent:updated", { id: "agent-001", state: "active", metadata: {} } as import("@fusion/core").Agent);
// Timer should still be registered through all transitions
expect(scheduler.getRegisteredAgents()).toContain("agent-001");
// Timer should fire
await vi.advanceTimersByTimeAsync(10000);
expect(callback).toHaveBeenCalledOnce();
});
it("timer fires for running agent (pre-existing behavior)", async () => {
scheduler.registerAgent("agent-001", { heartbeatIntervalMs: 5000 });
// Update to running state
(eventStore.getAgent as ReturnType<typeof vi.fn>).mockImplementation((agentId: string) => ({
id: agentId,
name: `Agent ${agentId}`,
role: "executor" as const,
state: "running" as const,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
metadata: {},
}));
eventStore.emit("agent:updated", { id: "agent-001", state: "running", metadata: {} } as import("@fusion/core").Agent);
expect(scheduler.getRegisteredAgents()).toContain("agent-001");
await vi.advanceTimersByTimeAsync(5000);
expect(callback).toHaveBeenCalledOnce();
});
it("timer is unregistered when agent becomes terminated (should clear timer)", async () => {
scheduler.registerAgent("agent-001", { heartbeatIntervalMs: 5000 });
expect(scheduler.getRegisteredAgents()).toContain("agent-001");
// Update to terminated state
(eventStore.getAgent as ReturnType<typeof vi.fn>).mockImplementation((agentId: string) => ({
id: agentId,
name: `Agent ${agentId}`,
role: "executor" as const,
state: "terminated" as const,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
metadata: {},
}));
eventStore.emit("agent:updated", { id: "agent-001", state: "terminated", metadata: {} } as import("@fusion/core").Agent);
// Timer should be cleared for terminated agents
expect(scheduler.getRegisteredAgents()).not.toContain("agent-001");
await vi.advanceTimersByTimeAsync(10000);
expect(callback).not.toHaveBeenCalled();
});
it("timer is unregistered when agent becomes error state (should clear timer)", async () => {
scheduler.registerAgent("agent-001", { heartbeatIntervalMs: 5000 });
expect(scheduler.getRegisteredAgents()).toContain("agent-001");
// Update to error state
(eventStore.getAgent as ReturnType<typeof vi.fn>).mockImplementation((agentId: string) => ({
id: agentId,
name: `Agent ${agentId}`,
role: "executor" as const,
state: "error" as const,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
metadata: {},
}));
eventStore.emit("agent:updated", { id: "agent-001", state: "error", metadata: {} } as import("@fusion/core").Agent);
// Timer should be cleared for error agents
expect(scheduler.getRegisteredAgents()).not.toContain("agent-001");
await vi.advanceTimersByTimeAsync(10000);
expect(callback).not.toHaveBeenCalled();
});
it("timer is unregistered when agent becomes paused state (should clear timer)", async () => {
scheduler.registerAgent("agent-001", { heartbeatIntervalMs: 5000 });
expect(scheduler.getRegisteredAgents()).toContain("agent-001");
// Update to paused state
(eventStore.getAgent as ReturnType<typeof vi.fn>).mockImplementation((agentId: string) => ({
id: agentId,
name: `Agent ${agentId}`,
role: "executor" as const,
state: "paused" as const,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
metadata: {},
}));
eventStore.emit("agent:updated", { id: "agent-001", state: "paused", metadata: {} } as import("@fusion/core").Agent);
// Timer should be cleared for paused agents
expect(scheduler.getRegisteredAgents()).not.toContain("agent-001");
await vi.advanceTimersByTimeAsync(10000);
expect(callback).not.toHaveBeenCalled();
});
});
// ── FN-2289 Regression: Multiplier stability across re-registration ─────────────────────
describe("FN-2289: multiplier stability across re-registration", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
scheduler?.stop();
vi.useRealTimers();
});
it("multiplier-adjusted interval remains stable when re-registering", async () => {
const taskStore = {
getSettings: vi.fn().mockResolvedValue({ heartbeatMultiplier: 0.5 }),
} as unknown as TaskStore;
scheduler = new HeartbeatTriggerScheduler(store, callback, taskStore);
scheduler.start();
// First registration with multiplier 0.5 -> effective interval 5000ms
scheduler.registerAgent("agent-001", { heartbeatIntervalMs: 10000 });
await vi.advanceTimersByTimeAsync(100); // Allow pending async operations to complete
// Re-register (simulating settings change or config update)
scheduler.registerAgent("agent-001", { heartbeatIntervalMs: 10000 });
await vi.advanceTimersByTimeAsync(100);
// Timer should still fire at the multiplied interval (5000ms)
// The timer was set up immediately, so we need to ensure we advance past 5000ms total
await vi.advanceTimersByTimeAsync(5000);
expect(callback).toHaveBeenCalledOnce();
});
it("async multiplier registration does not stale-overwrite newer registration", async () => {
const taskStore = {
getSettings: vi.fn().mockResolvedValue({ heartbeatMultiplier: 2.0 }),
} as unknown as TaskStore;
scheduler = new HeartbeatTriggerScheduler(store, callback, taskStore);
scheduler.start();
// Register with multiplier 2.0 -> effective interval 20000ms
scheduler.registerAgent("agent-001", { heartbeatIntervalMs: 10000 });
await Promise.resolve();
// Immediately re-register before async multiplier completes
scheduler.registerAgent("agent-001", { heartbeatIntervalMs: 10000 });
// Timer should still be registered
expect(scheduler.getRegisteredAgents()).toContain("agent-001");
// Advance time past the original interval (10s) but before multiplied (20s)
// If stale-overwrite happens, callback would be called at 10s instead of 20s
await vi.advanceTimersByTimeAsync(15000);
expect(callback).not.toHaveBeenCalled();
// Timer should fire at 20s (correct multiplied interval)
await vi.advanceTimersByTimeAsync(5000);
expect(callback).toHaveBeenCalledOnce();
});
});
describe("assignment watching", () => {
let eventStore: EventEmitter & {
getActiveHeartbeatRun: ReturnType<typeof vi.fn>;

View File

@@ -1667,9 +1667,19 @@ interface AgentTimer {
/**
* True when an agent's state indicates it should be ticking right now.
* Heartbeats track liveness while the agent is meant to be doing work.
*
* States where timers should remain armed:
* - "active" — Agent is working
* - "running" — Agent has an active heartbeat run
* - "idle" — Agent is between tasks, waiting for work (FN-2289 fix)
*
* States where timers should be cleared:
* - "terminated" — Agent has completed/failed
* - "error" — Agent encountered an error
* - "paused" — Agent is paused by budget exhaustion or manual action
*/
function isTickableState(state: Agent["state"]): boolean {
return state === "active" || state === "running";
return state === "active" || state === "running" || state === "idle";
}
/**