From 783b5ec93563d27aad7404c196bd7e5eaf187ba0 Mon Sep 17 00:00:00 2001 From: luciferlive112116 <291889058+luciferlive112116@users.noreply.github.com> Date: Fri, 17 Jul 2026 16:18:24 +0800 Subject: [PATCH] feat(miner): wire chat action-dispatch to the existing portfolio release/requeue routes (#6838) Registers portfolio_release / portfolio_requeue into the chat-action registry the scaffolding (#6519) ships empty. Handlers call the miner-ui clients releasePortfolioQueueItem / requeuePortfolioQueueItem, so chat POSTs the same /api/portfolio-queue/{release,requeue} routes the dashboard buttons already use -- no new route, no parallel write path, no direct store access. Release/requeue is local queue administration, not a chokepoint content-write: the route is a thin bridge to the same store methods the CLI's queue release/requeue use and invokes no chokepoint itself. Gating chat more strictly than the button beside it would change the button-triggered flow #6838 freezes, so this mirrors chat-governor-actions.js exactly and satisfies the registry brand with an allow-stage evaluateGate. Execution stays behind the shared LOOPOVER_MINER_CHAT_ACTIONS flag. Closes #6838 --- .../lib/chat-portfolio-actions.d.ts | 19 ++ .../lib/chat-portfolio-actions.js | 103 ++++++++++ packages/loopover-miner/package.json | 2 +- .../unit/miner-chat-portfolio-actions.test.ts | 178 ++++++++++++++++++ 4 files changed, 301 insertions(+), 1 deletion(-) create mode 100644 packages/loopover-miner/lib/chat-portfolio-actions.d.ts create mode 100644 packages/loopover-miner/lib/chat-portfolio-actions.js create mode 100644 test/unit/miner-chat-portfolio-actions.test.ts diff --git a/packages/loopover-miner/lib/chat-portfolio-actions.d.ts b/packages/loopover-miner/lib/chat-portfolio-actions.d.ts new file mode 100644 index 0000000000..cbb7b75401 --- /dev/null +++ b/packages/loopover-miner/lib/chat-portfolio-actions.d.ts @@ -0,0 +1,19 @@ +import type { ChatActionRegistry } from "./chat-action-registry.js"; + +export const PORTFOLIO_RELEASE_CHAT_ACTION: "portfolio_release"; +export const PORTFOLIO_REQUEUE_CHAT_ACTION: "portfolio_requeue"; + +export type PortfolioChatActionItem = { + repoFullName: string; + identifier: string; + apiBaseUrl?: string; +}; + +export function isPortfolioItemChatParams(params: unknown): boolean; + +export function registerPortfolioChatActions(options: { + releaseItem: (item: PortfolioChatActionItem) => Promise; + requeueItem: (item: PortfolioChatActionItem) => Promise; + registry?: ChatActionRegistry; + evaluateGate?: () => { decision: { stage: string } }; +}): void; diff --git a/packages/loopover-miner/lib/chat-portfolio-actions.js b/packages/loopover-miner/lib/chat-portfolio-actions.js new file mode 100644 index 0000000000..5abb8d8fac --- /dev/null +++ b/packages/loopover-miner/lib/chat-portfolio-actions.js @@ -0,0 +1,103 @@ +// Portfolio release/requeue chat-action registrations (#6838). +// +// Child issue of the chat action-dispatch scaffolding (#6519). Registers `portfolio_release` / +// `portfolio_requeue` into a chat-action registry. Handlers MUST be wired to the miner-ui clients +// `releasePortfolioQueueItem` / `requeuePortfolioQueueItem` (apps/loopover-miner-ui/src/lib/ +// portfolio-queue-actions.ts), so chat POSTs the SAME `/api/portfolio-queue/{release,requeue}` routes the +// dashboard's existing buttons already call — never portfolio-queue.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. That is what keeps chat from becoming a parallel write path (#6504's design). +// +// Release/requeue is local queue administration, not a chokepoint content-write: the route it lands on is a +// thin bridge to the same store methods the CLI's `queue release` / `queue requeue` already use +// (vite-portfolio-queue-actions-api.ts → reclaimStuckItem / requeueItem), and it invokes no chokepoint of its +// own. Requiring one only for the chat path would gate chat MORE strictly than the button beside it, which +// #6838 forbids ("No changes to the existing route or button-triggered flow"). So, exactly like +// chat-governor-actions.js's administrative pause/resume, we satisfy the registry's `governorGatedHandler` +// brand with an allow-stage evaluateGate rather than routing through governor-chokepoint.js. Execution still +// stays behind the shared LOOPOVER_MINER_CHAT_ACTIONS flag via `dispatchChatAction`. + +import { governorGatedHandler, chatActionRegistry } from "./chat-action-registry.js"; + +export const PORTFOLIO_RELEASE_CHAT_ACTION = "portfolio_release"; +export const PORTFOLIO_REQUEUE_CHAT_ACTION = "portfolio_requeue"; + +/** Local queue administration is not a chokepoint content-write (#6838); satisfy the registry brand only. */ +const allowAdministrativeGate = () => ({ decision: { stage: "allow" } }); + +/** + * Params for both actions: the queue item to act on. `repoFullName` + `identifier` are required non-empty + * strings; `apiBaseUrl` is optional (the route defaults it, mirroring the client's own + * `Pick` shape, where the buttons + * always pass one but the CLI path does not). Unknown keys are rejected rather than ignored: a typo'd param + * from a model-authored call must fail loudly, not silently act on the wrong item. + * + * @param {unknown} params + * @returns {boolean} + */ +export function isPortfolioItemChatParams(params) { + if (params == null || typeof params !== "object" || Array.isArray(params)) return false; + const record = /** @type {Record} */ (params); + for (const key of Object.keys(record)) { + if (key !== "repoFullName" && key !== "identifier" && key !== "apiBaseUrl") return false; + } + if (typeof record.repoFullName !== "string" || record.repoFullName.trim() === "") return false; + if (typeof record.identifier !== "string" || record.identifier.trim() === "") return false; + if (record.apiBaseUrl !== undefined && typeof record.apiBaseUrl !== "string") return false; + return true; +} + +/** + * Narrow validated params to the client's item shape. `apiBaseUrl` is only forwarded when present, so an + * omitted one stays omitted rather than becoming an explicit `undefined` in the POST body. + * + * @param {unknown} params + * @returns {{ repoFullName: string, identifier: string, apiBaseUrl?: string }} + */ +function readPortfolioItem(params) { + const record = /** @type {{ repoFullName: string, identifier: string, apiBaseUrl?: unknown }} */ (params); + const item = { repoFullName: record.repoFullName, identifier: record.identifier }; + return typeof record.apiBaseUrl === "string" ? { ...item, apiBaseUrl: record.apiBaseUrl } : item; +} + +/** + * Idempotently register `portfolio_release` / `portfolio_requeue`. + * + * @param {{ + * releaseItem: (item: { repoFullName: string, identifier: string, apiBaseUrl?: string }) => Promise, + * requeueItem: (item: { repoFullName: string, identifier: string, apiBaseUrl?: string }) => Promise, + * registry?: import("./chat-action-registry.js").ChatActionRegistry, + * evaluateGate?: () => { decision: { stage: string } }, + * }} options + */ +export function registerPortfolioChatActions(options) { + const releaseItem = options?.releaseItem; + const requeueItem = options?.requeueItem; + if (typeof releaseItem !== "function") { + throw new TypeError("registerPortfolioChatActions: releaseItem must be a function"); + } + if (typeof requeueItem !== "function") { + throw new TypeError("registerPortfolioChatActions: requeueItem must be a function"); + } + + const registry = options.registry ?? chatActionRegistry; + const evaluateGate = options.evaluateGate ?? allowAdministrativeGate; + + if (!registry.has(PORTFOLIO_RELEASE_CHAT_ACTION)) { + registry.register(PORTFOLIO_RELEASE_CHAT_ACTION, { + paramsValidator: isPortfolioItemChatParams, + handler: governorGatedHandler(async (request) => releaseItem(readPortfolioItem(request?.params)), { + evaluateGate, + }), + }); + } + + if (!registry.has(PORTFOLIO_REQUEUE_CHAT_ACTION)) { + registry.register(PORTFOLIO_REQUEUE_CHAT_ACTION, { + paramsValidator: isPortfolioItemChatParams, + handler: governorGatedHandler(async (request) => requeueItem(readPortfolioItem(request?.params)), { + evaluateGate, + }), + }); + } +} diff --git a/packages/loopover-miner/package.json b/packages/loopover-miner/package.json index 357aa6b8f4..d6ea4bbaf6 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/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-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-portfolio-actions.test.ts b/test/unit/miner-chat-portfolio-actions.test.ts new file mode 100644 index 0000000000..e112e407b9 --- /dev/null +++ b/test/unit/miner-chat-portfolio-actions.test.ts @@ -0,0 +1,178 @@ +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 { + isPortfolioItemChatParams, + PORTFOLIO_RELEASE_CHAT_ACTION, + PORTFOLIO_REQUEUE_CHAT_ACTION, + registerPortfolioChatActions, +} from "../../packages/loopover-miner/lib/chat-portfolio-actions.js"; + +const enabledEnv = { [CHAT_ACTION_DISPATCH_FLAG]: CHAT_ACTION_DISPATCH_ENABLE_VALUE }; + +const item = { repoFullName: "acme/widgets", identifier: "issue:12", apiBaseUrl: "https://api.github.com" }; +const released = { ok: true, entry: { repoFullName: "acme/widgets", identifier: "issue:12", status: "queued" } }; + +type ChatItem = { repoFullName: string; identifier: string; apiBaseUrl?: string }; + +function setup(over: Partial[0]> = {}) { + const registry = createChatActionRegistry(); + // Typed params (rather than `vi.fn(async () => …)`) so `mock.calls[0][0]` is a real, inspectable argument -- + // the untyped form infers a zero-length tuple and the forwarded item can't be asserted on. + const releaseItem = vi.fn(async (_item: ChatItem) => released); + const requeueItem = vi.fn(async (_item: ChatItem) => released); + registerPortfolioChatActions({ registry, releaseItem, requeueItem, ...over }); + return { registry, releaseItem, requeueItem }; +} + +describe("isPortfolioItemChatParams (#6838)", () => { + it("accepts a full item and an item without the optional apiBaseUrl", () => { + expect(isPortfolioItemChatParams(item)).toBe(true); + expect(isPortfolioItemChatParams({ repoFullName: "acme/widgets", identifier: "issue:12" })).toBe(true); + }); + + it("rejects a missing, non-object, or array params value", () => { + // Unlike governor pause/resume, these actions have REQUIRED params: there is no sensible default item to + // act on, so nullish must not silently resolve to one. + expect(isPortfolioItemChatParams(null)).toBe(false); + expect(isPortfolioItemChatParams(undefined)).toBe(false); + expect(isPortfolioItemChatParams("acme/widgets")).toBe(false); + expect(isPortfolioItemChatParams([item])).toBe(false); + }); + + it("rejects a missing or empty repoFullName / identifier", () => { + expect(isPortfolioItemChatParams({ identifier: "issue:12" })).toBe(false); + expect(isPortfolioItemChatParams({ repoFullName: "acme/widgets" })).toBe(false); + expect(isPortfolioItemChatParams({ repoFullName: "", identifier: "issue:12" })).toBe(false); + expect(isPortfolioItemChatParams({ repoFullName: "acme/widgets", identifier: " " })).toBe(false); + }); + + it("rejects a non-string repoFullName / identifier / apiBaseUrl", () => { + expect(isPortfolioItemChatParams({ repoFullName: 42, identifier: "issue:12" })).toBe(false); + expect(isPortfolioItemChatParams({ repoFullName: "acme/widgets", identifier: 12 })).toBe(false); + expect(isPortfolioItemChatParams({ ...item, apiBaseUrl: 42 })).toBe(false); + }); + + it("rejects an unknown key rather than ignoring it", () => { + // A model-authored call that typos a param must fail loudly, not act on a different item than intended. + expect(isPortfolioItemChatParams({ ...item, status: "done" })).toBe(false); + expect(isPortfolioItemChatParams({ ...item, repo_full_name: "acme/other" })).toBe(false); + }); +}); + +describe("registerPortfolioChatActions (#6838)", () => { + it("registers both actions on the supplied registry", () => { + const { registry } = setup(); + expect(registry.names().sort()).toEqual([PORTFOLIO_RELEASE_CHAT_ACTION, PORTFOLIO_REQUEUE_CHAT_ACTION].sort()); + }); + + it("throws when releaseItem or requeueItem is not a function", () => { + const registry = createChatActionRegistry(); + expect(() => registerPortfolioChatActions({ registry, requeueItem: async () => released } as never)).toThrow( + "releaseItem must be a function", + ); + expect(() => registerPortfolioChatActions({ registry, releaseItem: async () => released } as never)).toThrow( + "requeueItem must be a function", + ); + }); + + it("is idempotent: a second registration does not throw on the already-registered name", () => { + const { registry, releaseItem, requeueItem } = setup(); + expect(() => registerPortfolioChatActions({ registry, releaseItem, requeueItem })).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(PORTFOLIO_RELEASE_CHAT_ACTION)).toBe(false); + registerPortfolioChatActions({ releaseItem: async () => released, requeueItem: async () => released }); + expect(chatActionRegistry.has(PORTFOLIO_RELEASE_CHAT_ACTION)).toBe(true); + expect(chatActionRegistry.has(PORTFOLIO_REQUEUE_CHAT_ACTION)).toBe(true); + }); + + it("registers handlers the registry accepts as governor-gated", () => { + // The registry rejects any raw handler, so a successful register() IS the proof the brand is present. + const { registry } = setup(); + expect(registry.has(PORTFOLIO_RELEASE_CHAT_ACTION)).toBe(true); + expect(registry.has(PORTFOLIO_REQUEUE_CHAT_ACTION)).toBe(true); + }); +}); + +describe("portfolio chat actions through dispatchChatAction (#6838)", () => { + it("releases via the injected miner-ui client, forwarding the exact item", async () => { + const { registry, releaseItem, requeueItem } = setup(); + const result = await dispatchChatAction( + { action: PORTFOLIO_RELEASE_CHAT_ACTION, params: item }, + { registry, env: enabledEnv }, + ); + // dispatchChatAction wraps the handler's own result: the outer envelope reports the dispatch, the inner + // one reports the gate verdict + the client's return value. + expect(result).toMatchObject({ ok: true, status: "dispatched", action: PORTFOLIO_RELEASE_CHAT_ACTION }); + expect(result.result).toMatchObject({ ok: true, status: "executed", result: released }); + // Routed through the client that POSTs /api/portfolio-queue/release -- never the store directly. + expect(releaseItem).toHaveBeenCalledWith(item); + expect(requeueItem).not.toHaveBeenCalled(); + }); + + it("requeues via the injected miner-ui client", async () => { + const { registry, releaseItem, requeueItem } = setup(); + await dispatchChatAction({ action: PORTFOLIO_REQUEUE_CHAT_ACTION, params: item }, { registry, env: enabledEnv }); + expect(requeueItem).toHaveBeenCalledWith(item); + expect(releaseItem).not.toHaveBeenCalled(); + }); + + it("omits apiBaseUrl from the forwarded item when it was not supplied", async () => { + // Not passed as an explicit `undefined`: the client spreads the item into the POST body, so a stray key + // would serialize as `"apiBaseUrl": undefined` and change the request the buttons already send. + const { registry, releaseItem } = setup(); + await dispatchChatAction( + { action: PORTFOLIO_RELEASE_CHAT_ACTION, params: { repoFullName: "acme/widgets", identifier: "issue:12" } }, + { registry, env: enabledEnv }, + ); + // toHaveBeenCalledWith uses toEqual semantics, which treat an explicit `undefined` key as absent -- so the + // key list is asserted directly, since that is the exact thing this test exists to pin. + expect(Object.keys(releaseItem.mock.calls[0]![0])).toEqual(["repoFullName", "identifier"]); + }); + + it("does not run the client when the shared action flag is off", async () => { + const { registry, releaseItem } = setup(); + const result = await dispatchChatAction({ action: PORTFOLIO_RELEASE_CHAT_ACTION, params: item }, { registry, env: {} }); + expect(result).toMatchObject({ ok: false }); + expect(releaseItem).not.toHaveBeenCalled(); + }); + + it("does not run the client when params fail validation", async () => { + const { registry, releaseItem } = setup(); + const result = await dispatchChatAction( + { action: PORTFOLIO_RELEASE_CHAT_ACTION, params: { repoFullName: "acme/widgets" } }, + { registry, env: enabledEnv }, + ); + expect(result).toMatchObject({ ok: false }); + expect(releaseItem).not.toHaveBeenCalled(); + }); + + it("does not run the client when the gate denies", async () => { + // The registry's brand guarantees every handler consults a gate first; a non-allow stage must short-circuit + // BEFORE the write, not report a gated result after performing it. + const { registry, releaseItem } = setup({ evaluateGate: () => ({ decision: { stage: "deny" } }) }); + const result = await dispatchChatAction( + { action: PORTFOLIO_RELEASE_CHAT_ACTION, params: item }, + { registry, env: enabledEnv }, + ); + // The dispatch itself still succeeds -- it is the HANDLER's inner result that reports the refusal. + expect(result.result).toMatchObject({ ok: false, status: "gated", decision: { stage: "deny" } }); + expect(releaseItem).not.toHaveBeenCalled(); + }); +});