🎯 fix: Key Prefix State to the Generation and Resolve the Run Model

The cleared-prefix state was keyed to the stream id, which never changes
within a conversation: request.js sets streamId = conversationId, so once a
reconnect cleared the state every later edited resubmission in that
conversation dispatched run steps and labels with no offset and could
overwrite the prefix it retained. It is now keyed to the response message
id, the only per-generation identity available here -- minted per
submission and carried through a resume unchanged.

That is the third identity tried for this state. isResume missed the
deduplicated-retry path (a lost response returns resumed: true for a new
generation); the stream id is conversation-scoped. The response id is the
boundary that actually matches a generation.

current_model labels now resolve the model the run is really using.
initializeAgent merges the request's endpointOption override into
model_parameters and the run gives it precedence, so preferring the saved
agent.model could send labels to a different, potentially unavailable or
more expensive model than the conversation is on.
This commit is contained in:
Danny Avila 2026-07-27 17:59:47 -04:00
parent 8ed6947307
commit af2c838708
2 changed files with 24 additions and 14 deletions

View file

@ -557,9 +557,11 @@ export default function useResumableSSE(
* `editPrefixLength` must no longer be applied by run steps or labels.
*/
const editPrefixClearedRef = useRef(false);
/** Stream the cleared-prefix state above belongs to, so it is dropped when
* the generation changes rather than when a subscribe happens to be live. */
const prefixStateStreamIdRef = useRef<string | null>(null);
/** Generation the cleared-prefix state above belongs to, so it is dropped
* when a new generation starts rather than when a subscribe happens to be
* live. Keyed by response message id the stream id is the conversation
* id and is therefore shared by every generation within it. */
const prefixStateGenerationIdRef = useRef<string | null>(null);
/** Removes the pending chip once its steer is injected (the inline content
* part becomes the durable record), and records the id so a 202 ACK that
@ -705,15 +707,19 @@ export default function useResumableSSE(
* would otherwise dispatch with no offset and overwrite the content it
* kept.
*
* Keyed on the STREAM, not on `isResume`: a submission whose POST
* succeeded server-side but lost its response is retried, comes back
* `resumed: true`, and subscribes in resume mode despite being a new
* generation so an `isResume` check skips the reset exactly when it
* is needed. The stream id changes with the generation and does not
* change across reconnects of one, which is the boundary that matters.
* Keyed on the RESPONSE MESSAGE id the only per-generation identity
* available here. Not `isResume`: a submission whose POST succeeded but
* lost its response is retried, returns `resumed: true`, and subscribes
* in resume mode despite being a new generation. And not the stream id:
* `request.js` sets `streamId = conversationId`, so every generation in
* a conversation shares it and the state would never clear. The
* response id is minted per submission and is carried through a resume
* unchanged, which is exactly the boundary that matters.
*/
if (prefixStateStreamIdRef.current !== currentStreamId) {
prefixStateStreamIdRef.current = currentStreamId;
const generationId =
(currentSubmission.initialResponse as TMessage | undefined)?.messageId ?? currentStreamId;
if (prefixStateGenerationIdRef.current !== generationId) {
prefixStateGenerationIdRef.current = generationId;
editPrefixClearedRef.current = false;
}
let { userMessage } = currentSubmission;

View file

@ -189,11 +189,15 @@ export async function resolveActivityLabelModel({
activity.model != null && activity.model !== Constants.CURRENT_MODEL
? activity.model
: undefined;
/** `model_parameters.model` FIRST: `initializeAgent` merges the request's
* `endpointOption` override into it and the run itself gives it precedence,
* so the saved `agent.model` can be a stale or entirely different model.
* Reading it first is what makes "current model" mean the model the
* conversation is actually running on. */
const runModel = agent.model_parameters?.model ?? agent.model;
const model =
activityModel ??
(titleModel != null && titleModel !== Constants.CURRENT_MODEL
? titleModel
: (agent.model ?? agent.model_parameters?.model));
(titleModel != null && titleModel !== Constants.CURRENT_MODEL ? titleModel : runModel);
const options = await providerConfig.getOptions({
req,
endpoint,