From 9c646b5fe2adb67863b65b8950105a0fc85fd135 Mon Sep 17 00:00:00 2001 From: tryeverything24 <114252040+tryeverything24@users.noreply.github.com> Date: Sun, 26 Jul 2026 07:46:49 +0300 Subject: [PATCH] fix(miner-ui): dispatch governor pause/resume chat actions from the conversation The chat rail registered and unit-tested the whole governor pause/resume action family but never dispatched it: conversation.tsx only resolved portfolio-queue text, so governor-intent messages fell through to the read-only streaming assistant and GovernorChatActionResult was never rendered anywhere. Add resolveGovernorChatAction (mirroring chat-portfolio-queue-resolve), wire it into handleSubmit ahead of the generic stream so a matched intent dispatches through runGovernorChatAction (registration + flag-gated dispatch + gate), and render GovernorChatActionResult in the message stream: pending copy below the list while the round-trip is in flight, and the resolved Ledgers-verbatim result as the turn outcome. Closes #8670 --- .../src/chat-conversation.test.tsx | 144 ++++++++++++++++++ .../src/components/chat/conversation.tsx | 98 +++++++++++- .../src/components/chat/fixtures.ts | 5 + .../src/components/chat/message-bubble.tsx | 9 +- .../src/lib/chat-governor-resolve.test.ts | 72 +++++++++ .../src/lib/chat-governor-resolve.ts | 66 ++++++++ 6 files changed, 391 insertions(+), 3 deletions(-) create mode 100644 apps/loopover-miner-ui/src/lib/chat-governor-resolve.test.ts create mode 100644 apps/loopover-miner-ui/src/lib/chat-governor-resolve.ts diff --git a/apps/loopover-miner-ui/src/chat-conversation.test.tsx b/apps/loopover-miner-ui/src/chat-conversation.test.tsx index 42c48608f..2bfaa53a7 100644 --- a/apps/loopover-miner-ui/src/chat-conversation.test.tsx +++ b/apps/loopover-miner-ui/src/chat-conversation.test.tsx @@ -1,8 +1,11 @@ import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; +import { createChatActionRegistry } from "../../../packages/loopover-miner/lib/chat-action-registry.js"; import { ChatConversation } from "./components/chat/conversation"; +import { GOVERNOR_CHAT_ACTION_PENDING_MESSAGE } from "./lib/chat-governor-action-copy"; import type { ChatWireMessage } from "./lib/chat-stream"; +import type { GovernorPauseState } from "./lib/governor"; const sendButton = () => screen.getByRole("button", { name: "Send" }) as HTMLButtonElement; @@ -194,3 +197,144 @@ describe("ChatConversation (#6518)", () => { expect(seen[0]).toEqual([{ role: "user", content: "what is stuck?" }]); }); }); + +describe("ChatConversation governor pause/resume chat actions (#8670)", () => { + const pausedAt = "2026-07-16T10:00:00.000Z"; + const pausedState: GovernorPauseState = { paused: true, reason: null, pausedAt }; + const notPausedState: GovernorPauseState = { paused: false, reason: null, pausedAt: null }; + + /** Real registry + real registration/dispatch wire; only the two HTTP clients are injected. */ + function governorHarness(overrides: { pauseState?: GovernorPauseState } = {}) { + const registry = createChatActionRegistry(); + const pauseGovernorFn = vi.fn(async () => ({ ok: true as const, pauseState: overrides.pauseState ?? pausedState })); + const resumeGovernorFn = vi.fn(async () => ({ ok: true as const, pauseState: notPausedState })); + let streamCalls = 0; + const streamChatImpl = async function* (_messages: ChatWireMessage[]): AsyncGenerator { + streamCalls += 1; + yield "should not stream"; + }; + return { + pauseGovernorFn, + resumeGovernorFn, + streamChatImpl, + streamCalls: () => streamCalls, + deps: { registry, pauseGovernorFn, resumeGovernorFn }, + }; + } + + it('END-TO-END: typing "pause the governor" fires the pause action and renders GovernorChatActionResult copy', async () => { + const harness = governorHarness(); + render(); + ask("pause the governor"); + + // The pause client actually fires — through registration + flag-gated dispatch, not a direct call… + await waitFor(() => expect(harness.pauseGovernorFn).toHaveBeenCalledTimes(1)); + // …and the resolved result lands in the message list with GovernorChatActionResult's Ledgers-verbatim copy. + await waitFor(() => expect(screen.getByText(`Paused since ${pausedAt}`)).toBeTruthy()); + expect(screen.getByText("pause the governor")).toBeTruthy(); + expect(harness.resumeGovernorFn).not.toHaveBeenCalled(); + expect(harness.streamCalls()).toBe(0); + expect(sendButton().disabled).toBe(false); + }); + + it("passes a because-clause through to pauseGovernor as the structured reason param", async () => { + const harness = governorHarness({ + pauseState: { paused: true, reason: "release traffic spiked", pausedAt }, + }); + render(); + ask("pause the governor because release traffic spiked"); + + await waitFor(() => expect(harness.pauseGovernorFn).toHaveBeenCalledWith("release traffic spiked")); + await waitFor(() => expect(screen.getByText(`Paused since ${pausedAt} (release traffic spiked)`)).toBeTruthy()); + expect(harness.streamCalls()).toBe(0); + }); + + it('typing "resume the governor" fires the resume action and renders the not-paused copy', async () => { + const harness = governorHarness(); + render(); + ask("resume the governor"); + + await waitFor(() => expect(harness.resumeGovernorFn).toHaveBeenCalledTimes(1)); + await waitFor(() => expect(screen.getByText("Not paused")).toBeTruthy()); + expect(harness.pauseGovernorFn).not.toHaveBeenCalled(); + expect(harness.streamCalls()).toBe(0); + }); + + it("shows the pending governor copy (and locks the composer) while the round-trip is outstanding", async () => { + const registry = createChatActionRegistry(); + let resolvePause!: (value: { ok: true; pauseState: GovernorPauseState }) => void; + const pauseGovernorFn = vi.fn( + () => new Promise<{ ok: true; pauseState: GovernorPauseState }>((resolve) => (resolvePause = resolve)), + ); + const resumeGovernorFn = vi.fn(async () => ({ ok: true as const, pauseState: notPausedState })); + render( + , + ); + ask("pause the governor"); + + await waitFor(() => expect(screen.getByText(GOVERNOR_CHAT_ACTION_PENDING_MESSAGE)).toBeTruthy()); + expect(sendButton().disabled).toBe(true); + + resolvePause({ ok: true, pauseState: pausedState }); + + await waitFor(() => expect(screen.getByText(`Paused since ${pausedAt}`)).toBeTruthy()); + expect(screen.queryByText(GOVERNOR_CHAT_ACTION_PENDING_MESSAGE)).toBeNull(); + expect(sendButton().disabled).toBe(false); + }); + + it("surfaces a non-executed dispatch (flag off / gated) as a system note instead of an empty turn", async () => { + const runGovernorChatActionImpl = vi.fn(async () => ({ ok: false, status: "disabled", action: null })); + render( + , + ); + ask("pause the governor"); + + await waitFor(() => expect(screen.getByText("Couldn't run the governor action: disabled.")).toBeTruthy()); + expect(runGovernorChatActionImpl).toHaveBeenCalledTimes(1); + expect(sendButton().disabled).toBe(false); + }); + + it("surfaces a thrown dispatch as the inline turn-failed note and re-enables the composer", async () => { + const runGovernorChatActionImpl = vi.fn(async () => { + throw new Error("connection refused"); + }); + render( + , + ); + ask("pause the governor"); + + await waitFor(() => expect(screen.getByText(/latest response failed to complete/i)).toBeTruthy()); + expect(sendButton().disabled).toBe(false); + }); + + it("an ordinary governor QUESTION still streams through the read-only assistant, never a dispatch", async () => { + const harness = governorHarness(); + const seen: ChatWireMessage[][] = []; + const streamChatImpl = async function* (messages: ChatWireMessage[]) { + seen.push(messages); + yield "governor overview"; + }; + render(); + ask("what is the governor status?"); + + await waitFor(() => expect(screen.getByText("governor overview")).toBeTruthy()); + expect(harness.pauseGovernorFn).not.toHaveBeenCalled(); + expect(harness.resumeGovernorFn).not.toHaveBeenCalled(); + expect(seen[0]).toEqual([{ role: "user", content: "what is the governor status?" }]); + }); +}); diff --git a/apps/loopover-miner-ui/src/components/chat/conversation.tsx b/apps/loopover-miner-ui/src/components/chat/conversation.tsx index 901149d55..2a2489a4f 100644 --- a/apps/loopover-miner-ui/src/components/chat/conversation.tsx +++ b/apps/loopover-miner-ui/src/components/chat/conversation.tsx @@ -15,6 +15,16 @@ import { type HandlePortfolioQueueChatCommandResult, } from "@/lib/chat-portfolio-queue-actions"; import { fetchPortfolioQueueItems } from "@/lib/portfolio-queue-actions"; +import { + enabledChatActionsEnv, + registerGovernorChatActions, + runGovernorChatAction, + unwrapGovernorPauseChatResult, + type RegisterGovernorChatActionsOptions, +} from "@/lib/chat-governor-actions"; +import { formatGovernorPauseChatMessage } from "@/lib/chat-governor-action-copy"; +import { resolveGovernorChatAction } from "@/lib/chat-governor-resolve"; +import { GovernorChatActionResult } from "@/components/chat/governor-action-result"; // The chat-rail's content integration (#6518): the first point the persistent rail (#6513) holds a live // conversation. Pure wiring — it composes the standalone composer (#6514), message list (#6515), and streaming @@ -23,6 +33,11 @@ import { fetchPortfolioQueueItems } from "@/lib/portfolio-queue-actions"; // #7075: portfolio release/requeue is resolved first via resolvePortfolioQueueChatAction; only unresolved // text falls through to streamChat. Action dispatch reuses the already-built handlePortfolioQueueChatCommand // pipeline (no new routes / fetches). +// +// #8670: governor pause/resume is resolved the same way via resolveGovernorChatAction; a matched intent +// dispatches through the already-built runGovernorChatAction wire (registry + flag + gate — no new routes / +// fetches) and renders GovernorChatActionResult in the message stream, so the chat rail finally reaches the +// same pauseGovernor/resumeGovernor clients the Ledgers buttons call. const ASSISTANT_NAME = "LoopOver"; /** Inline failure note appended after a failed turn — keeps history visible instead of StateBoundary wipe (#7077). */ @@ -40,12 +55,19 @@ export type PortfolioQueueChatCommandFn = ( deps: HandlePortfolioQueueChatCommandDeps, ) => Promise; +/** Injectable so tests can observe the dispatch; defaults to the real flag-gated `runGovernorChatAction`. */ +export type RunGovernorChatActionFn = typeof runGovernorChatAction; + export type ChatConversationProps = { streamChatImpl?: StreamChatFn; /** Defaults to the real end-to-end portfolio release/requeue handler (#7075). */ handlePortfolioQueueChatCommandImpl?: PortfolioQueueChatCommandFn; /** Partial override of production portfolio-command deps (tests inject loadItems / gates / env). */ portfolioQueueChatDeps?: Partial; + /** Defaults to the real flag-gated governor dispatch wire (#8670). */ + runGovernorChatActionImpl?: RunGovernorChatActionFn; + /** Override of production governor registration deps (tests inject registry / pause / resume fns). */ + governorChatDeps?: RegisterGovernorChatActionsOptions; }; function defaultPortfolioQueueChatDeps( @@ -63,10 +85,15 @@ export function ChatConversation({ streamChatImpl = streamChat, handlePortfolioQueueChatCommandImpl = handlePortfolioQueueChatCommand, portfolioQueueChatDeps, + runGovernorChatActionImpl = runGovernorChatAction, + governorChatDeps, }: ChatConversationProps = {}) { const [messages, setMessages] = useState([]); const [activeSource, setActiveSource] = useState(null); const [streaming, setStreaming] = useState(false); + // #8670: true for the duration of a governor pause/resume round-trip — drives the pending copy of + // GovernorChatActionResult below the list (the chat twin of LedgersPage's `actionPending`). + const [governorActionPending, setGovernorActionPending] = useState(false); // #7078: true from submit until the first SSE text chunk — drives MessageList's TypingIndicator so the // pre-first-token round-trip isn't a silent gap. Cleared on first chunk, stream completion, or error. const [awaitingFirstChunk, setAwaitingFirstChunk] = useState(false); @@ -112,6 +139,60 @@ export function ChatConversation({ return; } + // #8670: try governor pause/resume resolution next — BEFORE opening a read-only stream, so + // governor-intent text actually reaches runGovernorChatAction instead of the generic assistant. + const governorResolved = resolveGovernorChatAction(text); + if (governorResolved.ok) { + setMessages((prev) => [...prev, userMessage]); + // Reuse the composer-disable flag for the action round-trip (no streaming source / typing indicator). + setStreaming(true); + void (async () => { + const stamp = () => new Date().toISOString(); + try { + // Same idempotent registration the Vite dev-server entry performs — safe to repeat per dispatch, + // and the only path that works when the rail renders before (or without) that entry point. + registerGovernorChatActions(governorChatDeps ?? {}); + const dispatch = await runGovernorChatActionImpl( + { + action: governorResolved.action, + ...(governorResolved.params ? { params: governorResolved.params } : {}), + }, + { + env: enabledChatActionsEnv(), + ...(governorChatDeps?.registry ? { registry: governorChatDeps.registry } : {}), + onPending: setGovernorActionPending, + }, + ); + const result = unwrapGovernorPauseChatResult(dispatch); + setMessages((prev) => [ + ...prev, + result + ? { + id: nextId(), + role: "system", + content: formatGovernorPauseChatMessage(result), + timestamp: stamp(), + governorActionResult: result, + } + : { + id: nextId(), + role: "system", + content: `Couldn't run the governor action: ${dispatch.status}.`, + timestamp: stamp(), + }, + ]); + } catch { + setMessages((prev) => [ + ...prev, + { id: nextId(), role: "system", content: TURN_FAILED_MESSAGE, timestamp: stamp() }, + ]); + } finally { + setStreaming(false); + } + })(); + return; + } + // What the backend grounds against: the prior user/assistant turns plus this question, in wire shape. const history: ChatWireMessage[] = [...messages, userMessage] .filter((message): message is ChatMessage & { role: "user" | "assistant" } => message.role !== "system") @@ -184,7 +265,14 @@ export function ChatConversation({ // would be read as a functional update and *call* it instead of storing it. setActiveSource(() => source); }, - [messages, streamChatImpl, handlePortfolioQueueChatCommandImpl, portfolioQueueChatDeps], + [ + messages, + streamChatImpl, + handlePortfolioQueueChatCommandImpl, + portfolioQueueChatDeps, + runGovernorChatActionImpl, + governorChatDeps, + ], ); return ( @@ -195,7 +283,13 @@ export function ChatConversation({ messages={messages} composing={streaming && awaitingFirstChunk} footer={ - streaming && activeSource ? ( + governorActionPending ? ( + // #8670: pending copy for an in-flight pause/resume, in the same viewport slot the live + // streaming render uses — GovernorChatActionResult owns the role="status" announcement. +
+ +
+ ) : streaming && activeSource ? (
{ASSISTANT_NAME.slice(0, 2).toUpperCase()} diff --git a/apps/loopover-miner-ui/src/components/chat/fixtures.ts b/apps/loopover-miner-ui/src/components/chat/fixtures.ts index 548b44242..98089cc7b 100644 --- a/apps/loopover-miner-ui/src/components/chat/fixtures.ts +++ b/apps/loopover-miner-ui/src/components/chat/fixtures.ts @@ -2,6 +2,8 @@ // entirely by props (a message array + a composing flag); a real data source arrives in a later, // separately-scoped issue. The shapes below are exactly what MessageBubble / MessageList render against. +import type { GovernorPauseStateResult } from "../../lib/governor"; + export type ChatRole = "user" | "assistant" | "system"; export interface ChatMessage { @@ -14,6 +16,9 @@ export interface ChatMessage { authorName?: string; /** Optional avatar image URL; when absent, MessageBubble renders the initials fallback only. */ avatarUrl?: string; + /** When set, MessageBubble renders this turn through GovernorChatActionResult instead of the plain + * `content` string (#8670) — `content` still carries the same copy for wire-history/plain-text use. */ + governorActionResult?: GovernorPauseStateResult; } /** Empty conversation — drives MessageList's StateBoundary empty branch. */ diff --git a/apps/loopover-miner-ui/src/components/chat/message-bubble.tsx b/apps/loopover-miner-ui/src/components/chat/message-bubble.tsx index 25eb445aa..6be270be6 100644 --- a/apps/loopover-miner-ui/src/components/chat/message-bubble.tsx +++ b/apps/loopover-miner-ui/src/components/chat/message-bubble.tsx @@ -1,4 +1,5 @@ import { Avatar, AvatarFallback, AvatarImage } from "@loopover/ui-kit/components/avatar"; +import { GovernorChatActionResult } from "./governor-action-result"; import type { ChatMessage, ChatRole } from "./fixtures"; // Role-differentiated bubble backgrounds, built ONLY from existing @loopover/ui-kit theme tokens @@ -33,7 +34,13 @@ export function MessageBubble({ message }: { message: ChatMessage }) {
- {message.content} + {message.governorActionResult ? ( + // #8670: a resolved governor pause/resume turn renders through the dedicated result component + // (same Ledgers-verbatim copy as `content`), not as an undifferentiated plain-text bubble. + + ) : ( + message.content + )}