Files
open-design/apps/web/tests/components/workspace
62b7c66f3e fix(web): answer one inline question form exactly once (#7512)
* fix(web): answer one inline question form exactly once

The `<question-form>` submit lock lived only in a ref owned by the mounted
FormBlock, and a send that parked in the busy conversation's queue was
reported back to the form as refused. Leaving the project while the answer
was still settling therefore rebuilt the form as "never submitted" — and
rolled back any file it had uploaded for a send the queue still pointed at.
The user re-answered, the queue drained, and one logical task produced two
identical user messages and two assistant runs: duplicate model calls,
duplicate billing, and a reply split in two with a mid-turn action bar.

The lock now belongs to the form occurrence (project + conversation +
assistant message + form id), recorded next to the draft so it outlives the
component, and a busy conversation no longer counts as the send being
consumed. Only the answer surfacing in history, or an explicit submit
failure, re-opens the form.

* fix(web): take the form's occurrence lock before the submit, not after it

The lock was written from `acceptSubmission`, i.e. only once the host had
settled the send. That left every await in between unguarded — uploading the
answer's files, and above all the host's own send while it sits in a pre-run
gate or parks in a busy conversation's queue. Leaving the project in that
window is the reported sequence, and the remount rebuilt the form as "never
submitted": the same answer could be sent a second time and both sends ran.

The lock is now taken the moment the answer leaves the form, and every failure
path releases it. Because a submit can outlive the component that started it,
a release has to reach whichever form is mounted when it lands, or an answer
refused after the user navigated away would leave the form locked with no way
back — so the occurrence lock notifies its subscribers.

* fix(web): keep the form's occurrence lock when storage is denied

Session storage carries the lock so it also survives a reload, but a private
window or an embedded frame refuses it: the write threw, the read returned
false, and every remount there read "never submitted" — handing exactly the
duplicate submit back to the users whose context denies storage.

The lock now falls back to a page-lived set when the write throws. That set is
populated only on refusal, so a context with working storage keeps storage as
the single record and nothing outlives the tab's own cleanup.

* fix(web): identify a question-form answer by its occurrence

"At most one user answer message and one non-failed run per
sourceAssistantMessageId + formId" was only ever a property of the form's own
submit lock, so every path around that lock still doubled the answer: a second
tab, a reload where storage is denied, a queue replay.

The answer's send ids are now derived from the occurrence the way the Home
handoff already derives its own, which hands the guarantee to the layers that
already dedupe on identity — the conversation queue keys entries on
`clientRequestId`, the answer row keeps one `userMessageId` instead of
appending a second, and the daemon's `createOrReuse` returns the first run
rather than spawning another. `formId` is plumbed from the form to the host so
two forms in one assistant message stay distinct occurrences.

Also pins the invariant that keeps the two halves apart: a question form and
its answer share a `taskAnalytics` lineage by design, and folding on that
lineage would merge a form with the work its answer asked for. Only the
daemon-issued `strategyTaskRunIndex` marks a continuation carrying no prompt
of its own, which off-mode never emits.

* test(e2e): pin one answer per question-form occurrence

Covers OPEND-2367's acceptance at the daemon boundary: after a rapid double
submit, leaving the project and coming back, and a full reload, the
conversation still holds exactly one answer for the occurrence.

The docblock states its limit rather than implying more: it stays green on the
pre-fix build, because once the answer reaches the message list that build
locked the form from history too. The window this bug actually lives in — the
answer still in flight — needs the submit promise held open and is covered at
the component layer.

* fix(web): key only the run on the answer occurrence

The occurrence identity was also naming the answer's message rows, but
handleSend persists the user row before the daemon answers. A second tab
answering the same form differently would upsert the first tab's accepted
answer and still lose the run to createOrReuse, leaving a transcript showing
one answer while another actually ran.

Only clientRequestId is derived now, so a duplicate still costs at most one
run while it can no longer rewrite an accepted answer. Making the first answer
authoritative for the row too needs the check and the write at one authority;
the client cannot make that atomic before the daemon has replied.

* fix: claim a question-form answer once at the daemon

Keying only the run left the acceptance half met: a second tab still wrote a
second answer row for the same occurrence, and "at most one user answer
message" is not satisfied by calling the extra row cosmetic.

The client cannot decide this. `handleSend` persists the user row before the
daemon has answered, and no client-side read-then-write is atomic against
another tab. So the answer's row keeps its occurrence-derived id and the write
goes out `createOnly`: the daemon, where the check and the write are one
operation, keeps whichever answer landed first and returns it. The run is
claimed on the same key by `createOrReuse`, and the losing submitter adopts
the returned answer so its transcript shows what the surviving run actually
read rather than an answer no run ever saw.

`saveMessage` now returns the stored row so a caller can tell it lost.

* test(e2e): pin the concurrent occurrence claim for a question-form answer

The review's remaining worry was a cross-tab race: two submitters answering one
occurrence differently, one winning the answer row and the other the run, so
the transcript would show an answer no run consumed. This drives that race at
the daemon's HTTP boundary instead of arguing about orderings — two concurrent
POST /api/runs sharing the occurrence's clientRequestId and userMessageId, with
different answers, one of which makes the fake agent finish differently so the
terminal run reports which prompt it actually ran.

The daemon turns out to be stricter than reuse: the occupancy is a request
fingerprint too, so the divergent submitter is refused with 409
IDEMPOTENCY_CONFLICT and never creates a run at all. The spec asserts that, one
answer row carrying the occurrence's id, and that the finished run agrees with
the stored answer.

`ChatRunCreateBody` gains the `userMessageId` the daemon already accepts, so a
spec can pin the user row the way the web client does.

* fix(daemon): keep a turn's recovery lineage when a rewrite omits it

`upsertMessage` bound `task_analytics_json` from the incoming write
unconditionally, so any rewrite that carried no opinion about the field erased
it. The run-create seed is exactly such a writer: it rebuilds the user row from
the request that won the claim, with content, attachments, session mode, run
context and plugin snapshot — and no analytics. A client PUT landing first
therefore wrote the answer's lineage and the seed immediately nulled it, which
is why an accepted clarification read back with `taskAnalytics: null` and lost
the logical task it belongs to after a reload.

The UPDATE now preserves the stored value when the writer omits the field.
An explicit null still clears it, so deliberately dropping lineage stays
possible; only silence is treated as silence.

Covered both orderings — PUT before the run request and after — in the e2e
dialog spec; the first was red before this change.

---------

Co-authored-by: Cheems <94773058+itscheems@users.noreply.github.com>
2026-08-28 08:54:16 +00:00
..