diff --git a/packages/loopover-miner/lib/chat-discover-attempt-actions.d.ts b/packages/loopover-miner/lib/chat-discover-attempt-actions.d.ts new file mode 100644 index 0000000000..b54be0a594 --- /dev/null +++ b/packages/loopover-miner/lib/chat-discover-attempt-actions.d.ts @@ -0,0 +1,33 @@ +import type { ChatActionRegistry } from "./chat-action-registry.js"; + +export const DISCOVER_CHAT_ACTION: "discover"; +export const ATTEMPT_CHAT_ACTION: "attempt"; + +export type DiscoverChatActionInput = { + targets?: string[]; + search?: string; + dryRun?: boolean; + json?: boolean; + apiBaseUrl?: string; + tokenEnv?: string; +}; + +export type AttemptChatActionInput = { + repoFullName: string; + issueNumber: number; + minerLogin: string; + base?: string; + live?: boolean; + dryRun?: boolean; + json?: boolean; +}; + +export function isDiscoverChatParams(params: unknown): boolean; +export function isAttemptChatParams(params: unknown): boolean; + +export function registerDiscoverAttemptChatActions(options: { + requestDiscover: (input: DiscoverChatActionInput) => Promise; + requestAttempt: (input: AttemptChatActionInput) => Promise; + registry?: ChatActionRegistry; + evaluateGate?: () => { decision: { stage: string } }; +}): void; diff --git a/packages/loopover-miner/lib/chat-discover-attempt-actions.js b/packages/loopover-miner/lib/chat-discover-attempt-actions.js new file mode 100644 index 0000000000..34e32e346a --- /dev/null +++ b/packages/loopover-miner/lib/chat-discover-attempt-actions.js @@ -0,0 +1,141 @@ +// Discover/attempt chat-action registrations (#6837). +// +// The third and last child of the chat action-dispatch scaffolding (#6519) — chat-action-registry.js:4-5 +// names all three families (portfolio release/requeue, governor pause/resume, discover/attempt); the other +// two already ship. Registers `discover` / `attempt` into a chat-action registry. Handlers MUST be wired to +// the miner-ui clients `requestDiscover` / `requestAttempt` (apps/loopover-miner-ui/src/lib/{discover, +// attempt}.ts), so chat POSTs the SAME `/api/discover` and `/api/attempt` routes that already exist (#6522, +// registered at vite.config.ts:36-37) — never discover-cli.js/attempt-cli.js directly, and never a +// hand-rolled fetch. The miner-ui wire module passes those clients in; this module only owns the registration +// contract + params validators. +// +// GATING — the gate lives at the endpoint, not here, and that is deliberate: +// * `attempt` INHERITS the real Governor chokepoint for free: the route calls the real, unmodified +// `runAttempt`, and attempt-runner.js routes every write through +// `evaluateGovernorChokepointGatePersisted` before executing it (vite-attempt-api.ts:7-9). +// * `discover` has no chokepoint because it performs no gated write — it only fans out, ranks and enqueues +// (vite-discover-api.ts:13-14), so the CLI has none and the route adds none. +// Re-evaluating the chokepoint here would therefore be a SECOND, competing gate on a path that already has +// one (or needs none) — exactly what those route comments rule out, and it would gate chat more strictly than +// the equivalent CLI invocation. So, like chat-governor-actions.js and chat-portfolio-actions.js, we satisfy +// the registry's `governorGatedHandler` brand with an allow-stage evaluateGate. Execution still stays behind +// the shared LOOPOVER_MINER_CHAT_ACTIONS flag via `dispatchChatAction`, and `evaluateGate` stays injectable. + +import { governorGatedHandler, chatActionRegistry } from "./chat-action-registry.js"; + +export const DISCOVER_CHAT_ACTION = "discover"; +export const ATTEMPT_CHAT_ACTION = "attempt"; + +/** The endpoint owns the gate (see the header note); satisfy the registry brand only. */ +const allowEndpointGatedAction = () => ({ decision: { stage: "allow" } }); + +const DISCOVER_KEYS = new Set(["targets", "search", "dryRun", "json", "apiBaseUrl", "tokenEnv"]); +const ATTEMPT_KEYS = new Set(["repoFullName", "issueNumber", "minerLogin", "base", "live", "dryRun", "json"]); + +/** + * @param {unknown} params + * @returns {Record | null} + */ +function asParamsRecord(params) { + if (params == null || typeof params !== "object" || Array.isArray(params)) return null; + return /** @type {Record} */ (params); +} + +/** A non-empty string — the shape every required text field here needs. */ +function isNonEmptyString(value) { + return typeof value === "string" && value.trim() !== ""; +} + +/** + * `DiscoverActionInput` — every field optional (the CLI defaults them all), so an empty object is a valid + * "discover with defaults". Unknown keys are rejected rather than ignored: these params can be model-authored, + * and a typo'd flag must fail loudly instead of silently running a different discovery than intended. + * + * @param {unknown} params + * @returns {boolean} + */ +export function isDiscoverChatParams(params) { + if (params == null) return true; + const record = asParamsRecord(params); + if (record === null) return false; + for (const key of Object.keys(record)) { + if (!DISCOVER_KEYS.has(key)) return false; + } + if (record.targets !== undefined) { + if (!Array.isArray(record.targets) || !record.targets.every(isNonEmptyString)) return false; + } + for (const key of ["search", "apiBaseUrl", "tokenEnv"]) { + if (record[key] !== undefined && typeof record[key] !== "string") return false; + } + for (const key of ["dryRun", "json"]) { + if (record[key] !== undefined && typeof record[key] !== "boolean") return false; + } + return true; +} + +/** + * `AttemptActionInput` — `repoFullName` / `issueNumber` / `minerLogin` are REQUIRED (the CLI has no default + * for which issue to attempt), so unlike discover there is no valid empty form. `issueNumber` must be a + * positive integer: a float or 0 would reach the CLI as a nonsense issue reference. + * + * @param {unknown} params + * @returns {boolean} + */ +export function isAttemptChatParams(params) { + const record = asParamsRecord(params); + if (record === null) return false; + for (const key of Object.keys(record)) { + if (!ATTEMPT_KEYS.has(key)) return false; + } + if (!isNonEmptyString(record.repoFullName)) return false; + if (!isNonEmptyString(record.minerLogin)) return false; + if (!Number.isInteger(record.issueNumber) || /** @type {number} */ (record.issueNumber) <= 0) return false; + if (record.base !== undefined && typeof record.base !== "string") return false; + for (const key of ["live", "dryRun", "json"]) { + if (record[key] !== undefined && typeof record[key] !== "boolean") return false; + } + return true; +} + +/** + * Idempotently register `discover` / `attempt`. + * + * @param {{ + * requestDiscover: (input: object) => Promise, + * requestAttempt: (input: object) => Promise, + * registry?: import("./chat-action-registry.js").ChatActionRegistry, + * evaluateGate?: () => { decision: { stage: string } }, + * }} options + */ +export function registerDiscoverAttemptChatActions(options) { + const requestDiscover = options?.requestDiscover; + const requestAttempt = options?.requestAttempt; + if (typeof requestDiscover !== "function") { + throw new TypeError("registerDiscoverAttemptChatActions: requestDiscover must be a function"); + } + if (typeof requestAttempt !== "function") { + throw new TypeError("registerDiscoverAttemptChatActions: requestAttempt must be a function"); + } + + const registry = options.registry ?? chatActionRegistry; + const evaluateGate = options.evaluateGate ?? allowEndpointGatedAction; + + if (!registry.has(DISCOVER_CHAT_ACTION)) { + registry.register(DISCOVER_CHAT_ACTION, { + paramsValidator: isDiscoverChatParams, + // Nullish params mean "discover with defaults" -- forwarded as {} so the client always POSTs an object. + handler: governorGatedHandler(async (request) => requestDiscover(asParamsRecord(request?.params) ?? {}), { + evaluateGate, + }), + }); + } + + if (!registry.has(ATTEMPT_CHAT_ACTION)) { + registry.register(ATTEMPT_CHAT_ACTION, { + paramsValidator: isAttemptChatParams, + handler: governorGatedHandler(async (request) => requestAttempt(asParamsRecord(request?.params)), { + evaluateGate, + }), + }); + } +} diff --git a/packages/loopover-miner/package.json b/packages/loopover-miner/package.json index d6ea4bbaf6..687e43f617 100644 --- a/packages/loopover-miner/package.json +++ b/packages/loopover-miner/package.json @@ -38,7 +38,7 @@ "scripts": { "benchmark": "node scripts/benchmark.mjs", "cross-repo-eval": "node scripts/cross-repo-evaluation.mjs", - "build": "node --check bin/loopover-miner.js && node --check bin/loopover-miner-mcp.js && node --check lib/ams-policy.js && node --check lib/attempt-cli.js && node --check lib/attempt-input-builder.js && node --check lib/attempt-log.js && node --check lib/attempt-runner.js && node --check lib/attempt-worktree.js && node --check lib/calibration-run.js && node --check lib/calibration-types.js && node --check lib/calibration.js && node --check lib/chat-action-dispatch.js && node --check lib/chat-action-registry.js && node --check lib/chat-governor-actions.js && node --check lib/chat-portfolio-actions.js && node --check lib/ci-poller.js && node --check lib/claim-adjudication.js && node --check lib/claim-conflict-resolver.js && node --check lib/claim-ledger-cli.js && node --check lib/claim-ledger-expiry.js && node --check lib/claim-ledger.js && node --check lib/cli.js && node --check lib/coding-agent-construction.js && node --check lib/coding-agent-house-rules.js && node --check lib/coding-task-spec.js && node --check lib/cross-repo-evaluation.js && node --check lib/deny-check.js && node --check lib/deny-hook-synthesis.js && node --check lib/deny-hooks.js && node --check lib/deployment-docs-audit.js && node --check lib/discover-cli.js && node --check lib/event-ledger-cli.js && node --check lib/event-ledger.js && node --check lib/execute-local-write.js && node --check lib/feasibility-cli.js && node --check lib/governor-chokepoint-persisted.js && node --check lib/governor-chokepoint.js && node --check lib/governor-kill-switch.js && node --check lib/governor-ledger-cli.js && node --check lib/governor-ledger.js && node --check lib/governor-metrics-cli.js && node --check lib/governor-pause-cli.js && node --check lib/governor-run-halt.js && node --check lib/governor-state.js && node --check lib/harness-submission-trigger.js && node --check lib/init-wizard.js && node --check lib/laptop-init.js && node --check lib/live-issue-snapshot.js && node --check lib/local-store.js && node --check lib/logger.js && node --check lib/loop-cli.js && node --check lib/loop-closure.js && node --check lib/loop-reentry.js && node --check lib/manage-poll.js && node --check lib/manage-status.js && node --check lib/metrics-cli.js && node --check lib/miner-goal-spec.js && node --check lib/opportunity-fanout.js && node --check lib/opportunity-ranker.js && node --check lib/orb-export.js && node --check lib/plan-store-cli.js && node --check lib/plan-store.js && node --check lib/policy-doc-cache.js && node --check lib/policy-verdict-cache.js && node --check lib/portfolio-dashboard.js && node --check lib/portfolio-discovery.js && node --check lib/portfolio-queue-cli.js && node --check lib/portfolio-queue-manager.js && node --check lib/portfolio-queue.js && node --check lib/portfolio-queue-expiry.js && node --check lib/pr-disposition-poller.js && node --check lib/pr-number-parse.js && node --check lib/pr-outcome.js && node --check lib/prediction-ledger.js && node --check lib/pretooluse-hook.js && node --check lib/purge-cli.js && node --check lib/ranked-candidates.js && node --check lib/rejection-signal.js && node --check lib/rejection-state-machine.js && node --check lib/rejection-templates.js && node --check lib/replay-objective-anchor.js && node --check lib/replay-snapshot.js && node --check lib/replay-task-bridge.js && node --check lib/replay-task-generation.js && node --check lib/repo-clone.js && node --check lib/run-state-cli.js && node --check lib/run-state.js && node --check lib/self-review-context.js && node --check lib/sentry.js && node --check lib/slop-assessment.js && node --check lib/stack-detection.js && node --check lib/status.js && node --check lib/submission-freshness-check.js && node --check lib/update-check.js && node --check lib/version.js && node --check lib/worktree-allocator.js" + "build": "node --check bin/loopover-miner.js && node --check bin/loopover-miner-mcp.js && node --check lib/ams-policy.js && node --check lib/attempt-cli.js && node --check lib/attempt-input-builder.js && node --check lib/attempt-log.js && node --check lib/attempt-runner.js && node --check lib/attempt-worktree.js && node --check lib/calibration-run.js && node --check lib/calibration-types.js && node --check lib/calibration.js && node --check lib/chat-action-dispatch.js && node --check lib/chat-action-registry.js && node --check lib/chat-discover-attempt-actions.js && node --check lib/chat-governor-actions.js && node --check lib/chat-portfolio-actions.js && node --check lib/ci-poller.js && node --check lib/claim-adjudication.js && node --check lib/claim-conflict-resolver.js && node --check lib/claim-ledger-cli.js && node --check lib/claim-ledger-expiry.js && node --check lib/claim-ledger.js && node --check lib/cli.js && node --check lib/coding-agent-construction.js && node --check lib/coding-agent-house-rules.js && node --check lib/coding-task-spec.js && node --check lib/cross-repo-evaluation.js && node --check lib/deny-check.js && node --check lib/deny-hook-synthesis.js && node --check lib/deny-hooks.js && node --check lib/deployment-docs-audit.js && node --check lib/discover-cli.js && node --check lib/event-ledger-cli.js && node --check lib/event-ledger.js && node --check lib/execute-local-write.js && node --check lib/feasibility-cli.js && node --check lib/governor-chokepoint-persisted.js && node --check lib/governor-chokepoint.js && node --check lib/governor-kill-switch.js && node --check lib/governor-ledger-cli.js && node --check lib/governor-ledger.js && node --check lib/governor-metrics-cli.js && node --check lib/governor-pause-cli.js && node --check lib/governor-run-halt.js && node --check lib/governor-state.js && node --check lib/harness-submission-trigger.js && node --check lib/init-wizard.js && node --check lib/laptop-init.js && node --check lib/live-issue-snapshot.js && node --check lib/local-store.js && node --check lib/logger.js && node --check lib/loop-cli.js && node --check lib/loop-closure.js && node --check lib/loop-reentry.js && node --check lib/manage-poll.js && node --check lib/manage-status.js && node --check lib/metrics-cli.js && node --check lib/miner-goal-spec.js && node --check lib/opportunity-fanout.js && node --check lib/opportunity-ranker.js && node --check lib/orb-export.js && node --check lib/plan-store-cli.js && node --check lib/plan-store.js && node --check lib/policy-doc-cache.js && node --check lib/policy-verdict-cache.js && node --check lib/portfolio-dashboard.js && node --check lib/portfolio-discovery.js && node --check lib/portfolio-queue-cli.js && node --check lib/portfolio-queue-manager.js && node --check lib/portfolio-queue.js && node --check lib/portfolio-queue-expiry.js && node --check lib/pr-disposition-poller.js && node --check lib/pr-number-parse.js && node --check lib/pr-outcome.js && node --check lib/prediction-ledger.js && node --check lib/pretooluse-hook.js && node --check lib/purge-cli.js && node --check lib/ranked-candidates.js && node --check lib/rejection-signal.js && node --check lib/rejection-state-machine.js && node --check lib/rejection-templates.js && node --check lib/replay-objective-anchor.js && node --check lib/replay-snapshot.js && node --check lib/replay-task-bridge.js && node --check lib/replay-task-generation.js && node --check lib/repo-clone.js && node --check lib/run-state-cli.js && node --check lib/run-state.js && node --check lib/self-review-context.js && node --check lib/sentry.js && node --check lib/slop-assessment.js && node --check lib/stack-detection.js && node --check lib/status.js && node --check lib/submission-freshness-check.js && node --check lib/update-check.js && node --check lib/version.js && node --check lib/worktree-allocator.js" }, "dependencies": { "@loopover/engine": "^3.0.0", diff --git a/test/unit/miner-chat-discover-attempt-actions.test.ts b/test/unit/miner-chat-discover-attempt-actions.test.ts new file mode 100644 index 0000000000..6702b4d803 --- /dev/null +++ b/test/unit/miner-chat-discover-attempt-actions.test.ts @@ -0,0 +1,213 @@ +import { describe, expect, it, vi } from "vitest"; + +// governor-chokepoint.js (imported transitively by chat-action-registry.js) pulls in @loopover/engine, whose +// dist is not built in the test workspace -- resolve it against source, matching the sibling miner tests. +vi.mock("@loopover/engine", async () => { + return import("../../packages/loopover-engine/src/index"); +}); + +import { + CHAT_ACTION_DISPATCH_ENABLE_VALUE, + CHAT_ACTION_DISPATCH_FLAG, + dispatchChatAction, +} from "../../packages/loopover-miner/lib/chat-action-dispatch.js"; +import { chatActionRegistry, createChatActionRegistry } from "../../packages/loopover-miner/lib/chat-action-registry.js"; +import { + ATTEMPT_CHAT_ACTION, + DISCOVER_CHAT_ACTION, + isAttemptChatParams, + isDiscoverChatParams, + registerDiscoverAttemptChatActions, +} from "../../packages/loopover-miner/lib/chat-discover-attempt-actions.js"; + +const enabledEnv = { [CHAT_ACTION_DISPATCH_FLAG]: CHAT_ACTION_DISPATCH_ENABLE_VALUE }; + +const attemptParams = { repoFullName: "acme/widgets", issueNumber: 12, minerLogin: "miner" }; +const okResult = { ok: true, result: { outcome: "submitted" }, exitCode: 0 }; + +type DiscoverInput = { targets?: string[]; search?: string; dryRun?: boolean; json?: boolean; apiBaseUrl?: string; tokenEnv?: string }; +type AttemptInput = { repoFullName: string; issueNumber: number; minerLogin: string; base?: string; live?: boolean; dryRun?: boolean; json?: boolean }; + +function setup(over: Partial[0]> = {}) { + const registry = createChatActionRegistry(); + // Parameter-typed so `mock.calls[0][0]` is inspectable -- an untyped vi.fn infers a zero-length tuple. + const requestDiscover = vi.fn(async (_input: DiscoverInput) => okResult); + const requestAttempt = vi.fn(async (_input: AttemptInput) => okResult); + registerDiscoverAttemptChatActions({ registry, requestDiscover, requestAttempt, ...over }); + return { registry, requestDiscover, requestAttempt }; +} + +describe("isDiscoverChatParams (#6837)", () => { + it("accepts nullish and an empty object as 'discover with defaults'", () => { + // Every DiscoverActionInput field is optional (the CLI defaults them all), unlike attempt. + expect(isDiscoverChatParams(undefined)).toBe(true); + expect(isDiscoverChatParams(null)).toBe(true); + expect(isDiscoverChatParams({})).toBe(true); + }); + + it("accepts a fully specified input", () => { + expect( + isDiscoverChatParams({ + targets: ["acme/widgets"], + search: "label:bug", + dryRun: true, + json: true, + apiBaseUrl: "https://api.github.com", + tokenEnv: "GITHUB_TOKEN", + }), + ).toBe(true); + }); + + it("rejects a non-object params value", () => { + expect(isDiscoverChatParams("acme/widgets")).toBe(false); + expect(isDiscoverChatParams([])).toBe(false); + expect(isDiscoverChatParams(42)).toBe(false); + }); + + it("rejects a malformed targets list", () => { + expect(isDiscoverChatParams({ targets: "acme/widgets" })).toBe(false); + expect(isDiscoverChatParams({ targets: [42] })).toBe(false); + expect(isDiscoverChatParams({ targets: [""] })).toBe(false); + expect(isDiscoverChatParams({ targets: [] })).toBe(true); // empty list is a valid explicit "no targets" + }); + + it("rejects wrong-typed string and boolean fields", () => { + expect(isDiscoverChatParams({ search: 42 })).toBe(false); + expect(isDiscoverChatParams({ apiBaseUrl: 42 })).toBe(false); + expect(isDiscoverChatParams({ tokenEnv: 42 })).toBe(false); + expect(isDiscoverChatParams({ dryRun: "yes" })).toBe(false); + expect(isDiscoverChatParams({ json: "yes" })).toBe(false); + }); + + it("rejects an unknown key rather than ignoring it", () => { + // Model-authored params: a typo'd flag must fail loudly, not silently run a different discovery. + expect(isDiscoverChatParams({ dry_run: true })).toBe(false); + expect(isDiscoverChatParams({ targets: ["acme/widgets"], limit: 5 })).toBe(false); + }); +}); + +describe("isAttemptChatParams (#6837)", () => { + it("accepts the required trio, with and without the optional fields", () => { + expect(isAttemptChatParams(attemptParams)).toBe(true); + expect(isAttemptChatParams({ ...attemptParams, base: "main", live: true, dryRun: false, json: true })).toBe(true); + }); + + it("rejects nullish and non-objects: there is no default issue to attempt", () => { + expect(isAttemptChatParams(undefined)).toBe(false); + expect(isAttemptChatParams(null)).toBe(false); + expect(isAttemptChatParams([attemptParams])).toBe(false); + }); + + it("rejects a missing or empty required field", () => { + expect(isAttemptChatParams({ issueNumber: 12, minerLogin: "miner" })).toBe(false); + expect(isAttemptChatParams({ repoFullName: "acme/widgets", minerLogin: "miner" })).toBe(false); + expect(isAttemptChatParams({ repoFullName: "acme/widgets", issueNumber: 12 })).toBe(false); + expect(isAttemptChatParams({ ...attemptParams, repoFullName: " " })).toBe(false); + expect(isAttemptChatParams({ ...attemptParams, minerLogin: "" })).toBe(false); + }); + + it("rejects an issueNumber that is not a positive integer", () => { + // A float or 0 would reach the CLI as a nonsense issue reference. + expect(isAttemptChatParams({ ...attemptParams, issueNumber: 0 })).toBe(false); + expect(isAttemptChatParams({ ...attemptParams, issueNumber: -3 })).toBe(false); + expect(isAttemptChatParams({ ...attemptParams, issueNumber: 1.5 })).toBe(false); + expect(isAttemptChatParams({ ...attemptParams, issueNumber: "12" })).toBe(false); + expect(isAttemptChatParams({ ...attemptParams, issueNumber: Number.NaN })).toBe(false); + }); + + it("rejects wrong-typed optional fields", () => { + expect(isAttemptChatParams({ ...attemptParams, base: 42 })).toBe(false); + expect(isAttemptChatParams({ ...attemptParams, live: "yes" })).toBe(false); + expect(isAttemptChatParams({ ...attemptParams, dryRun: 1 })).toBe(false); + expect(isAttemptChatParams({ ...attemptParams, json: 1 })).toBe(false); + }); + + it("rejects an unknown key rather than ignoring it", () => { + expect(isAttemptChatParams({ ...attemptParams, issue_number: 12 })).toBe(false); + }); +}); + +describe("registerDiscoverAttemptChatActions (#6837)", () => { + it("registers both actions on the supplied registry", () => { + const { registry } = setup(); + expect(registry.names().sort()).toEqual([ATTEMPT_CHAT_ACTION, DISCOVER_CHAT_ACTION].sort()); + }); + + it("throws when requestDiscover or requestAttempt is not a function", () => { + const registry = createChatActionRegistry(); + expect(() => registerDiscoverAttemptChatActions({ registry, requestAttempt: async () => okResult } as never)).toThrow( + "requestDiscover must be a function", + ); + expect(() => registerDiscoverAttemptChatActions({ registry, requestDiscover: async () => okResult } as never)).toThrow( + "requestAttempt must be a function", + ); + }); + + it("is idempotent: a second registration does not throw", () => { + const { registry, requestDiscover, requestAttempt } = setup(); + expect(() => registerDiscoverAttemptChatActions({ registry, requestDiscover, requestAttempt })).not.toThrow(); + expect(registry.size).toBe(2); + }); + + it("falls back to the shared chatActionRegistry when no registry is supplied", () => { + // The production wiring omits `registry`, so this nullish default is the path that actually ships -- every + // other test here injects an isolated registry and would never exercise it. + expect(chatActionRegistry.has(DISCOVER_CHAT_ACTION)).toBe(false); + registerDiscoverAttemptChatActions({ requestDiscover: async () => okResult, requestAttempt: async () => okResult }); + expect(chatActionRegistry.has(DISCOVER_CHAT_ACTION)).toBe(true); + expect(chatActionRegistry.has(ATTEMPT_CHAT_ACTION)).toBe(true); + }); +}); + +describe("discover/attempt chat actions through dispatchChatAction (#6837)", () => { + it("runs discover via the injected miner-ui client, forwarding the exact input", async () => { + const { registry, requestDiscover, requestAttempt } = setup(); + const input = { targets: ["acme/widgets"], dryRun: true }; + const result = await dispatchChatAction({ action: DISCOVER_CHAT_ACTION, params: input }, { registry, env: enabledEnv }); + expect(result).toMatchObject({ ok: true, status: "dispatched", action: DISCOVER_CHAT_ACTION }); + expect(result.result).toMatchObject({ ok: true, status: "executed", result: okResult }); + // Routed through the client that POSTs /api/discover -- never discover-cli.js directly. + expect(requestDiscover).toHaveBeenCalledWith(input); + expect(requestAttempt).not.toHaveBeenCalled(); + }); + + it("forwards {} for a params-less discover rather than undefined", async () => { + // The client always POSTs a JSON body; an undefined input would serialize as `undefined`, not `{}`. + const { registry, requestDiscover } = setup(); + await dispatchChatAction({ action: DISCOVER_CHAT_ACTION }, { registry, env: enabledEnv }); + expect(requestDiscover).toHaveBeenCalledWith({}); + }); + + it("runs attempt via the injected miner-ui client", async () => { + const { registry, requestAttempt, requestDiscover } = setup(); + await dispatchChatAction({ action: ATTEMPT_CHAT_ACTION, params: attemptParams }, { registry, env: enabledEnv }); + expect(requestAttempt).toHaveBeenCalledWith(attemptParams); + expect(requestDiscover).not.toHaveBeenCalled(); + }); + + it("does not run either client when the shared action flag is off", async () => { + const { registry, requestDiscover, requestAttempt } = setup(); + const result = await dispatchChatAction({ action: DISCOVER_CHAT_ACTION }, { registry, env: {} }); + expect(result).toMatchObject({ ok: false, status: "disabled" }); + expect(requestDiscover).not.toHaveBeenCalled(); + expect(requestAttempt).not.toHaveBeenCalled(); + }); + + it("does not run attempt when params fail validation", async () => { + const { registry, requestAttempt } = setup(); + const result = await dispatchChatAction( + { action: ATTEMPT_CHAT_ACTION, params: { repoFullName: "acme/widgets" } }, + { registry, env: enabledEnv }, + ); + expect(result).toMatchObject({ ok: false, status: "invalid_params" }); + expect(requestAttempt).not.toHaveBeenCalled(); + }); + + it("does not run the client when the gate denies", async () => { + // The registry brand guarantees a gate runs first; a non-allow stage must short-circuit BEFORE the write. + const { registry, requestAttempt } = setup({ evaluateGate: () => ({ decision: { stage: "deny" } }) }); + const result = await dispatchChatAction({ action: ATTEMPT_CHAT_ACTION, params: attemptParams }, { registry, env: enabledEnv }); + expect(result.result).toMatchObject({ ok: false, status: "gated", decision: { stage: "deny" } }); + expect(requestAttempt).not.toHaveBeenCalled(); + }); +});