🔑 fix: Key Prefix State to the Stream and Honor current_model for Labels

The cleared-prefix reset keyed on isResume, which skips exactly the case it
was added for: a submission whose POST succeeded server-side but lost its
response is retried, comes back resumed: true, and subscribes in resume
mode even though it is a NEW generation. A previous generation's cleared
state then survived into it, and incoming run steps and labels applied no
offset against content that still held its retained prefix. The state is
now keyed to the stream id, which changes with the generation and stays put
across reconnects of one.

activityModel now honors current_model. The options are documented as
title-shaped and the titleModel fallback already excludes the sentinel, but
the higher-precedence activity override passed the literal string through to
getOptions and the provider, so an endpoint following that convention failed
every label instead of using the agent model.
This commit is contained in:
Danny Avila 2026-07-27 09:20:40 -04:00
parent d8876a194f
commit 652657bf8e
2 changed files with 24 additions and 5 deletions

View file

@ -557,6 +557,9 @@ 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);
/** 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
@ -699,11 +702,18 @@ export default function useResumableSSE(
* A NEW generation starts with its retained prefix intact, so the
* cleared-prefix state from a previous one must not carry over the
* hook outlives any single submission, and a later edited resubmission
* in the same chat would otherwise be dispatched with no offset and
* overwrite the content it kept. Reconnects pass `isResume`, so the
* state survives exactly where it should: within one generation.
* 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.
*/
if (!isResume) {
if (prefixStateStreamIdRef.current !== currentStreamId) {
prefixStateStreamIdRef.current = currentStreamId;
editPrefixClearedRef.current = false;
}
let { userMessage } = currentSubmission;

View file

@ -180,8 +180,17 @@ export async function resolveActivityLabelModel({
providerConfig.customEndpointConfig,
'titleModel',
);
/** `current_model` means "the agent's model" for BOTH overrides. The
* activity options are documented as title-shaped, so an `activityModel`
* set to the sentinel must resolve the same way `titleModel` does passing
* the literal through would send `model: "current_model"` to the provider
* and fail every label. */
const activityModel =
activity.model != null && activity.model !== Constants.CURRENT_MODEL
? activity.model
: undefined;
const model =
activity.model ??
activityModel ??
(titleModel != null && titleModel !== Constants.CURRENT_MODEL
? titleModel
: (agent.model ?? agent.model_parameters?.model));