From 753376290ad74480279096b1f607908f6a6a4d14 Mon Sep 17 00:00:00 2001 From: LIlGG <1103069291@qq.com> Date: Thu, 16 Jul 2026 18:34:35 +0800 Subject: [PATCH] fix: preserve UI message step boundaries --- .../halo/aifoundation/ui/StartStepChunk.java | 3 +- .../halo/aifoundation/ui/StepStartPart.java | 14 ++++ .../ui/UIMessageChunkReducer.java | 7 +- .../aifoundation/ui/UIMessageChunkType.java | 2 + .../aifoundation/ui/UIMessageConverters.java | 26 ++----- .../halo/aifoundation/ui/UIMessagePart.java | 4 +- .../ui/UIMessagePartIdentity.java | 1 + .../halo/aifoundation/ui/UIMessageParts.java | 9 +++ .../ui/UIMessageTransportCodec.java | 3 + .../aifoundation/ui/UIMessageValidators.java | 6 ++ .../capability/ModelCapabilityService.java | 12 ++- .../service/language/LanguageModelImpl.java | 6 +- .../LanguageModelRuntimeComposition.java | 24 ++++-- .../LanguageModelRequestValidator.java | 4 +- .../openai/OpenAiCompatibleModelsTest.java | 9 +++ .../ModelCapabilityServiceTest.java | 46 +++++++++++ .../language/LanguageModelImplTest.java | 62 ++++++++++++++- .../ui/UIMessageConversionValidationTest.java | 76 +++++++++++++----- .../ui/UIMessageStreamReaderTest.java | 15 ++++ .../ui/UIMessageTransportCodecTest.java | 1 + dev/ui-message-stream.md | 18 +++-- .../.openspec.yaml | 2 + .../design.md | 78 +++++++++++++++++++ .../proposal.md | 43 ++++++++++ .../specs/console-model-management/spec.md | 21 +++++ .../specs/consumer-sdk-documentation/spec.md | 18 +++++ .../specs/model-capability-profile/spec.md | 22 ++++++ .../specs/ui-message-stream/spec.md | 52 +++++++++++++ .../tasks.md | 30 +++++++ ui/packages/sdk/src/core.test.ts | 36 ++++++++- ui/packages/sdk/src/message-reducer.ts | 5 +- ui/packages/sdk/src/persistence.ts | 16 +++- ui/packages/sdk/src/types.ts | 5 ++ ui/src/utils/boolean-capability-form.test.ts | 13 ++++ ui/src/utils/boolean-capability-form.ts | 19 +++++ ui/src/views/components/ModelForm.vue | 62 +++++++-------- .../components/model-group-removal.test.ts | 6 +- 37 files changed, 671 insertions(+), 105 deletions(-) create mode 100644 api/src/main/java/run/halo/aifoundation/ui/StepStartPart.java create mode 100644 openspec/changes/preserve-ui-message-step-boundaries/.openspec.yaml create mode 100644 openspec/changes/preserve-ui-message-step-boundaries/design.md create mode 100644 openspec/changes/preserve-ui-message-step-boundaries/proposal.md create mode 100644 openspec/changes/preserve-ui-message-step-boundaries/specs/console-model-management/spec.md create mode 100644 openspec/changes/preserve-ui-message-step-boundaries/specs/consumer-sdk-documentation/spec.md create mode 100644 openspec/changes/preserve-ui-message-step-boundaries/specs/model-capability-profile/spec.md create mode 100644 openspec/changes/preserve-ui-message-step-boundaries/specs/ui-message-stream/spec.md create mode 100644 openspec/changes/preserve-ui-message-step-boundaries/tasks.md create mode 100644 ui/src/utils/boolean-capability-form.test.ts create mode 100644 ui/src/utils/boolean-capability-form.ts diff --git a/api/src/main/java/run/halo/aifoundation/ui/StartStepChunk.java b/api/src/main/java/run/halo/aifoundation/ui/StartStepChunk.java index 73b007ef..0b97fb2c 100644 --- a/api/src/main/java/run/halo/aifoundation/ui/StartStepChunk.java +++ b/api/src/main/java/run/halo/aifoundation/ui/StartStepChunk.java @@ -3,7 +3,8 @@ /** * Lifecycle chunk describing the start of one generation step. * - *

Step start chunks are not persisted into {@link UIMessage#parts()} by the stream reader. + *

The stream reader persists this lifecycle event as a marker-only {@link StepStartPart}. + * The invocation-local index remains stream diagnostics and is not copied to the persisted part. * * @param stepIndex step index */ diff --git a/api/src/main/java/run/halo/aifoundation/ui/StepStartPart.java b/api/src/main/java/run/halo/aifoundation/ui/StepStartPart.java new file mode 100644 index 00000000..d3e82813 --- /dev/null +++ b/api/src/main/java/run/halo/aifoundation/ui/StepStartPart.java @@ -0,0 +1,14 @@ +package run.halo.aifoundation.ui; + +/** + * Marker persisted at the start of one assistant generation step. + * + *

The marker's position in {@link UIMessage#parts()} defines the boundary. It intentionally + * carries no invocation-local step index because indexes restart for each model invocation. + */ +public record StepStartPart() implements UIMessagePart { + @Override + public String type() { + return UIMessageChunkType.STEP_START; + } +} diff --git a/api/src/main/java/run/halo/aifoundation/ui/UIMessageChunkReducer.java b/api/src/main/java/run/halo/aifoundation/ui/UIMessageChunkReducer.java index 5c676a7d..258b5e8c 100644 --- a/api/src/main/java/run/halo/aifoundation/ui/UIMessageChunkReducer.java +++ b/api/src/main/java/run/halo/aifoundation/ui/UIMessageChunkReducer.java @@ -32,7 +32,7 @@ public UIMessageChunkReducer(List parts) { * Applies a stream chunk. * * @param chunk stream chunk - * @return true when persisted message parts changed + * @return true when visible persisted message content changed */ public boolean accept(UIMessageChunk chunk) { UIMessageChunkValidator.validate(chunk); @@ -74,7 +74,10 @@ public boolean accept(UIMessageChunk chunk) { tool.providerMetadata()); case ToolChunk tool -> replaceTool(tool); case FinishStepChunk ignored -> false; - case StartStepChunk ignored -> false; + case StartStepChunk ignored -> { + parts.add(UIMessageParts.stepStart()); + yield false; + } case FinishChunk finish -> { terminal = terminal.withFinish(finish.finishReason(), finish.usage()); yield false; diff --git a/api/src/main/java/run/halo/aifoundation/ui/UIMessageChunkType.java b/api/src/main/java/run/halo/aifoundation/ui/UIMessageChunkType.java index b58bbff2..12688e9d 100644 --- a/api/src/main/java/run/halo/aifoundation/ui/UIMessageChunkType.java +++ b/api/src/main/java/run/halo/aifoundation/ui/UIMessageChunkType.java @@ -9,6 +9,8 @@ public final class UIMessageChunkType { public static final String START = "start"; /** Starts one generation step. */ public static final String START_STEP = "start-step"; + /** Persisted generation-step boundary marker. */ + public static final String STEP_START = "step-start"; /** Persisted text part type. */ public static final String TEXT = "text"; /** Opens a streamed text block. */ diff --git a/api/src/main/java/run/halo/aifoundation/ui/UIMessageConverters.java b/api/src/main/java/run/halo/aifoundation/ui/UIMessageConverters.java index 1032c509..b27d61af 100644 --- a/api/src/main/java/run/halo/aifoundation/ui/UIMessageConverters.java +++ b/api/src/main/java/run/halo/aifoundation/ui/UIMessageConverters.java @@ -112,36 +112,24 @@ private void convertMessage(UIMessage message, int messageIndex) { var emitted = false; for (var partIndex = 0; partIndex < message.parts().size(); partIndex++) { var part = message.parts().get(partIndex); + if (part instanceof StepStartPart) { + emitted |= flushSegment(message, assistantContent, toolContent); + continue; + } var context = new UIMessageConversionContext<>(messages, message, messageIndex, part, partIndex); if (isToolApprovalResponse(part)) { - if (!assistantContent.isEmpty()) { - emitted |= flushSegment(message, assistantContent, toolContent); - } assistantContent.addAll(convertToolCall((ToolPart) part)); assistantContent.add(convertToolApprovalRequest((ToolPart) part)); - emitted |= flushSegment(message, assistantContent, toolContent); toolContent.add(convertToolApprovalResponse((ToolPart) part)); continue; } if (isTerminalToolPart(part)) { - if (!toolContent.isEmpty()) { - emitted |= flushSegment(message, assistantContent, toolContent); - } assistantContent.addAll(convertToolCall((ToolPart) part)); - emitted |= flushSegment(message, assistantContent, toolContent); toolContent.addAll(convertToolOutput((ToolPart) part)); continue; } - var converted = convertPart(part, context); - if (isToolResponsePart(part)) { - toolContent.addAll(converted); - } else { - if (!toolContent.isEmpty()) { - emitted |= flushSegment(message, assistantContent, toolContent); - } - assistantContent.addAll(converted); - } + assistantContent.addAll(convertPart(part, context)); } emitted |= flushSegment(message, assistantContent, toolContent); if (!emitted) { @@ -167,10 +155,6 @@ private boolean flushSegment(UIMessage message, return emitted; } - private boolean isToolResponsePart(UIMessagePart part) { - return isTerminalToolPart(part) || isToolApprovalResponse(part); - } - private boolean isToolApprovalResponse(UIMessagePart part) { return part instanceof ToolPart tool && (tool.state() == ToolPartState.APPROVAL_RESPONDED diff --git a/api/src/main/java/run/halo/aifoundation/ui/UIMessagePart.java b/api/src/main/java/run/halo/aifoundation/ui/UIMessagePart.java index fcd3721a..cd7cecd2 100644 --- a/api/src/main/java/run/halo/aifoundation/ui/UIMessagePart.java +++ b/api/src/main/java/run/halo/aifoundation/ui/UIMessagePart.java @@ -7,8 +7,8 @@ * stream chunks such as tool input deltas, finish events, errors, and aborts are * not represented as parts. */ -public sealed interface UIMessagePart permits TextPart, ReasoningPart, DataPart, ToolPart, - SourceUrlPart, SourceDocumentPart, FilePart { +public sealed interface UIMessagePart permits StepStartPart, TextPart, ReasoningPart, DataPart, + ToolPart, SourceUrlPart, SourceDocumentPart, FilePart { /** * Stable discriminator used by serializers and callers. diff --git a/api/src/main/java/run/halo/aifoundation/ui/UIMessagePartIdentity.java b/api/src/main/java/run/halo/aifoundation/ui/UIMessagePartIdentity.java index 459afd13..c3c43b75 100644 --- a/api/src/main/java/run/halo/aifoundation/ui/UIMessagePartIdentity.java +++ b/api/src/main/java/run/halo/aifoundation/ui/UIMessagePartIdentity.java @@ -23,6 +23,7 @@ public record UIMessagePartIdentity(String type, String id) { */ public static UIMessagePartIdentity of(UIMessagePart part) { return switch (part) { + case StepStartPart value -> new UIMessagePartIdentity(value.type(), value.type()); case TextPart value -> new UIMessagePartIdentity(value.type(), value.id()); case ReasoningPart value -> new UIMessagePartIdentity(value.type(), value.id()); case DataPart value -> new UIMessagePartIdentity(value.type(), value.id()); diff --git a/api/src/main/java/run/halo/aifoundation/ui/UIMessageParts.java b/api/src/main/java/run/halo/aifoundation/ui/UIMessageParts.java index 08618e03..50682b36 100644 --- a/api/src/main/java/run/halo/aifoundation/ui/UIMessageParts.java +++ b/api/src/main/java/run/halo/aifoundation/ui/UIMessageParts.java @@ -10,6 +10,15 @@ public final class UIMessageParts { private UIMessageParts() { } + /** + * Creates a persisted generation-step boundary marker. + * + * @return step-start marker + */ + public static StepStartPart stepStart() { + return new StepStartPart(); + } + /** * Creates a persisted text part. * diff --git a/api/src/main/java/run/halo/aifoundation/ui/UIMessageTransportCodec.java b/api/src/main/java/run/halo/aifoundation/ui/UIMessageTransportCodec.java index 86a9e6d5..482c7a0a 100644 --- a/api/src/main/java/run/halo/aifoundation/ui/UIMessageTransportCodec.java +++ b/api/src/main/java/run/halo/aifoundation/ui/UIMessageTransportCodec.java @@ -286,6 +286,7 @@ public static UIMessagePart partFromMap(Map map) { objectMap(map.get("providerMetadata"))); } return switch (type) { + case UIMessageChunkType.STEP_START -> UIMessageParts.stepStart(); case UIMessageChunkType.TEXT -> UIMessageParts.text(stringValue(map.get("id")), stringValue(map.get("text"))); case UIMessageChunkType.REASONING -> UIMessageParts.reasoning( @@ -322,6 +323,8 @@ public static Map partToMap(UIMessagePart part) { var map = new LinkedHashMap(); put(map, "type", part.type()); switch (part) { + case StepStartPart ignored -> { + } case TextPart text -> { put(map, "id", text.id()); put(map, "text", text.text()); diff --git a/api/src/main/java/run/halo/aifoundation/ui/UIMessageValidators.java b/api/src/main/java/run/halo/aifoundation/ui/UIMessageValidators.java index a352bf06..cba5685e 100644 --- a/api/src/main/java/run/halo/aifoundation/ui/UIMessageValidators.java +++ b/api/src/main/java/run/halo/aifoundation/ui/UIMessageValidators.java @@ -146,6 +146,12 @@ private void validatePart(UIMessage message, int messageIndex, UIMessagePart "UI message part type must not be blank")); } switch (part) { + case StepStartPart ignored -> { + if (message.role() != UIMessageRole.ASSISTANT) { + issues.add(issue(message, part, null, "part.step-start.role.invalid", + "Step-start parts are only allowed in assistant messages")); + } + } case TextPart text -> require(message, part, text.id(), "part.id.required", "Text part id must not be blank"); case ReasoningPart reasoning -> require(message, part, reasoning.id(), diff --git a/app/src/main/java/run/halo/aifoundation/service/capability/ModelCapabilityService.java b/app/src/main/java/run/halo/aifoundation/service/capability/ModelCapabilityService.java index d93687ab..ffa9c0d5 100644 --- a/app/src/main/java/run/halo/aifoundation/service/capability/ModelCapabilityService.java +++ b/app/src/main/java/run/halo/aifoundation/service/capability/ModelCapabilityService.java @@ -32,7 +32,7 @@ public ModelCapabilities effectiveCapabilities(AiModel model, var imageGeneration = copy(explicit == null ? null : explicit.getImageGeneration()); if (spec.getModelType() == ModelType.LANGUAGE) { - language = applyLanguageDefaults(language, spec.getFeatures()); + language = applyLanguageDefaults(language, spec.getFeatures(), providerType); } if (spec.getModelType() == ModelType.IMAGE_GENERATION && sources.getImageGeneration() != CapabilitySource.MANUAL) { @@ -47,10 +47,18 @@ public ModelCapabilities effectiveCapabilities(AiModel model, } private LanguageCapability applyLanguageDefaults(LanguageCapability language, - List features) { + List features, @Nullable AiProviderType providerType) { var result = language; var hasVision = features != null && features.contains(ModelFeature.VISION); var hasAudioInput = features != null && features.contains(ModelFeature.AUDIO_INPUT); + if (providerType != null) { + result = result == null ? LanguageCapability.unknown() : result; + if (result.getReasoningHistory() == null) { + var providerOptions = providerType.languageModelProviderOptions(); + result.setReasoningHistory(providerOptions != null + && providerOptions.reasoningHistorySupported()); + } + } if (hasVision) { result = result == null ? LanguageCapability.unknown() : result; if (result.getImageInput() == null) { diff --git a/app/src/main/java/run/halo/aifoundation/service/language/LanguageModelImpl.java b/app/src/main/java/run/halo/aifoundation/service/language/LanguageModelImpl.java index a99f0705..f4c4b0dc 100644 --- a/app/src/main/java/run/halo/aifoundation/service/language/LanguageModelImpl.java +++ b/app/src/main/java/run/halo/aifoundation/service/language/LanguageModelImpl.java @@ -100,6 +100,7 @@ public class LanguageModelImpl implements LanguageModel { private final String providerType; private final LanguageModelProviderOptions providerOptions; private final run.halo.aifoundation.capability.ModelCapabilities modelCapabilities; + private final boolean reasoningHistorySupported; private final LanguageModelRequestValidator requestValidator; private final LanguageModelMessageMapper messageMapper; private final GenerationMessageHistoryAssembler messageHistoryAssembler; @@ -148,6 +149,7 @@ public class LanguageModelImpl implements LanguageModel { this.providerType = composition.providerType(); this.providerOptions = composition.providerOptions(); this.modelCapabilities = composition.modelCapabilities(); + this.reasoningHistorySupported = composition.reasoningHistorySupported(); this.requestValidator = composition.requestValidator(); this.messageMapper = composition.messageMapper(); this.messageHistoryAssembler = composition.messageHistoryAssembler(); @@ -211,7 +213,7 @@ public StreamTextResult streamText(GenerateTextRequest request) { @Override public LanguageModelCapabilities capabilities() { - return LanguageModelCapabilities.of(providerOptions.reasoningHistorySupported(), + return LanguageModelCapabilities.of(reasoningHistorySupported, modelCapabilities); } @@ -1276,7 +1278,7 @@ protected boolean supportsToolCalling() { } protected boolean supportsReasoningHistory() { - return providerOptions.reasoningHistorySupported(); + return reasoningHistorySupported; } private String toolCallingUnsupportedMessage() { diff --git a/app/src/main/java/run/halo/aifoundation/service/language/LanguageModelRuntimeComposition.java b/app/src/main/java/run/halo/aifoundation/service/language/LanguageModelRuntimeComposition.java index 3b3bfc01..5d01a245 100644 --- a/app/src/main/java/run/halo/aifoundation/service/language/LanguageModelRuntimeComposition.java +++ b/app/src/main/java/run/halo/aifoundation/service/language/LanguageModelRuntimeComposition.java @@ -19,6 +19,7 @@ public record LanguageModelRuntimeComposition( String providerType, LanguageModelProviderOptions providerOptions, ModelCapabilities modelCapabilities, + boolean reasoningHistorySupported, LanguageModelRequestValidator requestValidator, LanguageModelMessageMapper messageMapper, GenerationMessageHistoryAssembler messageHistoryAssembler, @@ -65,12 +66,14 @@ public static LanguageModelRuntimeComposition create( var context = configuration.context(); var resolvedOptions = configuration.providerOptions(); var resolvedCapabilities = configuration.modelCapabilities(); + var reasoningHistorySupported = reasoningHistorySupported(resolvedCapabilities, + resolvedOptions); var requestValidator = new LanguageModelRequestValidator(context.providerType(), - resolvedOptions.reasoningHistorySupported(), resolvedCapabilities, context.modelName(), + reasoningHistorySupported, resolvedCapabilities, context.modelName(), context.providerName(), mediaResourcePolicy, capabilityMatcher); var messageMapper = new LanguageModelMessageMapper(context.providerType()); var messageHistoryAssembler = new GenerationMessageHistoryAssembler(context.providerType(), - resolvedOptions.reasoningHistorySupported(), messageMapper); + reasoningHistorySupported, messageMapper); var chatOptionsBuilder = new LanguageModelChatOptionsBuilder(context.providerType(), context.modelId(), resolvedOptions, runtimeSupport::writeJson); @@ -86,10 +89,19 @@ public static LanguageModelRuntimeComposition create( runtimeSupport::withToolTimeout); var toolStepCoordinator = new ToolStepCoordinator(toolExecutor); return new LanguageModelRuntimeComposition(context.providerType(), resolvedOptions, - resolvedCapabilities, requestValidator, messageMapper, messageHistoryAssembler, - chatOptionsBuilder, responseMapper, reasoningExtractor, toolCallMapper, - structuredOutputHandler, toolExecutor, toolStepCoordinator, new ToolApprovalResolver(), - runtimeSupport); + resolvedCapabilities, reasoningHistorySupported, requestValidator, messageMapper, + messageHistoryAssembler, chatOptionsBuilder, responseMapper, reasoningExtractor, + toolCallMapper, structuredOutputHandler, toolExecutor, toolStepCoordinator, + new ToolApprovalResolver(), runtimeSupport); + } + + private static boolean reasoningHistorySupported(ModelCapabilities capabilities, + LanguageModelProviderOptions providerOptions) { + var language = capabilities.getLanguage(); + if (language != null && language.getReasoningHistory() != null) { + return language.getReasoningHistory(); + } + return providerOptions.reasoningHistorySupported(); } private static LanguageModelRuntimeConfiguration configuration(String providerType, diff --git a/app/src/main/java/run/halo/aifoundation/service/language/mapping/LanguageModelRequestValidator.java b/app/src/main/java/run/halo/aifoundation/service/language/mapping/LanguageModelRequestValidator.java index d863df9c..71d7dfbb 100644 --- a/app/src/main/java/run/halo/aifoundation/service/language/mapping/LanguageModelRequestValidator.java +++ b/app/src/main/java/run/halo/aifoundation/service/language/mapping/LanguageModelRequestValidator.java @@ -324,8 +324,8 @@ private void validateReasoningPart(ModelMessageRole role, ModelMessagePart part) "reasoning content part is only supported for assistant messages"); } if (!reasoningHistorySupported) { - throw new IllegalArgumentException("reasoning content is not supported by provider type: " - + providerType); + throw new IllegalArgumentException( + "assistant reasoning history is not supported by the resolved model"); } if (!hasText(part.getText()) && (part.getProviderMetadata() == null || part.getProviderMetadata().isEmpty())) { diff --git a/app/src/test/java/run/halo/aifoundation/provider/support/openai/OpenAiCompatibleModelsTest.java b/app/src/test/java/run/halo/aifoundation/provider/support/openai/OpenAiCompatibleModelsTest.java index c5bb312f..e65f2116 100644 --- a/app/src/test/java/run/halo/aifoundation/provider/support/openai/OpenAiCompatibleModelsTest.java +++ b/app/src/test/java/run/halo/aifoundation/provider/support/openai/OpenAiCompatibleModelsTest.java @@ -55,6 +55,12 @@ void chatRequestBody_replaysReasoningContentForToolContinuation() { var assistant = AssistantMessage.builder() .content("") .properties(Map.of("reasoningContent", "tool reasoning")) + .toolCalls(List.of( + new AssistantMessage.ToolCall("call-1", "function", "weather", + "{\"city\":\"Hangzhou\"}"), + new AssistantMessage.ToolCall("call-2", "function", "search", + "{\"query\":\"Halo\"}") + )) .build(); var prompt = new Prompt(List.of(assistant), chatOptions()); @@ -66,6 +72,9 @@ void chatRequestBody_replaysReasoningContentForToolContinuation() { assertThat(messages.getFirst()) .containsEntry("reasoning_content", "tool reasoning"); + @SuppressWarnings("unchecked") + var toolCalls = (List>) messages.getFirst().get("tool_calls"); + assertThat(toolCalls).hasSize(2); } @Test diff --git a/app/src/test/java/run/halo/aifoundation/service/capability/ModelCapabilityServiceTest.java b/app/src/test/java/run/halo/aifoundation/service/capability/ModelCapabilityServiceTest.java index dba67a0f..018c95d2 100644 --- a/app/src/test/java/run/halo/aifoundation/service/capability/ModelCapabilityServiceTest.java +++ b/app/src/test/java/run/halo/aifoundation/service/capability/ModelCapabilityServiceTest.java @@ -17,6 +17,7 @@ import run.halo.aifoundation.extension.AiModel; import run.halo.aifoundation.provider.AiProviderType; import run.halo.aifoundation.provider.support.AdapterType; +import run.halo.aifoundation.provider.support.LanguageModelProviderOptions; import run.halo.aifoundation.provider.support.ModelFeature; import run.halo.aifoundation.provider.support.ModelType; import run.halo.app.extension.Metadata; @@ -126,6 +127,51 @@ void effectiveCapabilities_canUseProviderRecommendedImageAdapter() { assertThat(capabilities.getImageGeneration().getTextToImage()).isTrue(); } + @Test + void effectiveCapabilities_inheritsProviderReasoningHistoryDefault() { + var model = model(ModelType.LANGUAGE); + var providerType = mock(AiProviderType.class); + when(providerType.languageModelProviderOptions()).thenReturn( + LanguageModelProviderOptions.builder() + .reasoningHistorySupported(true) + .build()); + + var capabilities = service.effectiveCapabilities(model, providerType); + + assertThat(capabilities.getLanguage().getReasoningHistory()).isTrue(); + } + + @Test + void effectiveCapabilities_modelReasoningHistoryOverridesProviderDefault() { + var model = model(ModelType.LANGUAGE); + model.getSpec().setCapabilities(ModelCapabilities.builder() + .language(LanguageCapability.builder() + .reasoningHistory(false) + .build()) + .build()); + var providerType = mock(AiProviderType.class); + when(providerType.languageModelProviderOptions()).thenReturn( + LanguageModelProviderOptions.builder() + .reasoningHistorySupported(true) + .build()); + + var capabilities = service.effectiveCapabilities(model, providerType); + + assertThat(capabilities.getLanguage().getReasoningHistory()).isFalse(); + } + + @Test + void effectiveCapabilities_inheritsUnsupportedProviderReasoningHistoryDefault() { + var model = model(ModelType.LANGUAGE); + var providerType = mock(AiProviderType.class); + when(providerType.languageModelProviderOptions()) + .thenReturn(LanguageModelProviderOptions.defaults()); + + var capabilities = service.effectiveCapabilities(model, providerType); + + assertThat(capabilities.getLanguage().getReasoningHistory()).isFalse(); + } + @Test void matcher_treatsUnknownAsUnsupportedAndUsesMediaCoverage() { var unknown = ModelCapabilities.empty(); diff --git a/app/src/test/java/run/halo/aifoundation/service/language/LanguageModelImplTest.java b/app/src/test/java/run/halo/aifoundation/service/language/LanguageModelImplTest.java index 550b16fa..09071698 100644 --- a/app/src/test/java/run/halo/aifoundation/service/language/LanguageModelImplTest.java +++ b/app/src/test/java/run/halo/aifoundation/service/language/LanguageModelImplTest.java @@ -839,10 +839,59 @@ void generateText_rejectsReasoningHistoryWhenProviderUnsupported() { .build(); StepVerifier.create(model.generateText(request)) - .expectErrorMessage("reasoning content is not supported by provider type: ollama") + .expectErrorMessage( + "assistant reasoning history is not supported by the resolved model") .verify(); } + @Test + void generateText_modelCapabilityCanEnableReasoningHistory() { + var chatModel = mock(ChatModel.class); + when(chatModel.call(any(Prompt.class))).thenReturn(chatResponse("Done", "stop", 3, 5)); + var model = languageModelWithCapabilities(chatModel, "deepseek", + LanguageModelProviderOptions.defaults(), LanguageCapability.builder() + .reasoningHistory(true) + .build()); + + StepVerifier.create(model.generateText(GenerateTextRequest.builder() + .messages(List.of( + ModelMessage.assistant(List.of(ModelMessagePart.reasoning("thinking"))), + ModelMessage.user("Continue") + )) + .build())) + .assertNext(result -> assertThat(result.getText()).isEqualTo("Done")) + .verifyComplete(); + + assertThat(model.capabilities().reasoningHistorySupported()).isTrue(); + var captor = ArgumentCaptor.forClass(Prompt.class); + verify(chatModel).call(captor.capture()); + assertThat(captor.getValue().getInstructions().getFirst()) + .isInstanceOfSatisfying(DeepSeekAssistantMessage.class, + message -> assertThat(message.getReasoningContent()).isEqualTo("thinking")); + } + + @Test + void generateText_modelCapabilityCanDisableProviderReasoningHistory() { + var chatModel = mock(ChatModel.class); + var model = languageModelWithCapabilities(chatModel, "deepseek", + new DeepSeekProvider().languageModelProviderOptions(), LanguageCapability.builder() + .reasoningHistory(false) + .build()); + + StepVerifier.create(model.generateText(GenerateTextRequest.builder() + .messages(List.of( + ModelMessage.assistant(List.of(ModelMessagePart.reasoning("thinking"))), + ModelMessage.user("Continue") + )) + .build())) + .expectErrorMessage( + "assistant reasoning history is not supported by the resolved model") + .verify(); + + assertThat(model.capabilities().reasoningHistorySupported()).isFalse(); + verify(chatModel, times(0)).call(any(Prompt.class)); + } + @Test void generateText_mapsReasoningAndReasoningTokens() { var chatModel = mock(ChatModel.class); @@ -3625,11 +3674,18 @@ private void assertPreparedSettings(StepContext context) { private LanguageModelImpl languageModelWithCapabilities(ChatModel chatModel, LanguageCapability languageCapability) { - var context = run.halo.aifoundation.service.model.ModelRuntimeContext.unresolved("openai", + return languageModelWithCapabilities(chatModel, "openai", + LanguageModelProviderOptions.defaults(), languageCapability); + } + + private LanguageModelImpl languageModelWithCapabilities(ChatModel chatModel, + String providerType, LanguageModelProviderOptions providerOptions, + LanguageCapability languageCapability) { + var context = run.halo.aifoundation.service.model.ModelRuntimeContext.unresolved(providerType, "gpt-4o", "vision-model", "openai-provider", run.halo.aifoundation.provider.mapping.RuntimeParameterMappings.empty()); var configuration = new LanguageModelRuntimeConfiguration(context, - LanguageModelProviderOptions.defaults(), ModelCapabilities.language(languageCapability)); + providerOptions, ModelCapabilities.language(languageCapability)); return new LanguageModelImpl(chatModel, LanguageModelRuntimeComposition.create(configuration, new LanguageModelRuntimeSupport(), new MediaResourcePolicy(), new ModelCapabilityMatcher())); diff --git a/app/src/test/java/run/halo/aifoundation/ui/UIMessageConversionValidationTest.java b/app/src/test/java/run/halo/aifoundation/ui/UIMessageConversionValidationTest.java index f4778186..7079e4b2 100644 --- a/app/src/test/java/run/halo/aifoundation/ui/UIMessageConversionValidationTest.java +++ b/app/src/test/java/run/halo/aifoundation/ui/UIMessageConversionValidationTest.java @@ -52,21 +52,15 @@ void convertsToolCallsResultsAndErrors() { )); assertThat(result.warnings()).isEmpty(); - assertThat(result.messages()).hasSize(4); + assertThat(result.messages()).hasSize(2); assertThat(result.messages().get(0).getRole()).isEqualTo(ModelMessageRole.ASSISTANT); assertThat(result.messages().get(0).getContent()).extracting(ModelMessagePart::getType) - .containsExactly(PartType.TEXT, PartType.TOOL_CALL); + .containsExactly(PartType.TEXT, PartType.TOOL_CALL, PartType.TOOL_CALL); assertThat(result.messages().get(1).getRole()).isEqualTo(ModelMessageRole.TOOL); assertThat(result.messages().get(1).getContent()).extracting(ModelMessagePart::getType) - .containsExactly(PartType.TOOL_RESULT); + .containsExactly(PartType.TOOL_RESULT, PartType.TOOL_ERROR); assertThat(result.messages().get(1).getContent().getFirst().getResult()) .isEqualTo(Map.of("temp", 20)); - assertThat(result.messages().get(2).getRole()).isEqualTo(ModelMessageRole.ASSISTANT); - assertThat(result.messages().get(2).getContent()).extracting(ModelMessagePart::getType) - .containsExactly(PartType.TOOL_CALL); - assertThat(result.messages().get(3).getRole()).isEqualTo(ModelMessageRole.TOOL); - assertThat(result.messages().get(3).getContent()).extracting(ModelMessagePart::getType) - .containsExactly(PartType.TOOL_ERROR); } @Test @@ -86,22 +80,16 @@ void convertsDynamicTerminalToolsAndSkipsPendingTools() { assertThat(result.warnings()).extracting(UIMessageConversionWarning::code) .containsExactly("tool.pending-skipped"); - assertThat(result.messages()).hasSize(4); + assertThat(result.messages()).hasSize(2); assertThat(result.messages().get(0).getRole()).isEqualTo(ModelMessageRole.ASSISTANT); assertThat(result.messages().get(0).getContent()).extracting(ModelMessagePart::getType) - .containsExactly(PartType.TEXT, PartType.TOOL_CALL); + .containsExactly(PartType.TEXT, PartType.TOOL_CALL, PartType.TOOL_CALL); assertThat(result.messages().get(1).getRole()).isEqualTo(ModelMessageRole.TOOL); assertThat(result.messages().get(1).getContent()).extracting(ModelMessagePart::getType) - .containsExactly(PartType.TOOL_RESULT); + .containsExactly(PartType.TOOL_RESULT, PartType.TOOL_ERROR); assertThat(result.messages().get(1).getContent().get(0).getResult()) .isEqualTo(Map.of("result", "Halo")); - assertThat(result.messages().get(2).getRole()).isEqualTo(ModelMessageRole.ASSISTANT); - assertThat(result.messages().get(2).getContent()).extracting(ModelMessagePart::getType) - .containsExactly(PartType.TOOL_CALL); - assertThat(result.messages().get(3).getRole()).isEqualTo(ModelMessageRole.TOOL); - assertThat(result.messages().get(3).getContent()).extracting(ModelMessagePart::getType) - .containsExactly(PartType.TOOL_ERROR); - assertThat(result.messages().get(3).getContent().getFirst().getErrorText()) + assertThat(result.messages().get(1).getContent().get(1).getErrorText()) .isEqualTo("Denied"); } @@ -271,20 +259,66 @@ null, new ToolApproval("approval-1", true, "Approved"), )); assertThat(result.warnings()).isEmpty(); - assertThat(result.messages()).hasSize(3); + assertThat(result.messages()).hasSize(2); assertThat(result.messages().get(0).getRole()).isEqualTo(ModelMessageRole.ASSISTANT); assertThat(result.messages().get(0).getContent()).extracting(ModelMessagePart::getType) - .containsExactly(PartType.REASONING, PartType.TOOL_CALL); + .containsExactly(PartType.REASONING, PartType.TOOL_CALL, PartType.TEXT); assertThat(result.messages().get(0).getContent().getFirst().getProviderMetadata()) .isEqualTo(Map.of("signature", "opaque")); assertThat(result.messages().get(1).getRole()).isEqualTo(ModelMessageRole.TOOL); assertThat(result.messages().get(1).getContent()).extracting(ModelMessagePart::getType) .containsExactly(PartType.TOOL_RESULT); + } + + @Test + void preservesExplicitStepBoundariesAndGroupsMultipleToolCalls() { + var result = UIMessageConverters.convertToModelMessages(List.of( + new UIMessage<>("assistant", UIMessageRole.ASSISTANT, List.of( + UIMessageParts.stepStart(), + UIMessageParts.reasoning("reasoning-1", "Need both tools.", Map.of()), + UIMessageParts.tool("call-1", "weather", ToolPartState.OUTPUT_AVAILABLE, + Map.of("city", "Hangzhou"), null, Map.of("temp", 20), null, null, + Map.of()), + UIMessageParts.tool("call-2", "search", ToolPartState.OUTPUT_AVAILABLE, + Map.of("q", "Halo"), null, Map.of("result", "Halo"), null, null, + Map.of()), + UIMessageParts.stepStart(), + UIMessageParts.text("final", "done"), + UIMessageParts.stepStart() + ), new Metadata("chat")) + )); + + assertThat(result.warnings()).isEmpty(); + assertThat(result.messages()).hasSize(3); + assertThat(result.messages().get(0).getContent()).extracting(ModelMessagePart::getType) + .containsExactly(PartType.REASONING, PartType.TOOL_CALL, PartType.TOOL_CALL); + assertThat(result.messages().get(1).getRole()).isEqualTo(ModelMessageRole.TOOL); + assertThat(result.messages().get(1).getContent()).extracting(ModelMessagePart::getType) + .containsExactly(PartType.TOOL_RESULT, PartType.TOOL_RESULT); assertThat(result.messages().get(2).getRole()).isEqualTo(ModelMessageRole.ASSISTANT); assertThat(result.messages().get(2).getContent()).extracting(ModelMessagePart::getType) .containsExactly(PartType.TEXT); } + @Test + void validatesStepStartPartRole() { + var assistant = UIMessageValidators.safeValidate(List.of( + new UIMessage<>("assistant", UIMessageRole.ASSISTANT, + List.of(UIMessageParts.stepStart()), new Metadata("chat")))); + var user = UIMessageValidators.safeValidate(List.of( + new UIMessage<>("user", UIMessageRole.USER, + List.of(UIMessageParts.stepStart()), new Metadata("chat")))); + var system = UIMessageValidators.safeValidate(List.of( + new UIMessage<>("system", UIMessageRole.SYSTEM, + List.of(UIMessageParts.stepStart()), new Metadata("chat")))); + + assertThat(assistant.isValid()).isTrue(); + assertThat(user.issues()).extracting(UIMessageValidationIssue::code) + .containsExactly("part.step-start.role.invalid"); + assertThat(system.issues()).extracting(UIMessageValidationIssue::code) + .containsExactly("part.step-start.role.invalid"); + } + @Test void failsForStrictUnsupportedAndEmptyPolicies() { var dataOnly = List.of(new UIMessage<>("data", UIMessageRole.USER, diff --git a/app/src/test/java/run/halo/aifoundation/ui/UIMessageStreamReaderTest.java b/app/src/test/java/run/halo/aifoundation/ui/UIMessageStreamReaderTest.java index f3780891..b07d20fc 100644 --- a/app/src/test/java/run/halo/aifoundation/ui/UIMessageStreamReaderTest.java +++ b/app/src/test/java/run/halo/aifoundation/ui/UIMessageStreamReaderTest.java @@ -96,6 +96,20 @@ void readerReturnsEmptyResponseMessageWhenNoVisibleParts() { new Metadata("chat-1"))); } + @Test + void readerPersistsStepStartWithoutEmittingVisibleSnapshot() { + var result = UIMessageStreamReader.read(new UIMessageStream(Flux.just( + UIMessageChunks.start("msg-step"), + UIMessageChunks.startStep(0), + UIMessageChunks.finishStep(0, null, null, null, List.of(), null, null, Map.of()), + UIMessageChunks.finish(null, null, null) + ))); + + assertThat(result.messages().collectList().block()).isEmpty(); + assertThat(result.responseMessage().block().parts()) + .containsExactly(UIMessageParts.stepStart()); + } + @Test void readerReplacesStablePartsById() { var result = UIMessageStreamReader.read(new UIMessageStream(Flux.just( @@ -157,6 +171,7 @@ void readerExcludesTransientDataAndLifecycleChunks() { assertThat(result.messages().collectList().block()).hasSize(2); assertThat(result.responseMessage().block().parts()).containsExactly( + UIMessageParts.stepStart(), UIMessageParts.tool("call-1", "weather", ToolPartState.INPUT_STREAMING, null, "{\"city\"", null, null, null, Map.of()) ); diff --git a/app/src/test/java/run/halo/aifoundation/ui/UIMessageTransportCodecTest.java b/app/src/test/java/run/halo/aifoundation/ui/UIMessageTransportCodecTest.java index ba2365e6..76176d04 100644 --- a/app/src/test/java/run/halo/aifoundation/ui/UIMessageTransportCodecTest.java +++ b/app/src/test/java/run/halo/aifoundation/ui/UIMessageTransportCodecTest.java @@ -15,6 +15,7 @@ record Metadata(String chatId) { @Test void decodesAndEncodesAllBuiltInPartTypes() { var parts = List.of( + UIMessageParts.stepStart(), UIMessageParts.text("text-1", "hello"), UIMessageParts.reasoning("reasoning-1", "thinking", Map.of("opaque", "state")), UIMessageParts.data("notice", Map.of("level", "info")), diff --git a/dev/ui-message-stream.md b/dev/ui-message-stream.md index 51d5ea2c..485233dd 100644 --- a/dev/ui-message-stream.md +++ b/dev/ui-message-stream.md @@ -662,7 +662,12 @@ Mono terminal = read.finish(); | `tool-input-available` | 聚合为同一个动态 `tool-*` 消息片段的 `input-available` 状态 | | `tool-output-available` / `tool-output-error` | 聚合为同一个动态 `tool-*` 消息片段的完成状态 | | `tool-approval-request` / `tool-approval-response` | 聚合为同一个动态 `tool-*` 消息片段的审批状态 | -| `start-step` / `finish-step` / `error` / `abort` | 只更新生命周期或终态信息,不进入 `UIMessage.parts` | +| `start-step` | 追加无字段的 `StepStartPart`(传输类型为 `step-start`) | +| `finish-step` / `error` / `abort` | 只更新生命周期或终态信息,不进入 `UIMessage.parts` | + +`StepStartPart` 只记录它在有序 `parts` 中的位置,不保存每次调用都会重新计数的 +`stepIndex`,也不会单独触发一条可见消息。保存 assistant 消息时应原样保留 `parts` 的顺序; +后续转换会用这些 marker 恢复同一步中的 reasoning、文本和多个工具调用。 SSE 传输层使用 `tool-input-*`、`tool-output-*` 和 `tool-approval-*` 这类规范工具数据块 表达“流中发生的事件”。聚合后的 assistant `UIMessage.parts` 仍然使用动态 @@ -672,8 +677,8 @@ SSE 传输层使用 `tool-input-*`、`tool-output-*` 和 `tool-approval-*` 这 ## 工具续跑 `UIMessage` 不使用 `TOOL` 角色。工具生命周期保存在 assistant `UIMessage.parts()` 中的 -动态 `tool-*` 消息片段;转换为模型请求时,SDK 会按消息片段顺序拆成 assistant 和 tool -`ModelMessage`。 +动态 `tool-*` 消息片段;转换为模型请求时,SDK 会按 `StepStartPart` 划分 generation step, +每个非空 step 最多生成一条 assistant `ModelMessage` 和随后一条 tool `ModelMessage`。 外部工具执行成功后,把包含原始 `tool-*` 消息片段的 assistant 消息更新为 `output-available`: @@ -866,6 +871,7 @@ UIMessageConversionResult conversion = | ------------------------------------------------ | --------------------------------------------------------------------- | | `TextPart` | 转为模型文本 | | `ReasoningPart` | 由模型能力决定;支持 reasoning history 时保留,不支持时丢弃并记录警告 | +| `StepStartPart` | 仅划分 generation step,不生成模型内容 | | `ToolPart` + `input-available` | 转为 assistant 工具调用内容 | | `ToolPart` + `approval-requested` | 转为 assistant 审批请求内容 | | `ToolPart` + `output-available` / `output-error` | 转为工具消息 | @@ -873,9 +879,9 @@ UIMessageConversionResult conversion = | `DataPart` | 跳过并记录警告,除非注册转换器 | | `SourceUrlPart` / `FilePart` | 跳过并记录警告 | -转换会保留工具边界。例如 assistant 文本后面出现 `output-available` 工具消息片段,再出现 assistant 文本, -结果会拆成 assistant、tool、assistant 三段 `ModelMessage`。连续的工具响应可以合并在 -同一个工具 `ModelMessage` 中。 +同一个 step 中的 reasoning、文本和多个工具调用会合并到一条 assistant `ModelMessage`, +对应的多个工具结果会合并到随后一条 tool `ModelMessage`。下一个 `StepStartPart` 才会开始新的 +assistant/tool 组合;调用方手动构造且不含 marker 的 assistant 消息按一个隐式 step 处理。 通过 `UIMessageChatHandlers.streamText(model, request, ...)` 调用时,推理默认使用 `UIReasoningConversion.AUTO`。SDK 会根据 `LanguageModel.capabilities()` 自动决定是否 diff --git a/openspec/changes/preserve-ui-message-step-boundaries/.openspec.yaml b/openspec/changes/preserve-ui-message-step-boundaries/.openspec.yaml new file mode 100644 index 00000000..cd2ce7e9 --- /dev/null +++ b/openspec/changes/preserve-ui-message-step-boundaries/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-16 diff --git a/openspec/changes/preserve-ui-message-step-boundaries/design.md b/openspec/changes/preserve-ui-message-step-boundaries/design.md new file mode 100644 index 00000000..a233bcd2 --- /dev/null +++ b/openspec/changes/preserve-ui-message-step-boundaries/design.md @@ -0,0 +1,78 @@ +## Context + +`StreamTextResult.toUIMessageStream()` already emits `StartStepChunk` (`start-step`) for each generation step, but both Java and TypeScript reducers currently discard it. The persisted assistant `UIMessage` therefore contains reasoning and completed tool parts without their generation-step boundary. `UIMessageConverters` flushes an assistant message after each terminal tool part, so multiple tool calls from one step are reconstructed as separate assistant messages. Providers that require reasoning state on the assistant tool-call message can then reject continuation. + +The model resource already exposes nullable `capabilities.language.reasoningHistory`, while providers expose a default `reasoningHistorySupported`. Runtime composition currently uses the provider value directly, so a model override is not consistently honored. The change spans the public Java API, TypeScript SDK, app runtime, Console UI, tests, and developer documentation. + +## Goals / Non-Goals + +**Goals:** + +- Preserve generation-step boundaries in persisted UI messages using the existing stream lifecycle event. +- Rebuild provider-neutral model history with the same assistant/tool grouping used by AI SDK v6. +- Make model-level reasoning-history support an explicit tri-state override with provider fallback. +- Keep validation, conversion, chat handling, and capability reporting on one effective capability value. +- Expose the tri-state setting in the Console and document the public contract concisely. + +**Non-Goals:** + +- Provider-specific branching in `UIMessageConverters`. +- Persisting step finish events or using invocation-local `stepIndex` as a conversation identifier. +- Migrating or heuristically repairing pre-change histories. +- Reproducing the incident narrative or internal reducer/runtime design in the developer guide. + +## Decisions + +### Persist a marker-only `StepStartPart` + +Every `StartStepChunk` appends a public `StepStartPart` whose stable wire discriminator is `step-start`. The part carries no `stepIndex`: step indexes restart for each model invocation and cannot identify a continued step across requests. The marker's position in the ordered part list is the boundary. + +The Java sealed part model, JSON polymorphism, TypeScript union, and both reducers will recognize the part. It is legal only on assistant messages. It is persisted but not visible by default, so a marker alone does not produce an empty message bubble or visible snapshot. + +Alternative considered: infer boundaries from tool completion order. This cannot distinguish multiple calls in one step from separate steps and recreates the current defect. Alternative considered: persist `stepIndex`. Repeated indexes across external continuation make it ambiguous. + +### Convert one step block into at most two model messages + +The converter partitions assistant parts at each `StepStartPart`. A nonempty block produces at most one assistant message containing its reasoning, text, supported files, tool calls, and approval requests, followed by at most one tool message containing matching results, errors, and approval responses. Multiple tool calls in one generation step remain in the same assistant message. + +An assistant UI message without a marker is one implicit step, preserving a simple public construction path without introducing historical-shape heuristics. Empty step blocks are ignored. User and system conversion remains unchanged except that marker parts are rejected by validation. + +Alternative considered: duplicate reasoning onto every split assistant message. That changes model history semantics, is provider-specific in motivation, and can replay reasoning incorrectly. Alternative considered: stop splitting entirely across all steps. That loses the causal assistant/tool sequence between genuinely separate generation steps. + +### Resolve reasoning-history support once as an effective capability + +For a language model, non-null `AiModel.spec.capabilities.language.reasoningHistory` wins. Otherwise the provider's `LanguageModelProviderOptions.reasoningHistorySupported` supplies the default. The resolved value is written into the effective `ModelCapabilities` snapshot and used to create `LanguageModelCapabilities`, `LanguageModelRequestValidator`, `GenerationMessageHistoryAssembler`, and UI-message chat conversion policy. + +This keeps core history conversion provider-neutral. DeepSeek declares the provider default; another provider or an individual model may inherit, explicitly enable, or explicitly disable the behavior. + +Alternative considered: check provider type in the converter. This couples a public data transformation to provider identity and would not handle custom or future providers correctly. + +### Expose one tri-state Console field + +The language capability editor adds a Chinese select with values “继承供应商”, “支持”, and “不支持”, mapped to `null`, `true`, and `false`. It uses the existing generated `LanguageCapability.reasoningHistory` field and remains in the advanced model capability panel. + +### Keep documentation caller-focused + +`dev/ui-message-stream.md` will explain that `start-step` accumulates into `step-start`, that callers must persist the returned ordered parts, and that conversion preserves per-step tool grouping. Capability inheritance belongs in model/API documentation or field help, while implementation details and regression narratives stay in design, code, and tests. + +## Risks / Trade-offs + +- [Pre-change multi-step histories remain ambiguous] → The plugin is unreleased and compatibility was explicitly excluded; no heuristic migration is added. +- [Marker parts could create blank UI snapshots] → Reducers persist the marker but visibility checks explicitly ignore marker-only changes. +- [Capability values diverge across runtime components] → Resolve the effective value before runtime composition and assert all consumers with focused tests. +- [Frontend form serializes inheritance incorrectly] → Normalize the inherited selection to `null`/absence and cover create/edit payload behavior with component or helper tests. +- [Step partitioning changes existing conversion expectations] → Replace the prior per-tool split expectation and add multi-step, multi-tool, implicit-step, and invalid-role regression tests. + +## Migration Plan + +1. Add the public part type and reducer/codec support in Java and TypeScript. +2. Change validation and conversion to consume explicit step boundaries. +3. Resolve effective reasoning-history capability and wire runtime consumers. +4. Add the Console tri-state editor and regenerate API client artifacts only through the project generator if required. +5. Update focused documentation and tests, then run backend and frontend gates. + +Rollback consists of reverting this change as one unit. No persisted-data migration or external dependency must be reversed. + +## Open Questions + +None. The protocol, compatibility boundary, capability precedence, UI behavior, and documentation scope were agreed before implementation. diff --git a/openspec/changes/preserve-ui-message-step-boundaries/proposal.md b/openspec/changes/preserve-ui-message-step-boundaries/proposal.md new file mode 100644 index 00000000..58a09723 --- /dev/null +++ b/openspec/changes/preserve-ui-message-step-boundaries/proposal.md @@ -0,0 +1,43 @@ +## Why + +Persisted `UIMessage` values currently discard generation-step boundaries. When one reasoning step contains multiple tool calls, reconstructing model history can split those calls into separate assistant messages and leave later messages without the reasoning state required by some providers. The protocol needs to preserve the original step structure while keeping conversion provider-neutral. + +## What Changes + +- Persist every `start-step` stream chunk as a public marker-only `step-start` UI message part. +- Reconstruct each assistant generation step as at most one assistant model message followed by at most one tool model message, keeping all tool calls from the same step together. +- Treat an assistant UI message without a `step-start` marker as one implicit step for simple manually constructed messages. +- Reject `step-start` parts on user and system messages, and keep marker-only parts invisible in default message rendering. +- Resolve reasoning-history support from the model-level tri-state override first and the provider default second, then use that effective value consistently throughout validation, conversion, and chat handling. +- Add a Chinese Console control for inheriting, enabling, or disabling reasoning-history support on language models. +- Document only the public step-boundary persistence and reuse contract in the developer UI-message guide. +- **BREAKING**: Existing persisted assistant messages are not migrated or heuristically re-grouped; compatibility with pre-change multi-step histories is intentionally out of scope. + +### Non-goals + +- Add provider-specific branching to the generic UI message converter. +- Add a persisted step-finish part or expose invocation-local step indexes as conversation identity. +- Migrate AI Assistant history or repair previously persisted conversations. +- Put reducer internals, the DeepSeek incident narrative, or capability-resolution implementation details into `dev/ui-message-stream.md`. + +## Capabilities + +### New Capabilities + +None. + +### Modified Capabilities + +- `ui-message-stream`: Persist step-start markers, validate their role, and preserve step boundaries during model-message conversion. +- `model-capability-profile`: Define effective reasoning-history support using a model tri-state override with provider fallback. +- `console-model-management`: Allow administrators to configure the reasoning-history tri-state for language models. +- `consumer-sdk-documentation`: Document the public step-boundary persistence and UI-message reuse contract without internal implementation detail. + +## Impact + +- Public Java UI message part model, stream reducer, validator, converter, and JSON codec tests. +- Public TypeScript SDK UI message types and reducer behavior. +- Language model capability resolution and runtime composition in the app module. +- Generated Console API types and the language-model capability editor. +- UI message conversion, provider request, capability, frontend, and documentation tests. +- No new external runtime dependency and no provider-specific behavior in the core converter. diff --git a/openspec/changes/preserve-ui-message-step-boundaries/specs/console-model-management/spec.md b/openspec/changes/preserve-ui-message-step-boundaries/specs/console-model-management/spec.md new file mode 100644 index 00000000..9b1c3130 --- /dev/null +++ b/openspec/changes/preserve-ui-message-step-boundaries/specs/console-model-management/spec.md @@ -0,0 +1,21 @@ +## ADDED Requirements + +### Requirement: Console configures reasoning-history support +The Console SHALL let super administrators configure a language model's reasoning-history capability as inherited, supported, or unsupported. + +#### Scenario: New model inherits provider behavior +- **WHEN** an administrator leaves reasoning-history support at “继承供应商” +- **THEN** the saved model SHALL keep `capabilities.language.reasoningHistory` unknown +- **AND** runtime behavior SHALL use the provider default + +#### Scenario: Administrator enables model support +- **WHEN** an administrator selects “支持” for reasoning-history support +- **THEN** the saved model SHALL set `capabilities.language.reasoningHistory = true` + +#### Scenario: Administrator disables model support +- **WHEN** an administrator selects “不支持” for reasoning-history support +- **THEN** the saved model SHALL set `capabilities.language.reasoningHistory = false` + +#### Scenario: Existing value is editable +- **WHEN** an administrator edits a language model with an explicit reasoning-history value +- **THEN** the advanced capability editor SHALL display the corresponding tri-state selection diff --git a/openspec/changes/preserve-ui-message-step-boundaries/specs/consumer-sdk-documentation/spec.md b/openspec/changes/preserve-ui-message-step-boundaries/specs/consumer-sdk-documentation/spec.md new file mode 100644 index 00000000..163bf8d1 --- /dev/null +++ b/openspec/changes/preserve-ui-message-step-boundaries/specs/consumer-sdk-documentation/spec.md @@ -0,0 +1,18 @@ +## ADDED Requirements + +### Requirement: UI message documentation explains persisted step boundaries +The developer UI-message guide SHALL document the caller-visible generation-step persistence and reuse contract without internal implementation detail. + +#### Scenario: Persisted marker is documented +- **WHEN** a plugin author reads `dev/ui-message-stream.md` +- **THEN** the guide SHALL explain that stream `start-step` chunks accumulate into marker-only `step-start` parts +- **AND** it SHALL explain that callers must preserve ordered message parts when storing assistant messages + +#### Scenario: Conversion grouping is documented +- **WHEN** a plugin author reads the UI message conversion section +- **THEN** the guide SHALL explain that conversion groups reasoning and multiple tool calls by generation step +- **AND** it SHALL explain that messages without a marker are treated as one implicit step + +#### Scenario: Internal details are excluded +- **WHEN** the developer guide describes step-boundary reuse +- **THEN** it SHALL NOT include reducer internals, incident-specific failure analysis, or runtime capability-resolution algorithms diff --git a/openspec/changes/preserve-ui-message-step-boundaries/specs/model-capability-profile/spec.md b/openspec/changes/preserve-ui-message-step-boundaries/specs/model-capability-profile/spec.md new file mode 100644 index 00000000..8044acc3 --- /dev/null +++ b/openspec/changes/preserve-ui-message-step-boundaries/specs/model-capability-profile/spec.md @@ -0,0 +1,22 @@ +## ADDED Requirements + +### Requirement: Effective reasoning-history capability +The runtime SHALL resolve reasoning-history support from the model-level tri-state override and the provider default into one effective capability value. + +#### Scenario: Explicit model support wins +- **WHEN** a language model sets `capabilities.language.reasoningHistory = true` +- **THEN** the effective language capability SHALL report reasoning-history support +- **AND** reasoning history SHALL be preserved and accepted by runtime history handling + +#### Scenario: Explicit model rejection wins +- **WHEN** a language model sets `capabilities.language.reasoningHistory = false` +- **THEN** the effective language capability SHALL report reasoning-history as unsupported +- **AND** runtime validation SHALL reject caller-provided assistant reasoning history before provider invocation + +#### Scenario: Unknown model value inherits provider default +- **WHEN** a language model omits `capabilities.language.reasoningHistory` or sets it to null +- **THEN** the effective value SHALL equal the provider's reasoning-history default + +#### Scenario: Runtime consumers use the same effective value +- **WHEN** a resolved language model is composed for invocation +- **THEN** capability reporting, request validation, message history assembly, and UI-message chat conversion SHALL use the same effective reasoning-history value diff --git a/openspec/changes/preserve-ui-message-step-boundaries/specs/ui-message-stream/spec.md b/openspec/changes/preserve-ui-message-step-boundaries/specs/ui-message-stream/spec.md new file mode 100644 index 00000000..9caffabd --- /dev/null +++ b/openspec/changes/preserve-ui-message-step-boundaries/specs/ui-message-stream/spec.md @@ -0,0 +1,52 @@ +## ADDED Requirements + +### Requirement: Generation step boundaries are persisted +The SDK SHALL persist generation-step starts in accumulated assistant UI messages using marker-only `step-start` parts. + +#### Scenario: Start step chunk becomes persisted part +- **WHEN** a UI message stream reader receives a `start-step` chunk +- **THEN** it SHALL append a `step-start` part at the same ordered position in the assistant message +- **AND** the part SHALL NOT contain an invocation-local step index + +#### Scenario: First step is marked +- **WHEN** a normal generated assistant stream begins its first generation step +- **THEN** the accumulated assistant message SHALL include the first `step-start` part + +#### Scenario: Marker alone is not visible content +- **WHEN** an assistant message contains only a `step-start` part +- **THEN** the default reader and frontend rendering SHALL NOT emit an empty visible message bubble solely for that marker + +#### Scenario: Step finish remains lifecycle-only +- **WHEN** a UI message stream reader receives a step-finish lifecycle chunk +- **THEN** it SHALL NOT append a persisted step-finish part + +### Requirement: Step-start part role is validated +The SDK SHALL allow `step-start` parts only in assistant UI messages. + +#### Scenario: Assistant marker is valid +- **WHEN** an assistant UI message contains a `step-start` part +- **THEN** UI message validation SHALL accept the marker + +#### Scenario: User or system marker is invalid +- **WHEN** a user or system UI message contains a `step-start` part +- **THEN** UI message validation SHALL reject the message before model invocation + +### Requirement: Model history preserves generation steps +The SDK SHALL convert each assistant generation-step block into at most one assistant model message followed by at most one tool model message. + +#### Scenario: Multiple tool calls stay in one assistant message +- **WHEN** one step block contains reasoning and multiple completed tool calls +- **THEN** conversion SHALL emit one assistant model message containing the reasoning and all tool calls +- **AND** it SHALL emit one following tool model message containing all corresponding tool results + +#### Scenario: Separate steps remain separate +- **WHEN** an assistant UI message contains multiple nonempty blocks separated by `step-start` parts +- **THEN** conversion SHALL preserve the ordered assistant and tool message sequence for each block + +#### Scenario: Message without marker is one implicit step +- **WHEN** a caller constructs an assistant UI message without a `step-start` part +- **THEN** conversion SHALL treat all its parts as one implicit generation step + +#### Scenario: Empty step block is ignored +- **WHEN** consecutive markers or a trailing marker create an empty step block +- **THEN** conversion SHALL NOT emit an empty model message for that block diff --git a/openspec/changes/preserve-ui-message-step-boundaries/tasks.md b/openspec/changes/preserve-ui-message-step-boundaries/tasks.md new file mode 100644 index 00000000..52bc79a7 --- /dev/null +++ b/openspec/changes/preserve-ui-message-step-boundaries/tasks.md @@ -0,0 +1,30 @@ +## 1. Persisted Step Protocol + +- [x] 1.1 Add marker-only `StepStartPart` to the Java UI message part model and JSON polymorphic contract +- [x] 1.2 Persist `StartStepChunk` in the Java reducer without making marker-only state visible +- [x] 1.3 Add `step-start` to the TypeScript SDK part union and persist it in the frontend reducer without creating blank visible content +- [x] 1.4 Validate that `step-start` is accepted only on assistant UI messages + +## 2. Step-Aware Model History + +- [x] 2.1 Refactor assistant UI-message conversion to partition parts by generation step +- [x] 2.2 Emit at most one assistant and one tool model message per nonempty step while preserving all calls and results +- [x] 2.3 Cover multi-tool, multi-step, implicit-step, empty-step, codec, reducer, and invalid-role behavior with focused tests + +## 3. Effective Reasoning-History Capability + +- [x] 3.1 Resolve model-level `reasoningHistory` overrides over provider defaults in the effective capability snapshot +- [x] 3.2 Feed the effective value consistently into capability reporting, request validation, history assembly, and UI-message chat handling +- [x] 3.3 Add runtime and provider-request regression tests for enabled, disabled, inherited, DeepSeek, and non-reasoning cases + +## 4. Console Configuration + +- [x] 4.1 Add a Chinese tri-state reasoning-history field to the language-model advanced capability editor +- [x] 4.2 Preserve null, true, and false values through model form initialization and submission +- [x] 4.3 Add focused frontend tests for inherited, supported, and unsupported selections + +## 5. Documentation and Verification + +- [x] 5.1 Update `dev/ui-message-stream.md` with only the public step-boundary persistence and reuse contract +- [x] 5.2 Run OpenSpec validation plus targeted backend and frontend tests, type checks, and formatting/lint gates +- [x] 5.3 Review the final diff for provider neutrality, generated-file policy, and absence of compatibility heuristics diff --git a/ui/packages/sdk/src/core.test.ts b/ui/packages/sdk/src/core.test.ts index 4cd4089c..1cc33c35 100644 --- a/ui/packages/sdk/src/core.test.ts +++ b/ui/packages/sdk/src/core.test.ts @@ -106,7 +106,7 @@ describe('UI message reducer', () => { expect(state.terminal.finishReason).toBe('stop') }) - it('reduces canonical tool chunks and keeps start-step lifecycle-only', () => { + it('persists start-step markers before canonical tool parts', () => { const state = createUIMessageReducer({ messageId: 'assistant-1' }) for (const chunk of [ @@ -158,6 +158,7 @@ describe('UI message reducer', () => { } expect(state.message.parts).toEqual([ + { type: 'step-start' }, { type: 'tool-search', toolCallId: 'call-1', @@ -197,6 +198,20 @@ describe('UI message reducer', () => { ]) }) + it('persists start-step without making a marker-only message visible', () => { + const state = createUIMessageReducer({ messageId: 'assistant-1' }) + + applyUIMessageChunk(state, { type: 'start-step', stepIndex: 0 }) + + expect(state.message.parts).toEqual([{ type: 'step-start' }]) + expect(state.visible).toBe(false) + + const existing = createUIMessageReducer({ + message: { id: 'assistant-2', role: 'assistant', parts: [{ type: 'step-start' }] }, + }) + expect(existing.visible).toBe(false) + }) + it('validates dynamic data and tool chunk protocol', () => { expect(() => validateUIMessageChunk({ @@ -711,6 +726,7 @@ describe('UI message persistence helpers', () => { id: 'assistant-1', role: 'assistant', parts: [ + { type: 'step-start' }, { type: 'tool-search', toolCallId: 'pending', @@ -820,6 +836,24 @@ describe('UI message persistence helpers', () => { ).toThrow(AIUIMessageValidationError) }) + it('allows step-start only in assistant messages', () => { + const assistantIssues = validateUIMessages([ + { id: 'assistant-1', role: 'assistant', parts: [{ type: 'step-start' }] }, + ]) + const userIssues = validateUIMessages([ + { id: 'user-1', role: 'user', parts: [{ type: 'step-start' }] }, + ]) + + expect(assistantIssues).toEqual([]) + expect(userIssues).toEqual([ + { + path: '$[0].parts[0].type', + code: 'part.step-start.role.invalid', + message: 'Step-start parts are only allowed in assistant messages.', + }, + ]) + }) + it('uses metadata and data schemas during validation', () => { const valid = validateUIMessages( [ diff --git a/ui/packages/sdk/src/message-reducer.ts b/ui/packages/sdk/src/message-reducer.ts index a6f4ecaf..d9433706 100644 --- a/ui/packages/sdk/src/message-reducer.ts +++ b/ui/packages/sdk/src/message-reducer.ts @@ -41,7 +41,7 @@ export function createUIMessageReducer( metadata: options.metadata, }, terminal: {}, - visible: Boolean(options.message?.parts.length), + visible: Boolean(options.message?.parts.some(isPersistedVisiblePart)), messageMetadataSchema: options.messageMetadataSchema, dataPartSchemas: options.dataPartSchemas, } @@ -140,6 +140,7 @@ export function applyUIMessageChunk( break } case 'start-step': + upsertPart(state, { type: 'step-start' }) break case 'finish-step': state.terminal = { @@ -371,7 +372,7 @@ function samePartIdentity(left: UIMessagePart, right: UIMessagePart): boolean { } function isPersistedVisiblePart(part: UIMessagePart): boolean { - return !isDataPart(part) || !part.transientData + return part.type !== 'step-start' && (!isDataPart(part) || !part.transientData) } function finishTerminal( diff --git a/ui/packages/sdk/src/persistence.ts b/ui/packages/sdk/src/persistence.ts index 07590540..c8e469ae 100644 --- a/ui/packages/sdk/src/persistence.ts +++ b/ui/packages/sdk/src/persistence.ts @@ -44,7 +44,7 @@ export function pruneMessages( const parts = removePendingToolParts ? message.parts.filter((part) => !isPendingToolPart(part)) : [...message.parts] - if (parts.length === 0) { + if (!parts.some((part) => part.type !== 'step-start')) { continue } pruned.push({ ...message, parts: parts.map((part) => ({ ...part }) as UIMessagePart) }) @@ -109,12 +109,13 @@ function validateMessage( } } message.parts.forEach((part, partIndex) => { - validatePart(part, `${path}.parts[${partIndex}]`, options, issues) + validatePart(part, message.role, `${path}.parts[${partIndex}]`, options, issues) }) } function validatePart( part: UIMessagePart, + role: UIMessageRole, path: string, options: ValidateUIMessagesOptions, issues: UIMessageValidationIssue[], @@ -136,6 +137,17 @@ function validatePart( return } switch (part.type) { + case 'step-start': + if (role !== 'assistant') { + issues.push( + issue( + `${path}.type`, + 'part.step-start.role.invalid', + 'Step-start parts are only allowed in assistant messages.', + ), + ) + } + break case 'text': requireString( part.id, diff --git a/ui/packages/sdk/src/types.ts b/ui/packages/sdk/src/types.ts index 34803c29..f5c6dbdf 100644 --- a/ui/packages/sdk/src/types.ts +++ b/ui/packages/sdk/src/types.ts @@ -29,6 +29,7 @@ export interface UIMessage { } export type UIMessagePart = + | StepStartPart | TextPart | ReasoningPart | DataPart @@ -37,6 +38,10 @@ export type UIMessagePart = | FilePart | ToolPart +export interface StepStartPart { + type: 'step-start' +} + export interface TextPart { type: 'text' id: string diff --git a/ui/src/utils/boolean-capability-form.test.ts b/ui/src/utils/boolean-capability-form.test.ts new file mode 100644 index 00000000..ee8439b8 --- /dev/null +++ b/ui/src/utils/boolean-capability-form.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from '@rstest/core' +import { booleanCapabilityFormValue, booleanCapabilityValue } from './boolean-capability-form' + +describe('boolean capability form values', () => { + it.each([ + { label: '继承供应商', formValue: '', capabilityValue: undefined }, + { label: '支持', formValue: 'true', capabilityValue: true }, + { label: '不支持', formValue: 'false', capabilityValue: false }, + ])('round-trips $label', ({ formValue, capabilityValue }) => { + expect(booleanCapabilityValue(formValue)).toBe(capabilityValue) + expect(booleanCapabilityFormValue(capabilityValue)).toBe(formValue) + }) +}) diff --git a/ui/src/utils/boolean-capability-form.ts b/ui/src/utils/boolean-capability-form.ts new file mode 100644 index 00000000..bbaafafe --- /dev/null +++ b/ui/src/utils/boolean-capability-form.ts @@ -0,0 +1,19 @@ +export function booleanCapabilityValue(value: unknown): boolean | undefined { + if (value === true || value === 'true') { + return true + } + if (value === false || value === 'false') { + return false + } + return undefined +} + +export function booleanCapabilityFormValue(value?: boolean): '' | 'true' | 'false' { + if (value === true) { + return 'true' + } + if (value === false) { + return 'false' + } + return '' +} diff --git a/ui/src/views/components/ModelForm.vue b/ui/src/views/components/ModelForm.vue index 1ff96f5d..f7f1ede9 100644 --- a/ui/src/views/components/ModelForm.vue +++ b/ui/src/views/components/ModelForm.vue @@ -16,6 +16,7 @@ import { } from '@/api/generated' import { useProviderTypesFetch } from '@/composables/use-provider-types-fetch' import type { ModelFormState } from '@/types/form' +import { booleanCapabilityFormValue, booleanCapabilityValue } from '@/utils/boolean-capability-form' import { capabilityDomainSource, capabilitySourceLabel, @@ -86,6 +87,12 @@ const booleanCapabilityOptions = [ { label: '不支持', value: 'false' }, ] +const reasoningHistoryOptions = [ + { label: '继承供应商', value: '' }, + { label: '支持', value: 'true' }, + { label: '不支持', value: 'false' }, +] + const inputSourceOptions = [ { label: 'Base64 / Data', value: LanguageCapabilityInputSourcesEnum.Data }, { label: 'URL', value: LanguageCapabilityInputSourcesEnum.Url }, @@ -95,7 +102,7 @@ const selectedFeatures = ref( props.formState?.features ? [...props.formState.features] : [], ) const languageFileInputValue = ref( - booleanFormValue(props.formState?.capabilities?.language?.fileInput), + booleanCapabilityFormValue(props.formState?.capabilities?.language?.fileInput), ) const hasImageRecognitionFeature = computed(() => @@ -192,7 +199,7 @@ watch( watch( () => props.formState?.capabilities?.language?.fileInput, (value) => { - languageFileInputValue.value = booleanFormValue(value) + languageFileInputValue.value = booleanCapabilityFormValue(value) }, ) @@ -230,6 +237,7 @@ defineExpose({ interface ModelFormRawState extends ModelFormState { languageFileInput?: boolean | string + languageReasoningHistory?: boolean | string languageInputMediaTypes?: string languageInputSources?: string | string[] imageGenerationTextToImage?: boolean | string @@ -258,13 +266,14 @@ function buildCapabilities(data: ModelFormRawState): AiModel['spec']['capabiliti } function buildLanguageCapability(data: ModelFormRawState): LanguageCapability | undefined { - const fileInput = booleanValue(data.languageFileInput) + const fileInput = booleanCapabilityValue(data.languageFileInput) const shouldKeepMediaDetails = hasFeature(data.features, ModelFeature.Vision) || hasFeature(data.features, ModelFeature.AudioInput) || fileInput === true const domain: LanguageCapability = { fileInput, + reasoningHistory: booleanCapabilityValue(data.languageReasoningHistory), inputMediaTypes: shouldKeepMediaDetails ? listValue(data.languageInputMediaTypes) : undefined, inputSources: shouldKeepMediaDetails ? (listValue(data.languageInputSources) as LanguageCapability['inputSources']) @@ -277,9 +286,9 @@ function buildImageGenerationCapability( data: ModelFormRawState, ): ImageGenerationCapability | undefined { const domain: ImageGenerationCapability = { - textToImage: booleanValue(data.imageGenerationTextToImage), - imageToImage: booleanValue(data.imageGenerationImageToImage), - maskInput: booleanValue(data.imageGenerationMaskInput), + textToImage: booleanCapabilityValue(data.imageGenerationTextToImage), + imageToImage: booleanCapabilityValue(data.imageGenerationImageToImage), + maskInput: booleanCapabilityValue(data.imageGenerationMaskInput), maxImagesPerCall: positiveIntegerValue(data.imageGenerationMaxImagesPerCall), sizes: listValue(data.imageGenerationSizes), aspectRatios: listValue(data.imageGenerationAspectRatios), @@ -320,16 +329,6 @@ function sourceFor( : ModelCapabilitySourcesImageGenerationEnum.Manual } -function booleanValue(value: unknown) { - if (value === true || value === 'true') { - return true - } - if (value === false || value === 'false') { - return false - } - return undefined -} - function listValue(value: unknown) { const values = Array.isArray(value) ? value @@ -348,16 +347,6 @@ function positiveIntegerValue(value: unknown) { return Number.isInteger(number) && number > 0 ? number : undefined } -function booleanFormValue(value?: boolean) { - if (value === true) { - return 'true' - } - if (value === false) { - return 'false' - } - return '' -} - function listFormValue(values?: string[]) { return values?.join('\n') || '' } @@ -367,8 +356,7 @@ function onFeaturesInput(value: unknown) { } function onLanguageFileInput(value: unknown) { - languageFileInputValue.value = - typeof value === 'string' ? value : booleanFormValue(value as boolean) + languageFileInputValue.value = booleanCapabilityFormValue(booleanCapabilityValue(value)) } function hasFeature(features: ModelFormRawState['features'], feature: AiModelSpecFeaturesEnum) { @@ -465,6 +453,14 @@ function sameJson(a: unknown, b: unknown) { :value="selectedFeatures" @input="onFeaturesInput" /> + + {{ option.label }} `, @@ -40,6 +41,9 @@ describe('model group removal', () => { expect(wrapper.text()).not.toContain('分组') expect(wrapper.find('input[name="group"]').exists()).toBe(false) + expect(wrapper.text()).toContain('支持推理历史回传') + expect(wrapper.text()).toContain('继承供应商') + expect(wrapper.find('input[name="languageReasoningHistory"]').exists()).toBe(true) }) it('keeps parameter mappings and model capabilities in sibling panels', () => {