diff --git a/client/src/hooks/SSE/useResumableSSE.ts b/client/src/hooks/SSE/useResumableSSE.ts index ce4a1ab85a..5a572a8bb5 100644 --- a/client/src/hooks/SSE/useResumableSSE.ts +++ b/client/src/hooks/SSE/useResumableSSE.ts @@ -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(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(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; diff --git a/packages/api/src/agents/activityLabels/host.ts b/packages/api/src/agents/activityLabels/host.ts index 014a0bf1dc..bef8bc4f0b 100644 --- a/packages/api/src/agents/activityLabels/host.ts +++ b/packages/api/src/agents/activityLabels/host.ts @@ -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,