Skip to content

Commit 453274a

Browse files
fix(miner-ui): wire portfolio release/requeue into chat submit (#7075) (#7160)
* fix(miner-ui): wire portfolio release/requeue into chat submit (#7075) Co-authored-by: Cursor <cursoragent@cursor.com> * fix(miner-ui): wire portfolio release/requeue into chat submit (#7075) Co-authored-by: Cursor <cursoragent@cursor.com> * style(miner-ui): prettier-fix chat-action vitest stubs Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent f03b956 commit 453274a

4 files changed

Lines changed: 249 additions & 7 deletions

File tree

Lines changed: 14 additions & 0 deletions
Loading

apps/loopover-miner-ui/src/chat-conversation.test.tsx

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
2-
import { describe, expect, it } from "vitest";
2+
import { describe, expect, it, vi } from "vitest";
33

44
import { ChatConversation } from "./components/chat/conversation";
55
import type { ChatWireMessage } from "./lib/chat-stream";
@@ -134,4 +134,63 @@ describe("ChatConversation (#6518)", () => {
134134
expect(screen.getByText(/latest response failed to complete/i)).toBeTruthy();
135135
expect(screen.queryByText(/Couldn't load the conversation/i)).toBeNull();
136136
});
137+
138+
it("REGRESSION (#7075): a release-shaped message dispatches through the portfolio handler, not streamChat", async () => {
139+
let streamCalls = 0;
140+
const streamChatImpl = async function* (_messages: ChatWireMessage[]): AsyncGenerator<string> {
141+
streamCalls += 1;
142+
yield "should not stream";
143+
};
144+
const handlePortfolioQueueChatCommandImpl = vi.fn(async (text: string) => {
145+
expect(text).toBe("release acme/widgets");
146+
return {
147+
dispatched: true,
148+
messages: [
149+
{
150+
id: "sys-release",
151+
role: "system" as const,
152+
content: "Queue release succeeded for acme/widgets (issue:12).",
153+
timestamp: "2026-07-16T09:00:00.000Z",
154+
},
155+
],
156+
};
157+
});
158+
159+
render(
160+
<ChatConversation
161+
streamChatImpl={streamChatImpl}
162+
handlePortfolioQueueChatCommandImpl={handlePortfolioQueueChatCommandImpl}
163+
/>,
164+
);
165+
ask("release acme/widgets");
166+
167+
await waitFor(() => expect(handlePortfolioQueueChatCommandImpl).toHaveBeenCalledTimes(1));
168+
await waitFor(() => expect(screen.getByText(/Queue release succeeded for acme\/widgets/i)).toBeTruthy());
169+
expect(screen.getByText("release acme/widgets")).toBeTruthy();
170+
expect(streamCalls).toBe(0);
171+
expect(sendButton().disabled).toBe(false);
172+
});
173+
174+
it("REGRESSION (#7075): an ordinary question still reaches streamChat unchanged", async () => {
175+
const seen: ChatWireMessage[][] = [];
176+
const streamChatImpl = async function* (messages: ChatWireMessage[]) {
177+
seen.push(messages);
178+
yield "grounded answer";
179+
};
180+
const handlePortfolioQueueChatCommandImpl = vi.fn(async () => {
181+
throw new Error("must not dispatch portfolio actions for ordinary questions");
182+
});
183+
184+
render(
185+
<ChatConversation
186+
streamChatImpl={streamChatImpl}
187+
handlePortfolioQueueChatCommandImpl={handlePortfolioQueueChatCommandImpl}
188+
/>,
189+
);
190+
ask("what is stuck?");
191+
192+
await waitFor(() => expect(screen.getByText("grounded answer")).toBeTruthy());
193+
expect(handlePortfolioQueueChatCommandImpl).not.toHaveBeenCalled();
194+
expect(seen[0]).toEqual([{ role: "user", content: "what is stuck?" }]);
195+
});
137196
});

apps/loopover-miner-ui/src/components/chat/conversation.tsx

Lines changed: 75 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,23 +8,62 @@ import { MessageList } from "@/components/chat/message-list";
88
import type { ChatMessage } from "@/components/chat/fixtures";
99
import type { ChunkSource } from "@/lib/use-streaming-text";
1010
import { streamChat, type ChatWireMessage } from "@/lib/chat-stream";
11+
import {
12+
handlePortfolioQueueChatCommand,
13+
resolvePortfolioQueueChatAction,
14+
type HandlePortfolioQueueChatCommandDeps,
15+
type HandlePortfolioQueueChatCommandResult,
16+
} from "@/lib/chat-portfolio-queue-actions";
17+
import { fetchPortfolioQueueItems } from "@/lib/portfolio-queue-actions";
1118

1219
// The chat-rail's content integration (#6518): the first point the persistent rail (#6513) holds a live
1320
// conversation. Pure wiring — it composes the standalone composer (#6514), message list (#6515), and streaming
1421
// renderer (#6516) around the read-only streaming backend (#6517), and owns nothing but the conversation state.
15-
// Strictly ask-a-question / read-only: the only network call it can make is `streamChat` → `POST /api/chat`; it
16-
// never touches an action endpoint (portfolio release/requeue, governor pause/resume) — that surface is a
17-
// separate, later, flag-gated issue.
22+
//
23+
// #7075: portfolio release/requeue is resolved first via resolvePortfolioQueueChatAction; only unresolved
24+
// text falls through to streamChat. Action dispatch reuses the already-built handlePortfolioQueueChatCommand
25+
// pipeline (no new routes / fetches).
1826

1927
const ASSISTANT_NAME = "LoopOver";
2028
/** Inline failure note appended after a failed turn — keeps history visible instead of StateBoundary wipe (#7077). */
2129
const TURN_FAILED_MESSAGE =
2230
"The latest response failed to complete. Any partial answer above is incomplete — you can try again.";
2331

32+
/** Local queue administration is not a chokepoint content-write (#6838 / #7075); satisfy the registry brand. */
33+
const allowAdministrativeGate = () => ({ decision: { stage: "allow" } });
34+
2435
/** Injectable so tests can drive the stream deterministically; defaults to the real `POST /api/chat` bridge. */
2536
export type StreamChatFn = (messages: ChatWireMessage[]) => AsyncIterable<string>;
2637

27-
export function ChatConversation({ streamChatImpl = streamChat }: { streamChatImpl?: StreamChatFn } = {}) {
38+
export type PortfolioQueueChatCommandFn = (
39+
text: string,
40+
deps: HandlePortfolioQueueChatCommandDeps,
41+
) => Promise<HandlePortfolioQueueChatCommandResult>;
42+
43+
export type ChatConversationProps = {
44+
streamChatImpl?: StreamChatFn;
45+
/** Defaults to the real end-to-end portfolio release/requeue handler (#7075). */
46+
handlePortfolioQueueChatCommandImpl?: PortfolioQueueChatCommandFn;
47+
/** Partial override of production portfolio-command deps (tests inject loadItems / gates / env). */
48+
portfolioQueueChatDeps?: Partial<HandlePortfolioQueueChatCommandDeps>;
49+
};
50+
51+
function defaultPortfolioQueueChatDeps(
52+
overrides: Partial<HandlePortfolioQueueChatCommandDeps> = {},
53+
): HandlePortfolioQueueChatCommandDeps {
54+
return {
55+
loadItems: () => fetchPortfolioQueueItems(),
56+
buildGovernorInput: () => ({}),
57+
evaluateGate: allowAdministrativeGate,
58+
...overrides,
59+
};
60+
}
61+
62+
export function ChatConversation({
63+
streamChatImpl = streamChat,
64+
handlePortfolioQueueChatCommandImpl = handlePortfolioQueueChatCommand,
65+
portfolioQueueChatDeps,
66+
}: ChatConversationProps = {}) {
2867
const [messages, setMessages] = useState<ChatMessage[]>([]);
2968
const [activeSource, setActiveSource] = useState<ChunkSource | null>(null);
3069
const [streaming, setStreaming] = useState(false);
@@ -42,6 +81,37 @@ export function ChatConversation({ streamChatImpl = streamChat }: { streamChatIm
4281
content: text,
4382
timestamp: new Date().toISOString(),
4483
};
84+
85+
// #7075: try portfolio release/requeue resolution BEFORE opening a read-only stream.
86+
const portfolioResolved = resolvePortfolioQueueChatAction(text);
87+
if (portfolioResolved.ok) {
88+
setMessages((prev) => [...prev, userMessage]);
89+
// Reuse the composer-disable flag for the action round-trip (no streaming source / typing indicator).
90+
setStreaming(true);
91+
void (async () => {
92+
try {
93+
const result = await handlePortfolioQueueChatCommandImpl(
94+
text,
95+
defaultPortfolioQueueChatDeps(portfolioQueueChatDeps),
96+
);
97+
setMessages((prev) => [...prev, ...result.messages]);
98+
} catch {
99+
setMessages((prev) => [
100+
...prev,
101+
{
102+
id: nextId(),
103+
role: "system",
104+
content: TURN_FAILED_MESSAGE,
105+
timestamp: new Date().toISOString(),
106+
},
107+
]);
108+
} finally {
109+
setStreaming(false);
110+
}
111+
})();
112+
return;
113+
}
114+
45115
// What the backend grounds against: the prior user/assistant turns plus this question, in wire shape.
46116
const history: ChatWireMessage[] = [...messages, userMessage]
47117
.filter((message): message is ChatMessage & { role: "user" | "assistant" } => message.role !== "system")
@@ -114,7 +184,7 @@ export function ChatConversation({ streamChatImpl = streamChat }: { streamChatIm
114184
// would be read as a functional update and *call* it instead of storing it.
115185
setActiveSource(() => source);
116186
},
117-
[messages, streamChatImpl],
187+
[messages, streamChatImpl, handlePortfolioQueueChatCommandImpl, portfolioQueueChatDeps],
118188
);
119189

120190
return (

apps/loopover-miner-ui/vitest.setup.ts

Lines changed: 100 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { cleanup } from "@testing-library/react";
2-
import { afterEach } from "vitest";
2+
import { afterEach, vi } from "vitest";
33

44
// Unmount React trees between tests so jsdom state never leaks across cases.
55
afterEach(() => {
@@ -15,3 +15,102 @@ class ResizeObserverStub {
1515
disconnect(): void {}
1616
}
1717
globalThis.ResizeObserver ??= ResizeObserverStub as unknown as typeof ResizeObserver;
18+
19+
// #7075: ChatConversation wires handlePortfolioQueueChatCommand, which imports chat-action-registry →
20+
// governor-chokepoint → governor-ledger → node:sqlite. jsdom/Vite cannot bundle that builtin (same twin
21+
// pattern as chat-governor-actions.test.tsx / chat-portfolio-queue-actions.test.tsx).
22+
const GOVERNOR_GATED = Symbol("loopover.chat-action.governor-gated");
23+
24+
function createBrowserSafeRegistry() {
25+
const actions = new Map<
26+
string,
27+
{ paramsValidator: (params: unknown) => boolean; handler: (request: unknown) => Promise<unknown> }
28+
>();
29+
return {
30+
register(
31+
name: string,
32+
definition: {
33+
paramsValidator: (params: unknown) => boolean;
34+
handler: (request: unknown) => Promise<unknown>;
35+
},
36+
) {
37+
if (
38+
typeof definition.handler !== "function" ||
39+
!(definition.handler as { [k: symbol]: unknown })[GOVERNOR_GATED]
40+
) {
41+
throw new Error(`registerChatAction("${name}"): handler must be produced by governorGatedHandler()`);
42+
}
43+
if (actions.has(name)) {
44+
// Idempotent enough for shared registration across tests that remount ChatConversation.
45+
return definition;
46+
}
47+
actions.set(name, definition);
48+
return definition;
49+
},
50+
get: (name: string) => actions.get(name),
51+
has: (name: string) => actions.has(name),
52+
names: () => [...actions.keys()],
53+
get size() {
54+
return actions.size;
55+
},
56+
};
57+
}
58+
59+
const sharedBrowserRegistry = createBrowserSafeRegistry();
60+
61+
vi.mock("../../packages/loopover-miner/lib/chat-action-registry.js", () => {
62+
function isGovernorGatedHandler(handler: unknown): boolean {
63+
return typeof handler === "function" && (handler as { [k: symbol]: unknown })[GOVERNOR_GATED] === true;
64+
}
65+
66+
function governorGatedHandler(
67+
run: (request: unknown, gate: unknown) => unknown,
68+
options: { evaluateGate?: (input?: unknown) => { decision: { stage: string } } } = {},
69+
) {
70+
const evaluateGate = options.evaluateGate ?? (() => ({ decision: { stage: "allow" } }));
71+
const handler = async (request: { governorInput?: unknown }) => {
72+
const gate = evaluateGate(request?.governorInput);
73+
if (gate?.decision?.stage !== "allow") {
74+
return { ok: false, status: "gated", decision: gate?.decision ?? null };
75+
}
76+
const result = await run(request, gate);
77+
return { ok: true, status: "executed", decision: gate.decision, result };
78+
};
79+
Object.defineProperty(handler, GOVERNOR_GATED, { value: true });
80+
return handler;
81+
}
82+
83+
return {
84+
createChatActionRegistry: createBrowserSafeRegistry,
85+
governorGatedHandler,
86+
isGovernorGatedHandler,
87+
chatActionRegistry: sharedBrowserRegistry,
88+
registerChatAction: (name: string, definition: Parameters<typeof sharedBrowserRegistry.register>[1]) =>
89+
sharedBrowserRegistry.register(name, definition),
90+
};
91+
});
92+
93+
vi.mock("../../packages/loopover-miner/lib/chat-action-dispatch.js", () => ({
94+
CHAT_ACTION_DISPATCH_FLAG: "LOOPOVER_MINER_CHAT_ACTIONS",
95+
CHAT_ACTION_DISPATCH_ENABLE_VALUE: "enabled",
96+
isChatActionDispatchEnabled: (env: Record<string, string | undefined> = {}) =>
97+
env.LOOPOVER_MINER_CHAT_ACTIONS === "enabled",
98+
dispatchChatAction: async (
99+
request: { action?: string; params?: unknown; governorInput?: unknown },
100+
options: { env?: Record<string, string | undefined>; registry?: { get: (name: string) => unknown } } = {},
101+
) => {
102+
const env = options.env ?? {};
103+
if (env.LOOPOVER_MINER_CHAT_ACTIONS !== "enabled") {
104+
return { ok: false, status: "disabled" };
105+
}
106+
const registry = options.registry ?? sharedBrowserRegistry;
107+
const entry = registry.get(request.action ?? "") as
108+
{ paramsValidator: (params: unknown) => boolean; handler: (request: unknown) => Promise<unknown> } | undefined;
109+
if (!entry) return { ok: false, status: "unknown_action", action: request.action };
110+
if (!entry.paramsValidator(request.params)) {
111+
return { ok: false, status: "invalid_params", action: request.action };
112+
}
113+
const result = await entry.handler(request);
114+
return { ok: true, status: "dispatched", action: request.action, result };
115+
},
116+
}));

0 commit comments

Comments
 (0)