Files
software-mansion__argent/packages/tool-server/test/variant-store-selection-bound.test.ts
Ignacy Łątka 9ce76824f6 fix(variants): roll a fresh round when proposing after a completed selection (#439)
## Defect

`VariantProposalStore`
(packages/tool-server/src/utils/variant-proposals.ts) could leave a
round in the `completed && !consumed` state, and a variant proposed in
that state was silently dropped from the next selection outcome.

`submitSelection()` sets `completed = true, consumed = false`, and only
sets `consumed = true` when there is a parked waiter to settle
(`toSettle.length > 0`). When the preview window POSTs a selection while
no `await_user_selection` is currently parked (the window lingers open
after a submit, so the user can complete a selection during the next
proposing phase, or after a pending-timeout before the agent re-awaits),
the round stays `completed && !consumed`.

The next `proposeVariant()` for a NEW element called
`autoRollIfConsumed()`, which only reset the store when `completed &&
consumed`. So it did not roll: the new element was appended to the
already-completed round. The subsequent `awaitSelection()` then hit the
fast path `if (this.completed && !this.consumed)` and returned the
frozen `lastOutcome` captured at submit time — which predates the new
element. `proposeVariant` reported success (`totalElements` incremented,
"staged"), but the element never appeared in any outcome and was never
presented for selection.

### Repro (dropped element)

- `proposeVariant("Alpha")` -> `submitSelection(Alpha)` with no awaiter
parked -> `proposeVariant("Beta")` (returns `totalElements: 2`) ->
`await awaitSelection()` resolves a completed outcome whose
`selections`/`unselected` contain "Alpha" but NOT "Beta". Beta is
silently dropped.

## Fix

Roll a fresh round whenever a proposal lands on an already-`completed`
round, not only when `completed && consumed`. The helper (renamed
`autoRollIfCompleted`) now calls `reset()` on `this.completed`, so a
post-completed proposal begins a new round and the new element is staged
into it. The legitimate submit-then-await fast path (submit with no
waiter, then await with no new proposal) is unchanged — `completed`
stays true with no intervening propose, so the frozen outcome is still
handed back and the round consumed.

With the fix, the repro above starts round 2 with "Beta" as a live
proposal; the next await presents Beta (as a pending proposal) instead
of dropping it.

## Tests

- New regression test
`packages/tool-server/test/variant-store-propose-after-submit.test.ts` —
fails before the fix (Beta dropped from the completed outcome), passes
after (Beta presented in the fresh round).
- All existing variant-proposals tests pass, including the
submit-then-await fast path and the await-then-submit consumed path.
- Full tool-server suite: 1622 passed. `tsc --build` clean; eslint 0
problems on changed files.
2026-07-03 16:19:29 +02:00

61 lines
2.7 KiB
TypeScript

import { describe, it, expect } from "vitest";
import { VariantProposalStore } from "../src/utils/variant-proposals";
// The unauthenticated submit route must not retain an unbounded number of
// selections nor an unbounded variantId in `this.submitted` — mirroring the
// annotation caps, "bound memory regardless of caller".
describe("VariantProposalStore — selection retention is bounded regardless of caller", () => {
it("dedups selections per element and caps the variantId", () => {
const s = new VariantProposalStore();
const a = s.proposeVariant({ element: "Alpha", variant: { name: "v1", summary: "s" } });
const sels: Array<{ elementId: string; variantId: string | null }> = [];
for (let i = 0; i < 50_000; i++)
sels.push({ elementId: a.elementId, variantId: "x".repeat(100) });
sels.push({ elementId: a.elementId, variantId: "y".repeat(5_000_000) });
s.submitSelection({ selections: sels });
const submitted = (
s as unknown as {
submitted: Array<{ elementId: string; variantId: string | null }>;
}
).submitted;
// Bounded to the number of distinct real proposals (1), not 50_001.
expect(submitted.length).toBe(1);
// The retained variantId is capped.
expect((submitted[0]!.variantId ?? "").length).toBeLessThanOrEqual(200);
});
it("is behavior-preserving: the first selection per element still wins", () => {
const s = new VariantProposalStore();
const a = s.proposeVariant({ element: "Alpha", variant: { name: "v1", summary: "s" } });
// First dup picks the real variant; later dups (skip / bogus) are dead weight.
s.submitSelection({
selections: [
{ elementId: a.elementId, variantId: a.variantId },
{ elementId: a.elementId, variantId: null },
{ elementId: a.elementId, variantId: "nonexistent" },
],
});
const out = s.getLastOutcome();
expect(out?.status).toBe("completed");
const alpha = out?.selections.find((x) => x.element === "Alpha");
expect(alpha?.chosenVariant?.id).toBe(a.variantId);
});
it("keeps a selection for every distinct element (does not drop later elements)", () => {
const s = new VariantProposalStore();
const a = s.proposeVariant({ element: "Alpha", variant: { name: "v1", summary: "s" } });
const b = s.proposeVariant({ element: "Beta", variant: { name: "v1", summary: "s" } });
s.submitSelection({
selections: [
{ elementId: a.elementId, variantId: a.variantId },
{ elementId: b.elementId, variantId: b.variantId },
],
});
const out = s.getLastOutcome();
const picked = out?.selections.map((x) => x.element) ?? [];
expect(picked).toContain("Alpha");
expect(picked).toContain("Beta");
});
});