feat(streaming): add semantic text output disposition events - #3013
feat(streaming): add semantic text output disposition events#3013dargoner wants to merge 20 commits into
Conversation
Codecov Report❌ Patch coverage is 📢 Thoughts on this report? Let us know! |
f0f2f08 to
76413d6
Compare
|
CLA Not Signed The Contributor License Agreement (CLA) check is currently pending on this PR ( @dargoner please sign the CLA via the CLA assistant badge in the comment above, or visit https://cla-assistant.io/agentscope-ai/agentscope-java. Once signed, the Automated check by github-manager-bot |
oss-maintainer
left a comment
There was a problem hiding this comment.
Summary
The TextOutputDispositionEvent layer is a well-motivated addition: keeping TextDeltaEvent intact and adding an opt-in INTERMEDIATE/TERMINAL classification (with per-source + taskId isolation and per-subscription state) is the right shape, and the Jackson subtype registration plus the dedicated tests are appreciated. The blocking concerns are the lifecycle of the buffered top-level AgentEndEvent, the fail-open success inference, and the unbounded tracker state.
Verdict: comment-only for this round — two critical correctness items (inline) plus a scope request. Not approving.
Findings
- [Critical]
AgentEventStreams.java:66—IllegalStateExceptionon a late event aborts the wrapped stream viaconcatMap, turning an ordering anomaly into a user-visible failure of an opt-in, purely additive signal. - [Critical]
AgentEventStreams.java:127— buffering the top-levelAgentEndEventuntilcomplete()(line 144) means the end event is dropped when the source errors and never arrives if the stream does not terminate; emitting[terminal, end]inline (as the child branch already does) removes both hazards. - [Warning]
AgentEventStreams.java:176—outcome == nullcounts as success, so any end withoutMETADATA_INVOCATION_OUTCOME(custom/forwarded subagents, replayed state) can yield aTERMINAL. - [Warning]
ReplyLifecycleTracker.java:140—computeIfAbsentplus no eviction on child end growsstateswithout bound, and each entry pinslastResult. - [Warning]
AgentEvent.java:40— a new@JsonSubTypesentry is a wire change; consumers on an older core fail on unknown type id, which conflicts with the "ignore it safely" compatibility claim. - [Info]
FinalAnswerFilterMiddleware.java:73— shared tracker now records unusedlastResultstate and widens reply-id recognition; needs its own guard-rail test.
Scope — please split this PR
This branch carries 187 commits, 249 files and ~34.4k additions, and includes substantial work unrelated to text-output disposition: skill/evolution/* (new SkillArtifactMaterializer, CanonicalSkillHasher), OtelTracingMiddleware changes, AguiResumeCoordinator rework, ReActAgent per-session-state and structured-output tests, sandbox/McpJsonDefaults fixes, and more. A reviewer cannot meaningfully approve a feature hidden inside that volume, and any regression cannot be attributed or reverted cleanly. Could you rebase this onto current main and keep only the disposition events, wrapper, tracker, the AG-UI/agent-protocol wiring, and their tests? The remaining changes belong in their own PRs.
mergeable_state is currently blocked, and the license/cla status is pending — the CLA reminder is posted separately. Once the CLA is signed and the scope is reduced, I am happy to re-review the trimmed diff.
Automated review by github-manager-bot
|
|
||
| private List<AgentEvent> process(AgentEvent event) { | ||
| SourceKey sourceKey = tracker.sourceKey(event); | ||
| if (endedSources.contains(sourceKey)) { |
There was a problem hiding this comment.
Failing the whole stream with IllegalStateException when an event arrives after AgentEndEvent is a risky default for an opt-in, purely additive signal. process() runs inside concatMap, so the throw is translated into onError for the subscriber: a consumer that previously saw the remaining events now sees the invocation abort, and the buffered top-level AgentEndEvent in pendingTopLevelEnds is never flushed. Since event ordering across subagent / concurrent-task producers is not guaranteed today (e.g. a forwarded child end can precede a trailing parent event), I'd make this lenient — log at debug/warn and pass the event through — or gate strictness behind an explicit failOnLateEvent flag that defaults to off. The test rejectsEventsAfterTopLevelEndWithoutLeakingHeldEvents currently locks in the fail-fast contract; worth confirming that contract is intentional for production consumers, not just for the unit test.
| private List<AgentEvent> onAgentEnd(AgentEndEvent event, Observation observation) { | ||
| SourceKey sourceKey = observation.sourceKey(); | ||
| endedSources.add(sourceKey); | ||
| if (sourceKey.isTopLevel()) { |
There was a problem hiding this comment.
Why buffer the top-level AgentEndEvent here instead of emitting [terminal, end] inline like the child path below? The documented close order (AgentResultEvent -> TextOutputDispositionEvent(TERMINAL) -> AgentEndEvent) is already satisfied at this point, because AgentResultEvent is observed before AgentEndEvent and current.lastResult() is available in the snapshot. Deferring to complete() (line 144) adds two avoidable failure modes:
- If the source errors before terminating,
concatWithnever runs, so the invocation'sAgentEndEventis dropped entirely (doNotLeakPendingTopLevelEndOrTerminalOnErrortreats this as intended). Parent-side lifecycle logic that waits onAgentEndEvent— e.g. the harnessAgentSpawnTool/ AG-UISubagentEventConverterpaths that also setMETADATA_INVOCATION_OUTCOME— would hang or leak. - For a non-terminating stream (long-lived AG-UI session where a child-forwarded end matches the empty
SourceKey.topLevel()key), the deferred end may never be emitted at all.
Emitting inline removes pendingTopLevelEnds/endedSources state and the ordering hazard, and makes the top-level and child branches symmetric.
| end.getMetadata() == null | ||
| ? null | ||
| : end.getMetadata().get(AgentEndEvent.METADATA_INVOCATION_OUTCOME); | ||
| return outcome == null || AgentEndEvent.OUTCOME_SUCCESS.equals(outcome.toString()); |
There was a problem hiding this comment.
outcome == null || OUTCOME_SUCCESS.equals(outcome) is fail-open: any AgentEndEvent without outcome metadata is classified as a successful completion and can therefore produce a TERMINAL disposition. Today only AgentSpawnTool (and SubagentEventConverter) sets the key, so a child end from a custom/forwarded agent — the EventStreamingAgent-style adapters in #3098 — or a replayed/persisted stream that lost metadata will look like a normal completion. The PR description promises that cancellation and error paths never synthesize a terminal classification; the null branch is where that breaks. Consider requiring positive evidence instead: OUTCOME_SUCCESS.equals(outcome) for the child path, or additionally current.lastResult() != null (as the top-level path at line 153 already does), so the two branches share one guarantee.
| } | ||
|
|
||
| public void markDispositionEmitted(SourceKey sourceKey) { | ||
| states.computeIfAbsent(sourceKey, ignored -> new ReplyState()).dispositionEmitted = true; |
There was a problem hiding this comment.
markDispositionEmitted uses computeIfAbsent, so it can re-create a ReplyState that clearSource already removed, and neither the annotator nor this class ever evicts entries for child sources — AgentEventStreams only calls clearSource for buffered top-level ends. states is a LinkedHashMap that therefore grows with the number of distinct source + taskId pairs seen by one subscription. For a long-running session that spawns many subagents/tasks on the same wrapped stream that is an unbounded retention path (each entry also pins lastResult, i.e. the full result message). Suggest: clear the source when its AgentEndEvent is handled, use states.get(...) (no auto-create) in markDispositionEmitted, and add a regression test that asserts the map is empty once all sources have ended.
| @JsonSubTypes.Type(value = AgentStartEvent.class, name = "AGENT_START"), | ||
| @JsonSubTypes.Type(value = AgentEndEvent.class, name = "AGENT_END"), | ||
| @JsonSubTypes.Type(value = AgentResultEvent.class, name = "AGENT_RESULT"), | ||
| @JsonSubTypes.Type(value = TextOutputDispositionEvent.class, name = "TEXT_OUTPUT_DISPOSITION"), |
There was a problem hiding this comment.
Adding a subtype to this closed @JsonSubTypes list is a wire-format change, not just an in-process API addition. AgentEvent uses @JsonTypeInfo(Id.NAME) with no defaultImpl, so any consumer pinned to an older agentscope-core (or an event stream persisted by 2.0.3 and replayed on 2.0.2) fails deserialization with an unknown-type-id error rather than ignoring the new event. That contradicts the 'consumers that do not recognize TextOutputDispositionEvent can ignore it' compatibility claim, which only holds for code that switches on the event class. If events are persisted/replayed anywhere (session recovery, distribution, tracing exporters), please note the minimum consumer version in the docs/migration notes, and consider whether the disposition belongs in the persisted event log at all, or should stay a transient-only event.
| if (event instanceof ModelCallStartEvent start) { | ||
| replyId = start.getReplyId(); | ||
| toolCallSeen = false; | ||
| Observation observation = tracker.observe(event); |
There was a problem hiding this comment.
The refactor to share ReplyLifecycleTracker looks behaviour-preserving for replyId / toolCallSeen, but two notes: (1) the shared tracker now also records lastResult on every AGENT_RESULT, which FinalAnswerFilterMiddleware never reads — dead state pinned per source key, and this middleware's RoundState has no clearSource call at all, so it inherits the retention issue above; (2) the old isCurrentReply() only understood four event classes, while ReplyLifecycleTracker.replyId() also unwraps TextBlockStartEvent and AgentEndEvent. That widens which events are considered 'current reply' for the buffering decision. Both are fine if the extra classification is intended, but please add a middleware-level test asserting the buffer still drops intermediate text when a reply ends without a tool call, so the shared-state refactor has its own guard rail.
|
|
||
| private Flux<AgentEvent> apply(Flux<AgentEvent> source) { | ||
| Flux<AgentEvent> processed = | ||
| source.concatMap(event -> Flux.fromIterable(process(event)), 1); |
There was a problem hiding this comment.
withTextOutputDisposition is a new public API in agentscope-core, and changes to core interfaces cascade to harness, distribution and every extension. Two small asks before this settles: (1) ReplyLifecycleTracker is public but documented as 'not a stable public API' while living in io.agentscope.core.internal.stream — if it is meant to stay internal, keep the constructor package-private or expose only the annotator, otherwise the disclaimered-public rule is easy to break by accident; (2) please add a short Javadoc note on AgentEndEvent.METADATA_INVOCATION_OUTCOME about who owns writing it, since isNormallyCompleted depends on that contract and today only the harness spawn path fills it in.
(cherry picked from commit 0db3ed7)
(cherry picked from commit 72e56f5)
(cherry picked from commit d09f6b5)
(cherry picked from commit 3ba05d8)
(cherry picked from commit c788fe9)
(cherry picked from commit 0fb5206)
(cherry picked from commit 6e3b5a1)
(cherry picked from commit 61f58ae)
(cherry picked from commit 1c8149e)
(cherry picked from commit 3ab4abe)
(cherry picked from commit 50ed765)
(cherry picked from commit 22a30bf)
按来源和任务关闭最后模型回复,并区分子代理成功、失败与取消结束。 补齐协议、AG-UI、前端和数据面的回归验证与最终修复报告。 (cherry picked from commit 80c3595)
透传子智能体生命周期、思考、文本处置、工具参数结果及任务标识。 修正 acting 中间件事件发布位置,避免事件遗漏或重复。 (cherry picked from commit 6af8e3b)
补齐本地子智能体的 AgentResultEvent 转发,避免只收到结束事件而缺少权威结果。 AG-UI 仅按当前运行实际生成的分段消息清理临时文本,避免误删合法历史消息。 Web 预览总线不再重放订阅前事件,并串行化并发发送以避免丢帧。 (cherry picked from commit 76413d6)
上游 agentscope-ai#3010 之后文本消息 ID 为 replyId-blockId(如 reply-1-text-1),本分支的 AguiStreamContext 仍把传入值当 replyId 再拼 :text:N,导致处置事件 messageIds 与流式消息 ID 不一致。 改为直接复用官方 messageId,并从段 ID 反推所属 replyId 维持处置分组;保留按本轮实际生成段判定快照排除的语义,同步更新测试期望与 README 说明。 (cherry picked from commit 42ad838)
42ad838 to
39ecc5f
Compare
维护者评审提出的两个 critical: 1. 迟到事件原先直接抛 IllegalStateException,经 concatMap 会转成 onError, 把一个可选的附加信号升级成用户可见的流失败。现改为记录告警后原样透传; 同一来源再次出现 MODEL_CALL_START 时视为新一次调用并恢复标注。 2. 顶层 AgentEndEvent 原先缓存到流结束后的 complete() 才发出,源出错时 end 事件丢失,流不终止时永不发出。现改为内联发出 [terminal, end],并删除 pendingTopLevelEnds 与 complete()。 同时处理评审中的其余意见: - 子代理结束必须显式声明 OUTCOME_SUCCESS 才产生 TERMINAL,未知或异常 outcome 不再 fail-open。 - 每个来源在 AgentEndEvent 时清理 tracker 状态,并把结果关联移出 tracker, 避免 states 无界增长并 pin 住 AgentResultEvent。 - markDispositionEmitted 不再重建已清理的来源状态。 - 补充 AgentEndEvent 的 outcome 契约与新增事件的线格式兼容说明。 新增回归测试:结束先于源错误/源完成发出、迟到事件透传、子代理异常结束不产生 TERMINAL、tracker 状态清理、中间轮文本不泄漏进最终答案。
|
Thanks for the detailed review — all findings are addressed, and the scope split is done. Scope: the branch is rebased onto current Critical 1 — late events ( Critical 2 — buffered top-level end ( Warning — fail-open success ( Warning — unbounded tracker state ( Warning — wire format ( Info — middleware guard rail ( Info — tracker visibility and outcome contract: Verification: |
oss-maintainer
left a comment
There was a problem hiding this comment.
Summary
Re-review of the trimmed branch (9b76667c..ffa69712). The scope reduction I asked for landed — 249 files / ~34.4k additions is now 40 files / +3225, the unrelated control-plane and skill-evolution work is gone, and both blocking items from the last round are genuinely fixed: late events after AgentEndEvent are now forwarded with a warning instead of failing the wrapped stream, and the top-level end event is emitted inline instead of being buffered until complete(). Opt-in AguiAdapterConfig.textOutputDispositionEnabled plus the README that spells out "terminal does not mean final answer" is the right shape. CI is green (build on ubuntu/windows, Check License, Check Module Sync) and license/cla is now passing.
Verdict: comment-only again — no blocking item left, but three correctness risks I would like resolved before this merges (inline).
Findings
- [Warning]
AgentEventStreams.java:155—endedSourcesis still append-only for child sources, so the unbounded-state problem is only half fixed. - [Warning]
AguiStreamContext.java:468— replyId is recovered by regex over the AG-UI message id; mis-parsing silently attaches dispositions to the wrong reply. - [Warning]
AguiStreamContext.java:274—MESSAGES_SNAPSHOTcan drop the submitted user turn whenAgentState.getContext()does not echo it, and consumers are told to replace streamed text with that snapshot. - [Warning]
ReActAgent.java:2787— publication moved fromactingStream(...)to the middleware-wrapped stream; a middleware that short-circuitsonActingno longer publishes core tool events. - [Info]
AguiStreamContext.java:236—getTextMessageIdsreturns different key spaces depending on the config flag. - [Info]
AgentEventStreams.java:219— the derived disposition inherits the trigger metadata map, includingMETADATA_INVOCATION_OUTCOME.
What I verified this round
forwardsLateEventsWithoutFailingOrClassifyingThemexists inAgentEventStreamsTest, so the fail-open behaviour has a regression test.tracker.clearSource(sourceKey)on child end and the explicitOUTCOME_SUCCESSrequirement for child terminals both close earlier warnings.SubagentEventConverternow stampstaskIdon forwarded events and carriesreplyId/blockIdthrough, which keeps multi-task subagent streams separable downstream.
Nice work on the rework — the diff is now reviewable, and the remaining items are narrow.
Automated review by github-manager-bot
|
|
||
| private List<AgentEvent> onAgentEnd(AgentEndEvent event, Observation observation) { | ||
| SourceKey sourceKey = observation.sourceKey(); | ||
| endedSources.add(sourceKey); |
There was a problem hiding this comment.
tracker.clearSource(sourceKey) closes the unbounded states map I flagged last round, but endedSources still only grows inside a subscription: a child key (source + taskId) is removed only when another ModelCallStartEvent with the same key arrives, and AgentSpawnTool mints a fresh task_<uuid> per spawn. For a long-lived stream that fans out many subagent invocations the set, and the authoritativeResults entries of children that never end, keep accumulating until the subscription terminates. Dropping the child key once its end event is forwarded (the new-model-call branch already handles re-opening) would make the reclaim consistent with clearSource.
| AgentEvent trigger) { | ||
| TextOutputDispositionEvent event = | ||
| new TextOutputDispositionEvent(replyId, disposition, generateReason); | ||
| event.withSource(trigger.getSource()).withMetadata(trigger.getMetadata()); |
There was a problem hiding this comment.
Minor: withMetadata(trigger.getMetadata()) copies the trigger's whole metadata map onto the derived event, so a disposition emitted from an AgentEndEvent also carries METADATA_INVOCATION_OUTCOME. Harmless today, but a consumer that reads outcome off the disposition would then disagree with the authoritative result. Consider copying only source/taskId (the correlation keys) rather than the full map.
| * Recovers the owning reply id from a live text-segment message id. Ids that are not segment ids | ||
| * (for example a single-block reply id) are their own reply id. | ||
| */ | ||
| private static String replyIdOf(String messageId) { |
There was a problem hiding this comment.
Recovering the reply id by regex over the AG-UI message id couples correctness to a naming convention owned elsewhere: any id that legitimately ends in -text, -thinking, -reasoning (or -reasoning-2) is silently mapped to a different replyId, and ids produced outside the text/thinking converters fall through to "messageId == replyId", creating a bogus reply bucket in textMessageIdsByReply. Dispositions would then be attached to the wrong reply. Since the callers that build these ids already know the replyId, could it be passed explicitly (e.g. startTextMessage(messageId, replyId)) and keep the regex only as a compatibility fallback?
| for (AguiMessage message : runInput.getMessages()) { | ||
| if (message != null | ||
| && !isGeneratedTextSegmentId(message.getId()) | ||
| && (!hasAuthoritativeMessages |
There was a problem hiding this comment.
When the agent state yields any messages, run-input messages are kept only if their id is already present in messagesById. That makes the snapshot depend on AgentState.getContext() echoing the just-submitted user turn — if it does not (filtered memory, trimmed context, or a state store that persists only assistant turns), the user's message silently disappears from MESSAGES_SNAPSHOT, and since the README tells consumers to reconcile by replacing streamed text with the snapshot, the turn is lost in the UI. Safer default: keep run-input messages unless the authoritative list supplies a replacement for the same id. Worth a test for the "input message absent from agent state" case either way.
| emit(new AguiEvent.TextMessageEnd(threadId, runId, messageId)); | ||
| } | ||
|
|
||
| public List<String> getTextMessageIds(String replyId) { |
There was a problem hiding this comment.
The disabled branch treats replyId as a message id (startedTextMessages.contains(replyId) ? List.of(replyId) : ...), so callers of getTextMessageIds get either the reply id back or nothing depending on a config flag. That asymmetry is easy to misuse in the converters; returning the segment ids that were started for that reply in both modes (or documenting the contract in the javadoc) would be clearer.
| actingCore) | ||
| .apply(new ActingInput(toolCalls)); | ||
| return stream.doOnNext( | ||
| return stream.doOnNext(this::publishEvent) |
There was a problem hiding this comment.
Moving .doOnNext(this::publishEvent) from the tail of actingStream(...) out to the middleware-wrapped stream changes who controls publication: tool/text events are now published only if a middleware forwards the stream. A middleware that short-circuits, retries or filters onActing used to still publish the core events and now will not (and conversely, events a middleware injects are published where they weren't before). That may well be the intent, but it touches the HITL paths this file keeps churning on (#3099/#3104), so could you confirm with a test that a stop/deny path still emits the permission-denied tool result events end-to-end?
维护者第二轮复审提出的四项风险: 1. 子来源墓碑无界:endedSources 里的子来源键按次生成(task_<uuid>),只增不减 等价于让一个订阅保留它见过的所有子调用。现在子来源在结束事件发出后立即回收, 顶层键保留到下一次模型调用重新打开,与 clearSource 的生命周期保持一致。 2. 派生处置事件继承了触发的整体 metadata:从 AgentEndEvent 派生的 TERMINAL 会带上 METADATA_INVOCATION_OUTCOME,消费者据此判断会与权威结果矛盾。现在只复制 taskId 这一关联键。 3. AG-UI 的 replyId 靠消息 id 正则反推:任何以 -text/-thinking/-reasoning 结尾的合法 id 都会被归到别的 replyId,非文本转换器产生的 id 还会落到 messageId==replyId 的 伪桶。现在文本转换器显式传入 replyId,正则只作兼容回退。 4. MESSAGES_SNAPSHOT 会丢掉本轮用户消息:仅当 agent 状态回显该消息时才保留 run 输入,而消费者按快照替换流式文本,状态未回显时该轮次在 UI 上消失。现在 run 输入 始终保留。 同时 getTextMessageIds 不再随 textOutputDispositionEnabled 返回不同键空间, 并删除只写不读的 activeTextMessageIdsByReply 与 currentTextReplyId。 新增回归测试:子来源回收与顶层墓碑释放、处置事件只带关联键、中间轮文本不泄漏、 拒绝工具的 DENIED 结果在 acting 中间件下仍发布、询问工具的停止路径仍发布、 agent 状态不回显用户轮次时快照仍保留该轮次。
e367ee6 to
c9503ab
Compare
|
All six items are addressed and pushed as Warning — Warning — replyId recovered by regex ( Warning — Warning — publication moved off On intent: the move is what makes middleware-injected events reach consumers — Info — Info — disposition inherits the trigger metadata ( While refactoring I also removed two pieces of write-only state from this branch: Local verification: |
AgentScope-Java Version
2.0.3-SNAPSHOT
Description
Background
TextDeltaEventpreserves provider-level token streaming, but it does not tell a consumer whether a text segment belongs to an intermediate model turn that will invoke tools or to the last user-visible reply. Today, adapters have to infer that intent from neighboring tool and lifecycle events. That inference is easy to implement differently across AG-UI, Agent Protocol, Web UI, and custom consumers, especially when subagents or concurrent tasks are involved.Waiting for
AgentResultEventand then emitting the whole answer is simpler, but it removes the real-time preview that streaming consumers expect.This PR introduces an opt-in semantic layer that keeps the existing token stream intact while classifying each completed text lifecycle:
The wrapper emits
TextOutputDispositionEventwith one of two dispositions:INTERMEDIATE: the referenced text belongs to a non-terminal model turn and can be presented as progress or commentary.TERMINAL: the last user-visible reply candidate has completed its streaming lifecycle.The disposition event classifies text that has already been streamed; it does not replace or delay the original
TextDeltaEvent.TERMINALis also not the authoritative answer.AgentResultEventremains the authoritative invocation result and can reconcile the preview with the final message, structured output, or an empty result.For a normally completed invocation, the closing order is:
This ordering lets consumers keep token-level preview latency while committing only the authoritative result.
Correlation and isolation
replyIdassociates a disposition with the text lifecycle it classifies.sourceandmetadata.taskIdisolate top-level, subagent, and concurrent invocations, including concurrent calls from the same source.Compatibility
ReActAgent#streamEvents()keeps its existing default event sequence.TextOutputDispositionEventcan ignore it without changing their current behavior.Adapter behavior
AG-UI can opt in with:
When enabled, intermediate text is exposed as a semantic custom event rather than being presented as model reasoning. On completion, the authoritative
AgentResultEventproduces a standardMESSAGES_SNAPSHOTto reconcile provisional text. When disabled, the legacy AG-UI sequence and message IDs are preserved.The managed Web flow treats text deltas as non-persistent preview updates.
INTERMEDIATEcan downgrade a preview to commentary,TERMINALcloses the preview lifecycle, andAgentEndEventcommits the message derived from the buffered authoritative result. Empty authoritative results can therefore remove stale preview text, and late subscribers do not receive obsolete preview frames.Local and remote subagents preserve the same semantics. Remote transport continues using
AGENT_EVENTwith its JSON string payload, while local subagent forwarding now includes the authoritativeAgentResultEventbeforeAgentEndEvent.Why this shape
The framework is the only layer that reliably knows the model-turn and invocation lifecycle. Publishing a small semantic envelope there avoids duplicating heuristics in every protocol adapter, while keeping provider events available for observability and leaving presentation policy to consumers. It also allows consumers to choose either a full-trace projection or a final-answer projection without forcing buffering into the core token stream.
Verification
mvn spotless:checkandgit diff --checkare clean.A complete Windows test run is not marked as passing because the repository contains symlink tests that require Windows symlink privileges and an existing order-sensitive baseline test. These are unrelated to this change; the affected streaming suites pass independently.
Related to #2872.
Related to #2975.
Checklist
mvn spotless:checkmvn test)