Files
software-mansion__argent/packages/tool-server/test/variant-store-propose-after-submit.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

330 lines
17 KiB
TypeScript

import { describe, it, expect } from "vitest";
import { VariantProposalStore } from "../src/utils/variant-proposals";
const variant = (name: string) => ({ name, summary: `${name} summary` });
describe("variant store: propose after a waiter-less submit", () => {
it("does not drop an element proposed after submit-without-await", async () => {
const s = new VariantProposalStore();
s.proposeVariant({ element: "Alpha", variant: variant("A1") });
const a = s.snapshot().proposals.find((p) => p.element === "Alpha")!;
// Submit with NO await parked → round becomes completed && !consumed.
s.submitSelection({ selections: [{ elementId: a.id, variantId: a.variants[0]!.id }] });
// A new element proposed during this lingering completed round must begin a
// fresh round, not vanish behind the frozen pre-submit outcome. Pre-fix,
// Beta was appended onto the already-completed round 1 (round 1,
// totalElements 2) and then dropped; the fix rolls a fresh round so Beta is
// round 2's sole element.
const b = s.proposeVariant({ element: "Beta", variant: variant("B1") });
expect(b.round).toBe(2);
expect(b.totalElements).toBe(1);
// Alpha's already-decided outcome is delivered first (it must never be
// silently discarded), then a second await surfaces Beta's fresh round.
const outs = [
await s.awaitSelection({ timeoutMs: 200 }),
await s.awaitSelection({ timeoutMs: 200 }),
];
const mentioned = new Set<string>();
for (const out of outs) {
if (out.status === "completed") {
for (const x of [...out.selections, ...out.unselected]) mentioned.add(x.element);
} else if (out.status === "pending") {
for (const x of out.proposedElements) mentioned.add(x.element);
}
}
// Beta must be accounted for somewhere (presented for selection or in the
// outcome), never silently dropped.
expect(mentioned.has("Beta")).toBe(true);
expect(mentioned.has("Alpha")).toBe(true);
});
it("delivers the already-submitted-but-undelivered outcome before the rolled round's pending state", async () => {
// Regression: the naive fix above made Beta's fresh round visible, but at
// the cost of destroying Alpha's already-submitted (and not yet retrieved)
// selection outright — reset() nulled `lastOutcome` with nothing else
// holding onto it. The agent must still get Alpha's answer; it just
// arrives on the first await, before Beta's round is presented.
const s = new VariantProposalStore();
s.proposeVariant({ element: "Alpha", variant: variant("A1") });
const a = s.snapshot().proposals.find((p) => p.element === "Alpha")!;
s.submitSelection({ selections: [{ elementId: a.id, variantId: a.variants[0]!.id }] });
s.proposeVariant({ element: "Beta", variant: variant("B1") });
const first = await s.awaitSelection({ timeoutMs: 200 });
expect(first.status).toBe("completed");
if (first.status === "completed") {
expect(first.selections.map((x) => x.element)).toContain("Alpha");
}
const second = await s.awaitSelection({ timeoutMs: 200 });
expect(second.status).toBe("pending");
if (second.status === "pending") {
expect(second.proposedElements.map((x) => x.element)).toContain("Beta");
}
});
it("still resolves normally when propose_variant is called before any submission", async () => {
// No completed round exists yet, so pendingOutcomes is empty and behavior
// is unchanged: propose, submit, await in the normal order.
const s = new VariantProposalStore();
s.proposeVariant({ element: "Alpha", variant: variant("A1") });
const a = s.snapshot().proposals.find((p) => p.element === "Alpha")!;
const pending = s.awaitSelection({ timeoutMs: 2_000 });
s.submitSelection({ selections: [{ elementId: a.id, variantId: a.variants[0]!.id }] });
const out = await pending;
expect(out.status).toBe("completed");
if (out.status === "completed") {
expect(out.selections.map((x) => x.element)).toContain("Alpha");
}
});
it("a repeat waiter-less submit does NOT deliver the outcome twice", async () => {
// The submit route is unauthenticated and the preview UI re-enables its
// Complete button, so the same round can be submitted more than once with
// no await parked. Only ONE completion may ever be delivered — the second
// await must report the round already returned, not a phantom duplicate.
const s = new VariantProposalStore();
s.proposeVariant({ element: "Alpha", variant: variant("A1") });
const a = s.snapshot().proposals.find((p) => p.element === "Alpha")!;
const sel = [{ elementId: a.id, variantId: a.variants[0]!.id }];
s.submitSelection({ selections: sel });
s.submitSelection({ selections: sel });
const first = await s.awaitSelection({ timeoutMs: 200 });
const second = await s.awaitSelection({ timeoutMs: 200 });
expect(first.status).toBe("completed");
expect(second.status).toBe("no_proposals");
});
it("a repeat submit AFTER the outcome was consumed does not resurrect it", async () => {
// submit → await (delivers + consumes) → submit again → await must NOT
// re-deliver the already-consumed outcome, and must not strand.
const s = new VariantProposalStore();
s.proposeVariant({ element: "Alpha", variant: variant("A1") });
const a = s.snapshot().proposals.find((p) => p.element === "Alpha")!;
const sel = [{ elementId: a.id, variantId: a.variants[0]!.id }];
s.submitSelection({ selections: sel });
expect((await s.awaitSelection({ timeoutMs: 200 })).status).toBe("completed");
s.submitSelection({ selections: sel });
expect((await s.awaitSelection({ timeoutMs: 200 })).status).toBe("no_proposals");
});
it("bounds the undelivered-outcome queue against a runaway waiter-less submitter", async () => {
// The submit route is unauthenticated. A caller that proposes-and-submits
// repeatedly without ever awaiting rolls a fresh round each cycle and queues
// one outcome per round; without a cap the queue grows without limit. It is
// bounded to MAX_PENDING_OUTCOMES (32), keeping the most recent decisions.
const s = new VariantProposalStore();
for (let i = 0; i < 50; i++) {
s.proposeVariant({ element: `El${i}`, variant: variant(`V${i}`) });
const p = s.snapshot().proposals[0]!;
s.submitSelection({ selections: [{ elementId: p.id, variantId: p.variants[0]!.id }] });
}
// Drain every queued outcome; count completions and note the first/last round.
let completed = 0;
let firstRound = 0;
let lastRound = 0;
for (let i = 0; i < 60; i++) {
const out = await s.awaitSelection({ timeoutMs: 50 });
if (out.status !== "completed") break;
if (completed === 0) firstRound = out.round;
lastRound = out.round;
completed++;
}
expect(completed).toBe(32); // capped, not 50
// Oldest entries were dropped; the most recent decisions are retained.
expect(firstRound).toBe(19);
expect(lastRound).toBe(50);
});
it("an already-aborted await does not drain (and lose) a queued outcome", async () => {
// The pendingOutcomes fast-path mutates state (shift()). If the caller's
// signal is already aborted (HTTP client disconnected before the request
// reached awaitSelection), draining the queue for a dead caller would
// permanently destroy the human's already-submitted selection — the exact
// loss the queue exists to prevent. It must reject with AbortError instead,
// leaving the outcome for the next live await.
const s = new VariantProposalStore();
s.proposeVariant({ element: "Alpha", variant: variant("A1") });
const a = s.snapshot().proposals[0]!;
s.submitSelection({ selections: [{ elementId: a.id, variantId: a.variants[0]!.id }] });
const ac = new AbortController();
ac.abort();
await expect(s.awaitSelection({ timeoutMs: 1_000, signal: ac.signal })).rejects.toMatchObject({
name: "AbortError",
});
// The decision survived: a fresh live await still delivers Alpha.
const retry = await s.awaitSelection({ timeoutMs: 100 });
expect(retry.status).toBe("completed");
if (retry.status === "completed") {
expect(retry.selections.map((x) => x.element)).toContain("Alpha");
}
});
it("starting a CLI session does not leak a prior waiter-less outcome into its first await", async () => {
// A queued-but-undelivered outcome from a prior (possibly non-CLI) flow
// must not surface as the CLI session's first await result.
const s = new VariantProposalStore();
s.proposeVariant({ element: "Alpha", variant: variant("A1") });
const a = s.snapshot().proposals.find((p) => p.element === "Alpha")!;
s.submitSelection({ selections: [{ elementId: a.id, variantId: a.variants[0]!.id }] });
s.setCliSession(true, [{ id: "x", name: "X" }]);
const out = await s.awaitSelection({ timeoutMs: 100 });
// The precise property is a clean start: the session reset the round and
// dropped the stale queue, so there is nothing to deliver. `not completed`
// alone would also pass for a stranded `pending`, which would be a bug.
expect(out.status).toBe("no_proposals");
});
it("delivers a submit made DURING a CLI session (consumed, nothing queued) as a clean no_proposals", async () => {
// The CLI-session submit branch marks the round consumed and deliberately
// queues nothing (the `argent lens` watcher reads the outcome over HTTP; no
// await is parked). await_user_selection is hidden during a real CLI session,
// but if it is reached it must report the round already done — never a
// phantom completion and never a strand. This pins that branch through the
// public awaitSelection surface, which no other test exercises.
const s = new VariantProposalStore();
s.setCliSession(true, [{ id: "x", name: "X" }]);
s.proposeVariant({ element: "Alpha", variant: variant("A1") });
const a = s.snapshot().proposals.find((p) => p.element === "Alpha")!;
// No await parked → the CLI-session branch runs (consumed = true, no queue).
s.submitSelection({ selections: [{ elementId: a.id, variantId: a.variants[0]!.id }] });
const out = await s.awaitSelection({ timeoutMs: 100 });
expect(out.status).toBe("no_proposals");
});
it("preserves selection/global/annotation comments across the roll on the drain path", async () => {
// The queue-drain path freezes the outcome (buildOutcome) BEFORE reset()
// clears the live comment/annotation state, then delivers that frozen copy
// after an intervening propose_variant rolls the round. Only the element
// NAME was pinned before; assert the full comment payload survives the roll,
// since the drain depends on that separate frozen object.
const s = new VariantProposalStore();
s.proposeVariant({ element: "Alpha", variant: variant("A1") });
const a = s.snapshot().proposals.find((p) => p.element === "Alpha")!;
s.submitSelection({
selections: [{ elementId: a.id, variantId: a.variants[0]!.id, comment: "make it blue" }],
globalComment: "overall: tighten spacing",
annotations: [
{ target: "Save button", match: { by: "label", value: "Save" }, comment: "too small" },
],
});
// Roll the round out from under the frozen outcome.
s.proposeVariant({ element: "Beta", variant: variant("B1") });
const first = await s.awaitSelection({ timeoutMs: 200 });
expect(first.status).toBe("completed");
if (first.status === "completed") {
const alpha = first.selections.find((x) => x.element === "Alpha");
expect(alpha?.comment).toBe("make it blue");
expect(first.globalComment).toBe("overall: tighten spacing");
expect(first.annotations).toHaveLength(1);
expect(first.annotations[0]!.comment).toBe("too small");
expect(first.annotations[0]!.match.value).toBe("Save");
}
});
it("signals morePending when a completed outcome is drained while a fresh round is live", async () => {
// A waiter-less submit is queued; a later propose rolls a fresh round. The
// drained (old-round) completed outcome must carry morePending=true so the
// agent — told to apply-and-stop on completed — awaits again instead of
// stranding the freshly-staged round.
const s = new VariantProposalStore();
s.proposeVariant({ element: "Alpha", variant: variant("A1") });
const a = s.snapshot().proposals.find((p) => p.element === "Alpha")!;
s.submitSelection({ selections: [{ elementId: a.id, variantId: a.variants[0]!.id }] });
s.proposeVariant({ element: "Beta", variant: variant("B1") });
const first = await s.awaitSelection({ timeoutMs: 200 });
expect(first.status).toBe("completed");
if (first.status === "completed") {
expect(first.round).toBe(1);
expect(first.morePending).toBe(true);
}
// The signalled round then surfaces on the next await.
const second = await s.awaitSelection({ timeoutMs: 200 });
expect(second.status).toBe("pending");
});
it("signals morePending via the queue-non-empty branch with no live round", async () => {
// Isolate the `pendingOutcomes.length > 0` disjunct: queue two outcomes
// (rounds 1 and 2), then reset() to a round with NO live proposals so the
// "fresh live round" disjunct is false. Draining the first must STILL set
// morePending — solely because a second decision is queued behind it — so
// this assertion fails if that disjunct is dropped.
const s = new VariantProposalStore();
s.proposeVariant({ element: "Alpha", variant: variant("A1") });
const a = s.snapshot().proposals.find((p) => p.element === "Alpha")!;
s.submitSelection({ selections: [{ elementId: a.id, variantId: a.variants[0]!.id }] });
s.proposeVariant({ element: "Beta", variant: variant("B1") }); // rolls to round 2
const b = s.snapshot().proposals.find((p) => p.element === "Beta")!;
s.submitSelection({ selections: [{ elementId: b.id, variantId: b.variants[0]!.id }] });
// Queue now holds rounds 1 and 2. Roll to an empty round: no live proposals,
// so only the queue-non-empty disjunct can flag morePending on the drain.
s.reset();
expect(s.snapshot().proposals).toHaveLength(0);
const first = await s.awaitSelection({ timeoutMs: 200 });
expect(first.status).toBe("completed");
if (first.status === "completed") {
expect(first.round).toBe(1);
expect(first.morePending).toBe(true);
}
const second = await s.awaitSelection({ timeoutMs: 200 });
expect(second.status).toBe("completed");
if (second.status === "completed") {
expect(second.round).toBe(2);
expect(second.morePending).toBeUndefined(); // queue empty, no live round
}
});
it("does not signal morePending on a plain single-round completion", async () => {
// The common case (submit with a waiter parked, no roll) must NOT set
// morePending — the agent should apply and stop as before.
const s = new VariantProposalStore();
s.proposeVariant({ element: "Alpha", variant: variant("A1") });
const a = s.snapshot().proposals.find((p) => p.element === "Alpha")!;
const pending = s.awaitSelection({ timeoutMs: 2_000 });
s.submitSelection({ selections: [{ elementId: a.id, variantId: a.variants[0]!.id }] });
const out = await pending;
expect(out.status).toBe("completed");
if (out.status === "completed") {
expect(out.morePending).toBeUndefined();
}
});
it("bounds a queued outcome's size: caps matcher value and annotation count", async () => {
// MAX_PENDING_OUTCOMES bounds the COUNT of queued outcomes, but each retained
// outcome must also be size-bounded: the submit route is unauthenticated, and
// an annotation's match.value was otherwise ingested uncapped and in unbounded
// number. Assert both are clamped in the frozen, queued outcome.
const s = new VariantProposalStore();
s.proposeVariant({ element: "Alpha", variant: variant("A1") });
const a = s.snapshot().proposals[0]!;
// Over-cap on both axes (cap is 200 annotations / 200-char matcher value) —
// no need for pathological sizes to prove the clamp.
const bigValue = "x".repeat(5_000);
const manyAnnotations = Array.from({ length: 1_000 }, () => ({
target: "t",
match: { by: "text" as const, value: bigValue },
comment: "c",
}));
// Waiter-less submit → the outcome is frozen and queued.
s.submitSelection({
selections: [{ elementId: a.id, variantId: a.variants[0]!.id }],
annotations: manyAnnotations,
});
const out = await s.awaitSelection({ timeoutMs: 100 });
expect(out.status).toBe("completed");
if (out.status === "completed") {
expect(out.annotations.length).toBeLessThanOrEqual(200);
expect(out.annotations[0]!.match.value.length).toBeLessThanOrEqual(200);
}
});
});