Skip to content

Commit 58e7380

Browse files
authored
fix(miner-ui): wire discover/attempt chat actions into the shared registry (#7163)
registerDiscoverAttemptChatActions (packages/loopover-miner/lib/chat-discover-attempt-actions.js) was the only half of #6837 that shipped: the miner-ui wire module that supplies requestDiscover / requestAttempt to it and registers the actions was never added, so chat had no way to dispatch discover or attempt. Mirror chat-governor-actions.ts: add the wire module binding the existing ./discover / ./attempt HTTP clients, plus a vite-chat-discover-attempt-actions.ts plugin registered in vite.config.ts alongside chatGovernorActionsPlugin. No new route or fetch is added — the handlers still go through the existing /api/discover and /api/attempt clients. UI text-to-action resolution is intentionally left to a follow-up. Closes #7076 Co-authored-by: bitfathers94 <237535319+bitfathers94@users.noreply.github.com>
1 parent f077678 commit 58e7380

4 files changed

Lines changed: 294 additions & 0 deletions

File tree

Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
1+
import { describe, expect, it, vi } from "vitest";
2+
3+
// chat-action-registry → governor-chokepoint → governor-ledger → node:sqlite. jsdom/Vite cannot bundle that
4+
// builtin (#6837); keep a client-safe registry twin so the real registration module loads. Same pattern as
5+
// chat-governor-actions.test.tsx.
6+
vi.mock("../../../../packages/loopover-miner/lib/chat-action-registry.js", () => {
7+
const GOVERNOR_GATED = Symbol("loopover.chat-action.governor-gated");
8+
9+
function isGovernorGatedHandler(handler: unknown): boolean {
10+
return typeof handler === "function" && (handler as unknown as { [k: symbol]: unknown })[GOVERNOR_GATED] === true;
11+
}
12+
13+
function governorGatedHandler(
14+
run: (request: unknown, gate: unknown) => unknown,
15+
options: { evaluateGate?: (input?: unknown) => { decision: { stage: string } } } = {},
16+
) {
17+
const evaluateGate = options.evaluateGate ?? (() => ({ decision: { stage: "allow" } }));
18+
const handler = async (request: { governorInput?: unknown }) => {
19+
const gate = evaluateGate(request?.governorInput);
20+
if (gate?.decision?.stage !== "allow") {
21+
return { ok: false, status: "gated", decision: gate?.decision ?? null };
22+
}
23+
const result = await run(request, gate);
24+
return { ok: true, status: "executed", decision: gate.decision, result };
25+
};
26+
Object.defineProperty(handler, GOVERNOR_GATED, { value: true });
27+
return handler;
28+
}
29+
30+
function createChatActionRegistry() {
31+
const actions = new Map<
32+
string,
33+
{ paramsValidator: (params: unknown) => boolean; handler: (request: unknown) => Promise<unknown> }
34+
>();
35+
return {
36+
register(
37+
name: string,
38+
definition: {
39+
paramsValidator: (params: unknown) => boolean;
40+
handler: (request: unknown) => Promise<unknown>;
41+
},
42+
) {
43+
if (!isGovernorGatedHandler(definition.handler)) {
44+
throw new Error(`registerChatAction("${name}"): handler must be produced by governorGatedHandler()`);
45+
}
46+
actions.set(name, definition);
47+
return definition;
48+
},
49+
get: (name: string) => actions.get(name),
50+
has: (name: string) => actions.has(name),
51+
names: () => [...actions.keys()],
52+
get size() {
53+
return actions.size;
54+
},
55+
};
56+
}
57+
58+
return {
59+
createChatActionRegistry,
60+
governorGatedHandler,
61+
isGovernorGatedHandler,
62+
chatActionRegistry: createChatActionRegistry(),
63+
registerChatAction: () => {
64+
throw new Error("tests use an injected isolated registry");
65+
},
66+
};
67+
});
68+
69+
import {
70+
CHAT_ACTION_DISPATCH_ENABLE_VALUE,
71+
CHAT_ACTION_DISPATCH_FLAG,
72+
dispatchChatAction,
73+
} from "../../../../packages/loopover-miner/lib/chat-action-dispatch.js";
74+
import { createChatActionRegistry } from "../../../../packages/loopover-miner/lib/chat-action-registry.js";
75+
import {
76+
ATTEMPT_CHAT_ACTION,
77+
DISCOVER_CHAT_ACTION,
78+
registerDiscoverAttemptChatActions,
79+
} from "./chat-discover-attempt-actions";
80+
import { requestAttempt } from "./attempt";
81+
import { requestDiscover } from "./discover";
82+
83+
const enabledEnv = { [CHAT_ACTION_DISPATCH_FLAG]: CHAT_ACTION_DISPATCH_ENABLE_VALUE };
84+
85+
const discoverOk = { ok: true as const, result: { enqueued: 2 }, exitCode: 0 };
86+
const attemptOk = { ok: true as const, result: { outcome: "submitted" }, exitCode: 0 };
87+
88+
const validAttemptParams = { repoFullName: "acme/widgets", issueNumber: 12, minerLogin: "octocat" };
89+
90+
describe("registerDiscoverAttemptChatActions (#6837)", () => {
91+
it("registers discover and attempt into the supplied registry", () => {
92+
const registry = createChatActionRegistry();
93+
registerDiscoverAttemptChatActions({ registry });
94+
expect(registry.has(DISCOVER_CHAT_ACTION)).toBe(true);
95+
expect(registry.has(ATTEMPT_CHAT_ACTION)).toBe(true);
96+
});
97+
98+
it("routes a dispatched discover only through requestDiscover, never attempt", async () => {
99+
const registry = createChatActionRegistry();
100+
const requestDiscoverFn = vi.fn(async () => discoverOk);
101+
const requestAttemptFn = vi.fn(async () => attemptOk);
102+
registerDiscoverAttemptChatActions({ registry, requestDiscoverFn, requestAttemptFn });
103+
104+
const dispatch = await dispatchChatAction(
105+
{ action: DISCOVER_CHAT_ACTION, params: { search: "typescript", dryRun: true } },
106+
{ env: enabledEnv, registry },
107+
);
108+
109+
expect(dispatch).toMatchObject({ ok: true, status: "dispatched" });
110+
expect(dispatch.result).toMatchObject({ status: "executed", result: discoverOk });
111+
expect(requestDiscoverFn).toHaveBeenCalledWith({ search: "typescript", dryRun: true });
112+
expect(requestAttemptFn).not.toHaveBeenCalled();
113+
});
114+
115+
it("forwards nullish discover params to requestDiscover as an empty object", async () => {
116+
const registry = createChatActionRegistry();
117+
const requestDiscoverFn = vi.fn(async () => discoverOk);
118+
registerDiscoverAttemptChatActions({ registry, requestDiscoverFn, requestAttemptFn: vi.fn(async () => attemptOk) });
119+
120+
await dispatchChatAction({ action: DISCOVER_CHAT_ACTION }, { env: enabledEnv, registry });
121+
122+
expect(requestDiscoverFn).toHaveBeenCalledWith({});
123+
});
124+
125+
it("routes a dispatched attempt only through requestAttempt, never discover", async () => {
126+
const registry = createChatActionRegistry();
127+
const requestDiscoverFn = vi.fn(async () => discoverOk);
128+
const requestAttemptFn = vi.fn(async () => attemptOk);
129+
registerDiscoverAttemptChatActions({ registry, requestDiscoverFn, requestAttemptFn });
130+
131+
const dispatch = await dispatchChatAction(
132+
{ action: ATTEMPT_CHAT_ACTION, params: validAttemptParams },
133+
{ env: enabledEnv, registry },
134+
);
135+
136+
expect(dispatch).toMatchObject({ ok: true, status: "dispatched" });
137+
expect(dispatch.result).toMatchObject({ status: "executed", result: attemptOk });
138+
expect(requestAttemptFn).toHaveBeenCalledWith(validAttemptParams);
139+
expect(requestDiscoverFn).not.toHaveBeenCalled();
140+
});
141+
142+
it("rejects an attempt with missing required params before reaching the client", async () => {
143+
const registry = createChatActionRegistry();
144+
const requestAttemptFn = vi.fn(async () => attemptOk);
145+
registerDiscoverAttemptChatActions({
146+
registry,
147+
requestDiscoverFn: vi.fn(async () => discoverOk),
148+
requestAttemptFn,
149+
});
150+
151+
const dispatch = await dispatchChatAction(
152+
{ action: ATTEMPT_CHAT_ACTION, params: { repoFullName: "acme/widgets" } },
153+
{ env: enabledEnv, registry },
154+
);
155+
156+
expect(dispatch).toMatchObject({ ok: false, status: "invalid_params" });
157+
expect(requestAttemptFn).not.toHaveBeenCalled();
158+
});
159+
160+
it("does not run the client when the injected gate denies the write", async () => {
161+
const registry = createChatActionRegistry();
162+
const requestAttemptFn = vi.fn(async () => attemptOk);
163+
registerDiscoverAttemptChatActions({
164+
registry,
165+
requestDiscoverFn: vi.fn(async () => discoverOk),
166+
requestAttemptFn,
167+
evaluateGate: () => ({ decision: { stage: "block" } }),
168+
});
169+
170+
const dispatch = await dispatchChatAction(
171+
{ action: ATTEMPT_CHAT_ACTION, params: validAttemptParams },
172+
{ env: enabledEnv, registry },
173+
);
174+
175+
expect(dispatch).toMatchObject({ ok: true, status: "dispatched" });
176+
expect(dispatch.result).toMatchObject({ status: "gated" });
177+
expect(requestAttemptFn).not.toHaveBeenCalled();
178+
});
179+
180+
it("regression: default wiring binds the exported requestDiscover/requestAttempt clients", async () => {
181+
const discoverModule = await import("./discover");
182+
const attemptModule = await import("./attempt");
183+
const discoverSpy = vi.spyOn(discoverModule, "requestDiscover").mockResolvedValue(discoverOk);
184+
const attemptSpy = vi.spyOn(attemptModule, "requestAttempt").mockResolvedValue(attemptOk);
185+
186+
const registry = createChatActionRegistry();
187+
registerDiscoverAttemptChatActions({ registry });
188+
189+
await dispatchChatAction(
190+
{ action: DISCOVER_CHAT_ACTION, params: { search: "rust" } },
191+
{ env: enabledEnv, registry },
192+
);
193+
expect(discoverSpy).toHaveBeenCalledWith({ search: "rust" });
194+
195+
await dispatchChatAction(
196+
{ action: ATTEMPT_CHAT_ACTION, params: validAttemptParams },
197+
{ env: enabledEnv, registry },
198+
);
199+
expect(attemptSpy).toHaveBeenCalledWith(validAttemptParams);
200+
201+
discoverSpy.mockRestore();
202+
attemptSpy.mockRestore();
203+
});
204+
205+
it("defaults registration to the real ./discover and ./attempt module exports", () => {
206+
// Structural pin: the wire module's default path is `requestDiscover` / `requestAttempt`.
207+
expect(typeof requestDiscover).toBe("function");
208+
expect(typeof requestAttempt).toBe("function");
209+
});
210+
});
211+
212+
describe("chatDiscoverAttemptActionsPlugin (#6837)", () => {
213+
it("registers discover/attempt into the shared registry on configureServer", async () => {
214+
const { chatDiscoverAttemptActionsPlugin } = await import("../../vite-chat-discover-attempt-actions");
215+
const { chatActionRegistry } = await import("../../../../packages/loopover-miner/lib/chat-action-registry.js");
216+
217+
const plugin = chatDiscoverAttemptActionsPlugin();
218+
expect(plugin.name).toBe("loopover-miner-chat-discover-attempt-actions");
219+
220+
(plugin.configureServer as () => void)();
221+
222+
// configureServer fires import().then(register); wait for the shared registration to settle.
223+
await vi.waitFor(() => {
224+
expect(chatActionRegistry.has(DISCOVER_CHAT_ACTION)).toBe(true);
225+
expect(chatActionRegistry.has(ATTEMPT_CHAT_ACTION)).toBe(true);
226+
});
227+
});
228+
});
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
// Miner-ui wire for discover/attempt chat actions (#6837 — completing the miner-ui half that #7076 found
2+
// missing).
3+
//
4+
// Binds the shared registry registrations (packages/loopover-miner/lib/chat-discover-attempt-actions.js) to the
5+
// existing `requestDiscover` / `requestAttempt` HTTP clients — the same functions that POST `/api/discover` and
6+
// `/api/attempt`. Like chat-governor-actions.ts, this module only supplies those clients in; it adds no new
7+
// route and no hand-rolled fetch. UI text-to-action resolution for discover/attempt is intentionally left to a
8+
// follow-up, so there is no runner/unwrap surface here yet — registering the handlers is the whole scope.
9+
10+
import { type ChatActionRegistry } from "../../../../packages/loopover-miner/lib/chat-action-registry.js";
11+
import {
12+
ATTEMPT_CHAT_ACTION,
13+
DISCOVER_CHAT_ACTION,
14+
registerDiscoverAttemptChatActions as registerDiscoverAttemptChatActionsCore,
15+
} from "../../../../packages/loopover-miner/lib/chat-discover-attempt-actions.js";
16+
import { requestAttempt, type AttemptActionInput } from "./attempt";
17+
import { requestDiscover, type DiscoverActionInput } from "./discover";
18+
19+
export {
20+
ATTEMPT_CHAT_ACTION,
21+
DISCOVER_CHAT_ACTION,
22+
isAttemptChatParams,
23+
isDiscoverChatParams,
24+
} from "../../../../packages/loopover-miner/lib/chat-discover-attempt-actions.js";
25+
26+
export type DiscoverAttemptChatActionName = typeof DISCOVER_CHAT_ACTION | typeof ATTEMPT_CHAT_ACTION;
27+
28+
export type RegisterDiscoverAttemptChatActionsOptions = {
29+
registry?: ChatActionRegistry;
30+
requestDiscoverFn?: typeof requestDiscover;
31+
requestAttemptFn?: typeof requestAttempt;
32+
evaluateGate?: () => { decision: { stage: string } };
33+
};
34+
35+
/** Idempotently register both actions, defaulting to the real `./discover` / `./attempt` clients. */
36+
export function registerDiscoverAttemptChatActions(options: RegisterDiscoverAttemptChatActionsOptions = {}): void {
37+
const discover = options.requestDiscoverFn ?? requestDiscover;
38+
const attempt = options.requestAttemptFn ?? requestAttempt;
39+
registerDiscoverAttemptChatActionsCore({
40+
// The registry passes an already-validated params record (isDiscoverChatParams / isAttemptChatParams ran
41+
// in dispatch first), so narrowing to each client's input type here is safe.
42+
requestDiscover: (input) => discover(input as DiscoverActionInput),
43+
requestAttempt: (input) => attempt(input as AttemptActionInput),
44+
registry: options.registry,
45+
evaluateGate: options.evaluateGate,
46+
});
47+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import type { Plugin } from "vite";
2+
3+
// Registers discover/attempt chat actions into the shared registry on dev-server start (#6837). Handlers call
4+
// the existing miner-ui `requestDiscover` / `requestAttempt` clients — the same POST `/api/discover` and
5+
// `/api/attempt` path the routes already serve. No new /api/* route is added here (mirrors
6+
// vite-chat-governor-actions.ts).
7+
8+
export function chatDiscoverAttemptActionsPlugin(): Plugin {
9+
return {
10+
name: "loopover-miner-chat-discover-attempt-actions",
11+
configureServer() {
12+
void import("./src/lib/chat-discover-attempt-actions").then((mod) => {
13+
mod.registerDiscoverAttemptChatActions();
14+
});
15+
},
16+
};
17+
}

apps/loopover-miner-ui/vite.config.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import tsconfigPaths from "vite-tsconfig-paths";
77
import { attemptApiPlugin } from "./vite-attempt-api";
88
import { authPlugin } from "./vite-auth";
99
import { chatApiPlugin } from "./vite-chat-api";
10+
import { chatDiscoverAttemptActionsPlugin } from "./vite-chat-discover-attempt-actions";
1011
import { chatGovernorActionsPlugin } from "./vite-chat-governor-actions";
1112
import { discoverApiPlugin } from "./vite-discover-api";
1213
import { governorApiPlugin } from "./vite-governor-api";
@@ -27,6 +28,7 @@ export default defineConfig({
2728
authPlugin(),
2829
chatApiPlugin(),
2930
chatGovernorActionsPlugin(),
31+
chatDiscoverAttemptActionsPlugin(),
3032
runStateApiPlugin(),
3133
portfolioQueueApiPlugin(),
3234
portfolioQueueActionsApiPlugin(),

0 commit comments

Comments
 (0)