Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
144 changes: 144 additions & 0 deletions apps/loopover-miner-ui/src/chat-conversation.test.tsx
Original file line number Diff line number Diff line change
@@ -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;

Expand Down Expand Up @@ -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<string> {
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(<ChatConversation streamChatImpl={harness.streamChatImpl} governorChatDeps={harness.deps} />);
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(<ChatConversation streamChatImpl={harness.streamChatImpl} governorChatDeps={harness.deps} />);
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(<ChatConversation streamChatImpl={harness.streamChatImpl} governorChatDeps={harness.deps} />);
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(
<ChatConversation
streamChatImpl={async function* () {
/* never used */
}}
governorChatDeps={{ registry, pauseGovernorFn, resumeGovernorFn }}
/>,
);
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(
<ChatConversation
streamChatImpl={async function* () {
/* never used */
}}
runGovernorChatActionImpl={runGovernorChatActionImpl}
/>,
);
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(
<ChatConversation
streamChatImpl={async function* () {
/* never used */
}}
runGovernorChatActionImpl={runGovernorChatActionImpl}
/>,
);
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(<ChatConversation streamChatImpl={streamChatImpl} governorChatDeps={harness.deps} />);
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?" }]);
});
});
98 changes: 96 additions & 2 deletions apps/loopover-miner-ui/src/components/chat/conversation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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). */
Expand All @@ -40,12 +55,19 @@ export type PortfolioQueueChatCommandFn = (
deps: HandlePortfolioQueueChatCommandDeps,
) => Promise<HandlePortfolioQueueChatCommandResult>;

/** 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<HandlePortfolioQueueChatCommandDeps>;
/** 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(
Expand All @@ -63,10 +85,15 @@ export function ChatConversation({
streamChatImpl = streamChat,
handlePortfolioQueueChatCommandImpl = handlePortfolioQueueChatCommand,
portfolioQueueChatDeps,
runGovernorChatActionImpl = runGovernorChatAction,
governorChatDeps,
}: ChatConversationProps = {}) {
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [activeSource, setActiveSource] = useState<ChunkSource | null>(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);
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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 (
Expand All @@ -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.
<div className="px-3 pt-4" data-testid="chat-governor-pending">
<GovernorChatActionResult pending result={null} />
</div>
) : streaming && activeSource ? (
<div className="flex gap-3 px-3 pt-4" data-testid="chat-streaming-response">
<Avatar className="size-8 shrink-0">
<AvatarFallback>{ASSISTANT_NAME.slice(0, 2).toUpperCase()}</AvatarFallback>
Expand Down
5 changes: 5 additions & 0 deletions apps/loopover-miner-ui/src/components/chat/fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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. */
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -33,7 +34,13 @@ export function MessageBubble({ message }: { message: ChatMessage }) {
</Avatar>
<div className="flex min-w-0 flex-col gap-1">
<div className={`whitespace-pre-wrap break-words rounded-token-sm px-3 py-2 text-token-sm ${bubbleClass}`}>
{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.
<GovernorChatActionResult pending={false} result={message.governorActionResult} />
) : (
message.content
)}
</div>
<time className="text-token-xs text-muted-foreground" dateTime={message.timestamp}>
{formatTimestamp(message.timestamp)}
Expand Down
Loading
Loading