mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
refactor: remove reasoning title extraction from chat pipeline (#22926)
Removes the backend and frontend logic that extracted compact titles from reasoning/thinking blocks. The `Title` field on `ChatMessagePart` remains for other part types (e.g. source), but reasoning blocks no longer have titles derived from first-line markdown bold text or provider metadata summaries. **Backend:** - Remove `ReasoningTitleFromFirstLine`, `reasoningTitleFromContent`, `reasoningSummaryTitle`, `compactReasoningSummaryTitle`, and `reasoningSummaryHeadline` from chatprompt - Simplify `marshalContentBlock` to plain `json.Marshal` (no title injection) - Remove title tracking maps and `setReasoningTitleFromText` from chatloop stream processing - Remove `reasoningStoredTitle` from db2sdk - Remove related tests from db2sdk_test **Frontend:** - Remove `mergeThinkingTitles` from blockUtils - Simplify `appendTextBlock` to always merge consecutive thinking blocks - Remove `applyStreamThinkingTitle` from streamState - Simplify reasoning/thinking stream handler to ignore title-only parts - Update tests accordingly Net: **-487 lines / +42 lines**
This commit is contained in:
@@ -428,27 +428,6 @@ func processStepStream(
|
||||
activeReasoningContent := make(map[string]reasoningState)
|
||||
// Track tool names by ID for input delta publishing.
|
||||
toolNames := make(map[string]string)
|
||||
// Track reasoning text/titles for title extraction.
|
||||
reasoningTitles := make(map[string]string)
|
||||
reasoningText := make(map[string]string)
|
||||
|
||||
setReasoningTitleFromText := func(id string, text string) {
|
||||
if id == "" || strings.TrimSpace(text) == "" {
|
||||
return
|
||||
}
|
||||
if reasoningTitles[id] != "" {
|
||||
return
|
||||
}
|
||||
reasoningText[id] += text
|
||||
if !strings.ContainsAny(reasoningText[id], "\r\n") {
|
||||
return
|
||||
}
|
||||
title := chatprompt.ReasoningTitleFromFirstLine(reasoningText[id])
|
||||
if title == "" {
|
||||
return
|
||||
}
|
||||
reasoningTitles[id] = title
|
||||
}
|
||||
|
||||
for part := range stream {
|
||||
switch part.Type {
|
||||
@@ -485,12 +464,9 @@ func processStepStream(
|
||||
active.options = part.ProviderMetadata
|
||||
activeReasoningContent[part.ID] = active
|
||||
}
|
||||
setReasoningTitleFromText(part.ID, part.Delta)
|
||||
title := reasoningTitles[part.ID]
|
||||
publishMessagePart(fantasy.MessageRoleAssistant, codersdk.ChatMessagePart{
|
||||
Type: codersdk.ChatMessagePartTypeReasoning,
|
||||
Text: part.Delta,
|
||||
Title: title,
|
||||
Type: codersdk.ChatMessagePartTypeReasoning,
|
||||
Text: part.Delta,
|
||||
})
|
||||
|
||||
case fantasy.StreamPartTypeReasoningEnd:
|
||||
@@ -504,21 +480,6 @@ func processStepStream(
|
||||
}
|
||||
result.content = append(result.content, content)
|
||||
delete(activeReasoningContent, part.ID)
|
||||
|
||||
// Derive reasoning title at end of reasoning
|
||||
// block if we haven't yet.
|
||||
if reasoningTitles[part.ID] == "" {
|
||||
reasoningTitles[part.ID] = chatprompt.ReasoningTitleFromFirstLine(
|
||||
reasoningText[part.ID],
|
||||
)
|
||||
}
|
||||
title := reasoningTitles[part.ID]
|
||||
if title != "" {
|
||||
publishMessagePart(fantasy.MessageRoleAssistant, codersdk.ChatMessagePart{
|
||||
Type: codersdk.ChatMessagePartTypeReasoning,
|
||||
Title: title,
|
||||
})
|
||||
}
|
||||
}
|
||||
case fantasy.StreamPartTypeToolInputStart:
|
||||
activeToolCalls[part.ID] = &fantasy.ToolCallContent{
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"strings"
|
||||
|
||||
"charm.land/fantasy"
|
||||
fantasyopenai "charm.land/fantasy/providers/openai"
|
||||
"github.com/google/uuid"
|
||||
"github.com/sqlc-dev/pqtype"
|
||||
"golang.org/x/xerrors"
|
||||
@@ -588,8 +587,7 @@ func MarshalContent(blocks []fantasy.Content, fileIDs map[int]uuid.UUID) (pqtype
|
||||
}
|
||||
|
||||
// injectFileID adds a file_id field into the data sub-object of a
|
||||
// serialized content block envelope. This follows the same pattern
|
||||
// as the reasoning title injection in marshalContentBlock.
|
||||
// serialized content block envelope.
|
||||
func injectFileID(encoded json.RawMessage, fileID uuid.UUID) (json.RawMessage, error) {
|
||||
var envelope struct {
|
||||
Type string `json:"type"`
|
||||
@@ -673,15 +671,13 @@ func PartFromContent(block fantasy.Content) codersdk.ChatMessagePart {
|
||||
}
|
||||
case fantasy.ReasoningContent:
|
||||
return codersdk.ChatMessagePart{
|
||||
Type: codersdk.ChatMessagePartTypeReasoning,
|
||||
Text: value.Text,
|
||||
Title: reasoningSummaryTitle(value.ProviderMetadata),
|
||||
Type: codersdk.ChatMessagePartTypeReasoning,
|
||||
Text: value.Text,
|
||||
}
|
||||
case *fantasy.ReasoningContent:
|
||||
return codersdk.ChatMessagePart{
|
||||
Type: codersdk.ChatMessagePartTypeReasoning,
|
||||
Text: value.Text,
|
||||
Title: reasoningSummaryTitle(value.ProviderMetadata),
|
||||
Type: codersdk.ChatMessagePartTypeReasoning,
|
||||
Text: value.Text,
|
||||
}
|
||||
case fantasy.ToolCallContent:
|
||||
return codersdk.ChatMessagePart{
|
||||
@@ -778,43 +774,6 @@ func toolResultContentToPart(content fantasy.ToolResultContent) codersdk.ChatMes
|
||||
return ToolResultToPart(content.ToolCallID, content.ToolName, result, isError)
|
||||
}
|
||||
|
||||
// ReasoningTitleFromFirstLine extracts a compact markdown title.
|
||||
func ReasoningTitleFromFirstLine(text string) string {
|
||||
text = strings.TrimSpace(text)
|
||||
if text == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
firstLine := text
|
||||
if idx := strings.IndexAny(firstLine, "\r\n"); idx >= 0 {
|
||||
firstLine = firstLine[:idx]
|
||||
}
|
||||
firstLine = strings.TrimSpace(firstLine)
|
||||
if firstLine == "" || !strings.HasPrefix(firstLine, "**") {
|
||||
return ""
|
||||
}
|
||||
|
||||
rest := firstLine[2:]
|
||||
end := strings.Index(rest, "**")
|
||||
if end < 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
title := strings.TrimSpace(rest[:end])
|
||||
if title == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Require the first line to be exactly "**title**" (ignoring
|
||||
// surrounding whitespace) so providers without this format don't
|
||||
// accidentally emit a title.
|
||||
if strings.TrimSpace(rest[end+2:]) != "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
return compactReasoningSummaryTitle(title)
|
||||
}
|
||||
|
||||
func injectMissingToolResults(prompt []fantasy.Message) []fantasy.Message {
|
||||
result := make([]fantasy.Message, 0, len(prompt))
|
||||
for i := 0; i < len(prompt); i++ {
|
||||
@@ -1019,147 +978,5 @@ func sanitizeToolCallID(id string) string {
|
||||
}
|
||||
|
||||
func marshalContentBlock(block fantasy.Content) (json.RawMessage, error) {
|
||||
encoded, err := json.Marshal(block)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
title, ok := reasoningTitleFromContent(block)
|
||||
if !ok || title == "" {
|
||||
return encoded, nil
|
||||
}
|
||||
|
||||
var envelope struct {
|
||||
Type string `json:"type"`
|
||||
Data map[string]any `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(encoded, &envelope); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !strings.EqualFold(envelope.Type, string(fantasy.ContentTypeReasoning)) {
|
||||
return encoded, nil
|
||||
}
|
||||
if envelope.Data == nil {
|
||||
envelope.Data = map[string]any{}
|
||||
}
|
||||
envelope.Data["title"] = title
|
||||
|
||||
encodedWithTitle, err := json.Marshal(envelope)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return encodedWithTitle, nil
|
||||
}
|
||||
|
||||
func reasoningTitleFromContent(block fantasy.Content) (string, bool) {
|
||||
switch value := block.(type) {
|
||||
case fantasy.ReasoningContent:
|
||||
return ReasoningTitleFromFirstLine(value.Text), true
|
||||
case *fantasy.ReasoningContent:
|
||||
if value == nil {
|
||||
return "", false
|
||||
}
|
||||
return ReasoningTitleFromFirstLine(value.Text), true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
func reasoningSummaryTitle(metadata fantasy.ProviderMetadata) string {
|
||||
if len(metadata) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
reasoningMetadata := fantasyopenai.GetReasoningMetadata(
|
||||
fantasy.ProviderOptions(metadata),
|
||||
)
|
||||
if reasoningMetadata == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
for _, summary := range reasoningMetadata.Summary {
|
||||
if title := compactReasoningSummaryTitle(summary); title != "" {
|
||||
return title
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func compactReasoningSummaryTitle(summary string) string {
|
||||
const maxWords = 8
|
||||
const maxRunes = 80
|
||||
|
||||
summary = strings.TrimSpace(summary)
|
||||
if summary == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
summary = strings.Trim(summary, "\"'`")
|
||||
summary = reasoningSummaryHeadline(summary)
|
||||
words := strings.Fields(summary)
|
||||
if len(words) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
truncated := false
|
||||
if len(words) > maxWords {
|
||||
words = words[:maxWords]
|
||||
truncated = true
|
||||
}
|
||||
|
||||
title := strings.Join(words, " ")
|
||||
if truncated {
|
||||
title += "…"
|
||||
}
|
||||
return truncateRunes(title, maxRunes)
|
||||
}
|
||||
|
||||
func reasoningSummaryHeadline(summary string) string {
|
||||
summary = strings.TrimSpace(summary)
|
||||
if summary == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
// OpenAI summary_text may be markdown like:
|
||||
// "**Title**\n\nLonger explanation ...".
|
||||
// Keep only the heading segment for UI titles.
|
||||
if idx := strings.Index(summary, "\n\n"); idx >= 0 {
|
||||
summary = summary[:idx]
|
||||
}
|
||||
|
||||
if idx := strings.IndexAny(summary, "\r\n"); idx >= 0 {
|
||||
summary = summary[:idx]
|
||||
}
|
||||
|
||||
summary = strings.TrimSpace(summary)
|
||||
if summary == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
if strings.HasPrefix(summary, "**") {
|
||||
rest := summary[2:]
|
||||
if end := strings.Index(rest, "**"); end >= 0 {
|
||||
bold := strings.TrimSpace(rest[:end])
|
||||
if bold != "" {
|
||||
summary = bold
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return strings.TrimSpace(strings.Trim(summary, "\"'`"))
|
||||
}
|
||||
|
||||
func truncateRunes(value string, maxLen int) string {
|
||||
if maxLen <= 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
runes := []rune(value)
|
||||
if len(runes) <= maxLen {
|
||||
return value
|
||||
}
|
||||
|
||||
return string(runes[:maxLen])
|
||||
return json.Marshal(block)
|
||||
}
|
||||
|
||||
@@ -1165,10 +1165,7 @@ func chatMessageParts(role string, raw pqtype.NullRawMessage) ([]codersdk.ChatMe
|
||||
continue
|
||||
}
|
||||
if i < len(rawBlocks) {
|
||||
switch part.Type {
|
||||
case codersdk.ChatMessagePartTypeReasoning:
|
||||
part.Title = reasoningStoredTitle(rawBlocks[i])
|
||||
case codersdk.ChatMessagePartTypeFile:
|
||||
if part.Type == codersdk.ChatMessagePartTypeFile {
|
||||
if fid, err := chatprompt.ExtractFileID(rawBlocks[i]); err == nil {
|
||||
part.FileID = uuid.NullUUID{UUID: fid, Valid: true}
|
||||
}
|
||||
@@ -1267,22 +1264,6 @@ func parseToolResults(raw pqtype.NullRawMessage) ([]toolResultRow, error) {
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func reasoningStoredTitle(raw json.RawMessage) string {
|
||||
var envelope struct {
|
||||
Type string `json:"type"`
|
||||
Data struct {
|
||||
Title string `json:"title"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &envelope); err != nil {
|
||||
return ""
|
||||
}
|
||||
if !strings.EqualFold(envelope.Type, string(fantasy.ContentTypeReasoning)) {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(envelope.Data.Title)
|
||||
}
|
||||
|
||||
func contentBlockToPart(block fantasy.Content) codersdk.ChatMessagePart {
|
||||
switch value := block.(type) {
|
||||
case fantasy.TextContent:
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"time"
|
||||
|
||||
"charm.land/fantasy"
|
||||
fantasyopenai "charm.land/fantasy/providers/openai"
|
||||
"github.com/google/uuid"
|
||||
"github.com/sqlc-dev/pqtype"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -438,82 +437,6 @@ func TestAIBridgeInterception(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatMessage_ReasoningPartWithoutPersistedTitleIsEmpty(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
assistantContent, err := json.Marshal([]fantasy.Content{
|
||||
fantasy.ReasoningContent{
|
||||
Text: "Plan migration",
|
||||
ProviderMetadata: fantasy.ProviderMetadata{
|
||||
fantasyopenai.Name: &fantasyopenai.ResponsesReasoningMetadata{
|
||||
ItemID: "reasoning-1",
|
||||
Summary: []string{"Plan migration"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
message := db2sdk.ChatMessage(database.ChatMessage{
|
||||
ID: 1,
|
||||
ChatID: uuid.New(),
|
||||
CreatedAt: time.Now(),
|
||||
Role: string(fantasy.MessageRoleAssistant),
|
||||
Content: pqtype.NullRawMessage{
|
||||
RawMessage: assistantContent,
|
||||
Valid: true,
|
||||
},
|
||||
})
|
||||
|
||||
require.Len(t, message.Content, 1)
|
||||
require.Equal(t, codersdk.ChatMessagePartTypeReasoning, message.Content[0].Type)
|
||||
require.Equal(t, "Plan migration", message.Content[0].Text)
|
||||
require.Empty(t, message.Content[0].Title)
|
||||
}
|
||||
|
||||
func TestChatMessage_ReasoningPartPrefersPersistedTitle(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
reasoningContent, err := json.Marshal(fantasy.ReasoningContent{
|
||||
Text: "Verify schema updates, then apply changes in order.",
|
||||
ProviderMetadata: fantasy.ProviderMetadata{
|
||||
fantasyopenai.Name: &fantasyopenai.ResponsesReasoningMetadata{
|
||||
ItemID: "reasoning-1",
|
||||
Summary: []string{
|
||||
"**Metadata-derived title**\n\nLonger explanation.",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
var envelope map[string]any
|
||||
require.NoError(t, json.Unmarshal(reasoningContent, &envelope))
|
||||
dataValue, ok := envelope["data"].(map[string]any)
|
||||
require.True(t, ok)
|
||||
dataValue["title"] = "Persisted stream title"
|
||||
|
||||
encodedReasoning, err := json.Marshal(envelope)
|
||||
require.NoError(t, err)
|
||||
assistantContent, err := json.Marshal([]json.RawMessage{encodedReasoning})
|
||||
require.NoError(t, err)
|
||||
|
||||
message := db2sdk.ChatMessage(database.ChatMessage{
|
||||
ID: 1,
|
||||
ChatID: uuid.New(),
|
||||
CreatedAt: time.Now(),
|
||||
Role: string(fantasy.MessageRoleAssistant),
|
||||
Content: pqtype.NullRawMessage{
|
||||
RawMessage: assistantContent,
|
||||
Valid: true,
|
||||
},
|
||||
})
|
||||
|
||||
require.Len(t, message.Content, 1)
|
||||
require.Equal(t, codersdk.ChatMessagePartTypeReasoning, message.Content[0].Type)
|
||||
require.Equal(t, "Persisted stream title", message.Content[0].Title)
|
||||
}
|
||||
|
||||
func TestChatQueuedMessage_ParsesUserContentParts(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
+2
-4
@@ -24,15 +24,13 @@
|
||||
{
|
||||
"workspace_folder": "/workspace1",
|
||||
"name": "dev1",
|
||||
"id": "eb9b7f18-c277-48af-af7c-2a8e5fb42bab",
|
||||
"subagent_id": "72d17819-ea3b-450a-a502-175886583ecf"
|
||||
"id": "eb9b7f18-c277-48af-af7c-2a8e5fb42bab"
|
||||
},
|
||||
{
|
||||
"workspace_folder": "/workspace2",
|
||||
"config_path": "/workspace2/.devcontainer/devcontainer.json",
|
||||
"name": "dev2",
|
||||
"id": "964430ff-f0d9-4fcb-b645-6333cf6ba9f2",
|
||||
"subagent_id": "40a59d56-d3df-488f-b07d-331c0b774bac"
|
||||
"id": "964430ff-f0d9-4fcb-b645-6333cf6ba9f2"
|
||||
}
|
||||
],
|
||||
"api_key_scope": "all"
|
||||
|
||||
+2
-4
@@ -139,8 +139,7 @@
|
||||
},
|
||||
"after_unknown": {
|
||||
"agent_id": true,
|
||||
"id": true,
|
||||
"subagent_id": true
|
||||
"id": true
|
||||
},
|
||||
"before_sensitive": false,
|
||||
"after_sensitive": {}
|
||||
@@ -163,8 +162,7 @@
|
||||
},
|
||||
"after_unknown": {
|
||||
"agent_id": true,
|
||||
"id": true,
|
||||
"subagent_id": true
|
||||
"id": true
|
||||
},
|
||||
"before_sensitive": false,
|
||||
"after_sensitive": {}
|
||||
|
||||
-2
@@ -60,7 +60,6 @@
|
||||
"agent_id": "eb1fa705-34c6-405b-a2ec-70e4efd1614e",
|
||||
"config_path": null,
|
||||
"id": "eb9b7f18-c277-48af-af7c-2a8e5fb42bab",
|
||||
"subagent_id": "72d17819-ea3b-450a-a502-175886583ecf",
|
||||
"workspace_folder": "/workspace1"
|
||||
},
|
||||
"sensitive_values": {},
|
||||
@@ -79,7 +78,6 @@
|
||||
"agent_id": "eb1fa705-34c6-405b-a2ec-70e4efd1614e",
|
||||
"config_path": "/workspace2/.devcontainer/devcontainer.json",
|
||||
"id": "964430ff-f0d9-4fcb-b645-6333cf6ba9f2",
|
||||
"subagent_id": "40a59d56-d3df-488f-b07d-331c0b774bac",
|
||||
"workspace_folder": "/workspace2"
|
||||
},
|
||||
"sensitive_values": {},
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
appendTextBlock,
|
||||
asNonEmptyString,
|
||||
mergeThinkingTitles,
|
||||
} from "./blockUtils";
|
||||
import { appendTextBlock, asNonEmptyString } from "./blockUtils";
|
||||
import type { RenderBlock } from "./types";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -36,61 +32,6 @@ describe("asNonEmptyString", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// mergeThinkingTitles
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("mergeThinkingTitles", () => {
|
||||
it("merges when both titles are undefined", () => {
|
||||
expect(mergeThinkingTitles(undefined, undefined)).toEqual({
|
||||
shouldMerge: true,
|
||||
title: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("merges and picks nextTitle when current is undefined", () => {
|
||||
expect(mergeThinkingTitles(undefined, "Thinking")).toEqual({
|
||||
shouldMerge: true,
|
||||
title: "Thinking",
|
||||
});
|
||||
});
|
||||
|
||||
it("merges and keeps currentTitle when next is undefined", () => {
|
||||
expect(mergeThinkingTitles("Thinking", undefined)).toEqual({
|
||||
shouldMerge: true,
|
||||
title: "Thinking",
|
||||
});
|
||||
});
|
||||
|
||||
it("merges when titles are identical", () => {
|
||||
expect(mergeThinkingTitles("Thinking", "Thinking")).toEqual({
|
||||
shouldMerge: true,
|
||||
title: "Thinking",
|
||||
});
|
||||
});
|
||||
|
||||
it("merges and uses nextTitle when it extends currentTitle", () => {
|
||||
expect(mergeThinkingTitles("Think", "Thinking deeply")).toEqual({
|
||||
shouldMerge: true,
|
||||
title: "Thinking deeply",
|
||||
});
|
||||
});
|
||||
|
||||
it("merges and keeps currentTitle when it extends nextTitle", () => {
|
||||
expect(mergeThinkingTitles("Thinking deeply", "Think")).toEqual({
|
||||
shouldMerge: true,
|
||||
title: "Thinking deeply",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not merge when titles are completely different", () => {
|
||||
expect(mergeThinkingTitles("Analyzing", "Planning")).toEqual({
|
||||
shouldMerge: false,
|
||||
title: "Planning",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// appendTextBlock
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -135,20 +76,15 @@ describe("appendTextBlock", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("does not merge thinking blocks with incompatible titles", () => {
|
||||
it("merges thinking blocks with different titles using the new title", () => {
|
||||
const blocks: RenderBlock[] = [
|
||||
{ type: "thinking", text: "part1", title: "Analyzing" },
|
||||
];
|
||||
const result = appendTextBlock(blocks, "thinking", "part2", "Planning");
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toEqual({
|
||||
type: "thinking",
|
||||
text: "part1",
|
||||
title: "Analyzing",
|
||||
});
|
||||
expect(result[1]).toEqual({
|
||||
type: "thinking",
|
||||
text: "part2",
|
||||
text: "part1part2",
|
||||
title: "Planning",
|
||||
});
|
||||
});
|
||||
@@ -193,7 +129,7 @@ describe("appendTextBlock", () => {
|
||||
expect(result).not.toBe(blocks);
|
||||
});
|
||||
|
||||
it("merges thinking block when nextTitle extends currentTitle", () => {
|
||||
it("merges thinking block and uses new title", () => {
|
||||
const blocks: RenderBlock[] = [
|
||||
{ type: "thinking", text: "a", title: "Think" },
|
||||
];
|
||||
|
||||
@@ -13,35 +13,9 @@ export const asNonEmptyString = (value: unknown): string | undefined => {
|
||||
return next.length > 0 ? next : undefined;
|
||||
};
|
||||
|
||||
export const mergeThinkingTitles = (
|
||||
currentTitle: string | undefined,
|
||||
nextTitle: string | undefined,
|
||||
): { shouldMerge: boolean; title: string | undefined } => {
|
||||
if (!currentTitle && !nextTitle) {
|
||||
return { shouldMerge: true, title: undefined };
|
||||
}
|
||||
if (!currentTitle) {
|
||||
return { shouldMerge: true, title: nextTitle };
|
||||
}
|
||||
if (!nextTitle) {
|
||||
return { shouldMerge: true, title: currentTitle };
|
||||
}
|
||||
if (currentTitle === nextTitle) {
|
||||
return { shouldMerge: true, title: currentTitle };
|
||||
}
|
||||
if (nextTitle.startsWith(currentTitle)) {
|
||||
return { shouldMerge: true, title: nextTitle };
|
||||
}
|
||||
if (currentTitle.startsWith(nextTitle)) {
|
||||
return { shouldMerge: true, title: currentTitle };
|
||||
}
|
||||
return { shouldMerge: false, title: nextTitle };
|
||||
};
|
||||
|
||||
/**
|
||||
* Append a text or thinking block to a render block list, merging
|
||||
* with the previous block when the types match (and thinking titles
|
||||
* are compatible).
|
||||
* with the previous block when the types match.
|
||||
*
|
||||
* @param joinText Controls how existing and new text are concatenated
|
||||
* when merging into an existing block. Callers that process
|
||||
@@ -61,23 +35,14 @@ export const appendTextBlock = (
|
||||
const nextBlocks = [...blocks];
|
||||
const last = nextBlocks[nextBlocks.length - 1];
|
||||
if (last && last.type === type) {
|
||||
const shouldMerge =
|
||||
type === "response" ||
|
||||
(type === "thinking" &&
|
||||
last.type === "thinking" &&
|
||||
mergeThinkingTitles(last.title, title).shouldMerge);
|
||||
if (shouldMerge) {
|
||||
const mergedTitle =
|
||||
type === "thinking" && last.type === "thinking"
|
||||
? mergeThinkingTitles(last.title, title).title
|
||||
: undefined;
|
||||
nextBlocks[nextBlocks.length - 1] = createBlock(
|
||||
type,
|
||||
joinText(last.text, text),
|
||||
mergedTitle,
|
||||
);
|
||||
return nextBlocks;
|
||||
}
|
||||
nextBlocks[nextBlocks.length - 1] = createBlock(
|
||||
type,
|
||||
joinText(last.text, text),
|
||||
type === "thinking" && last.type === "thinking"
|
||||
? (title ?? last.title)
|
||||
: undefined,
|
||||
);
|
||||
return nextBlocks;
|
||||
}
|
||||
nextBlocks.push(createBlock(type, text, title));
|
||||
return nextBlocks;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
applyMessagePartToStreamState,
|
||||
applyStreamThinkingTitle,
|
||||
buildStreamTools,
|
||||
createEmptyStreamState,
|
||||
} from "./streamState";
|
||||
@@ -16,33 +15,6 @@ describe("createEmptyStreamState", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyStreamThinkingTitle", () => {
|
||||
it("returns blocks unchanged when title is undefined", () => {
|
||||
const blocks = [{ type: "response" as const, text: "hello" }];
|
||||
expect(applyStreamThinkingTitle(blocks, undefined)).toBe(blocks);
|
||||
});
|
||||
|
||||
it("creates a new thinking block when last block is not thinking", () => {
|
||||
const blocks = [{ type: "response" as const, text: "hello" }];
|
||||
const result = applyStreamThinkingTitle(blocks, "Plan");
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[1]).toEqual({ type: "thinking", text: "", title: "Plan" });
|
||||
});
|
||||
|
||||
it("merges title into existing thinking block", () => {
|
||||
const blocks = [
|
||||
{ type: "thinking" as const, text: "some thought", title: "Old" },
|
||||
];
|
||||
const result = applyStreamThinkingTitle(blocks, "Old and more");
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toEqual({
|
||||
type: "thinking",
|
||||
text: "some thought",
|
||||
title: "Old and more",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyMessagePartToStreamState", () => {
|
||||
it("creates new state with response block from text part on null prev", () => {
|
||||
const result = applyMessagePartToStreamState(null, {
|
||||
@@ -109,6 +81,16 @@ describe("applyMessagePartToStreamState", () => {
|
||||
expect(result).toBe(prev);
|
||||
});
|
||||
|
||||
it("returns prev for thinking part with only title and no text", () => {
|
||||
const prev = createEmptyStreamState();
|
||||
const result = applyMessagePartToStreamState(prev, {
|
||||
type: "thinking",
|
||||
text: "",
|
||||
title: "Some Title",
|
||||
});
|
||||
expect(result).toBe(prev);
|
||||
});
|
||||
|
||||
it("creates tool call entry from tool-call part", () => {
|
||||
const result = applyMessagePartToStreamState(null, {
|
||||
type: "tool-call",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { asString } from "components/ai-elements/runtimeTypeUtils";
|
||||
import { appendTextBlock, mergeThinkingTitles } from "./blockUtils";
|
||||
import { appendTextBlock } from "./blockUtils";
|
||||
import {
|
||||
asOptionalTitle,
|
||||
ensureToolBlock,
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
parseToolResultIsError,
|
||||
} from "./messageParsing";
|
||||
import { mergeStreamPayload } from "./streamingJson";
|
||||
import type { MergedTool, RenderBlock, StreamState } from "./types";
|
||||
import type { MergedTool, StreamState } from "./types";
|
||||
|
||||
let nextFallbackID = 0;
|
||||
|
||||
@@ -20,32 +20,6 @@ export const createEmptyStreamState = (): StreamState => ({
|
||||
/** Streaming variant — uses direct concatenation (the default joinText). */
|
||||
const appendStreamTextBlock = appendTextBlock;
|
||||
|
||||
export const applyStreamThinkingTitle = (
|
||||
blocks: RenderBlock[],
|
||||
title?: string,
|
||||
): RenderBlock[] => {
|
||||
if (!title) {
|
||||
return blocks;
|
||||
}
|
||||
const nextBlocks = [...blocks];
|
||||
const last = nextBlocks[nextBlocks.length - 1];
|
||||
if (last && last.type === "thinking") {
|
||||
const merged = mergeThinkingTitles(last.title, title);
|
||||
nextBlocks[nextBlocks.length - 1] = {
|
||||
type: "thinking",
|
||||
text: last.text,
|
||||
title: merged.title,
|
||||
};
|
||||
return nextBlocks;
|
||||
}
|
||||
nextBlocks.push({
|
||||
type: "thinking",
|
||||
text: "",
|
||||
title,
|
||||
});
|
||||
return nextBlocks;
|
||||
};
|
||||
|
||||
export const applyMessagePartToStreamState = (
|
||||
prev: StreamState | null,
|
||||
part: Record<string, unknown>,
|
||||
@@ -67,16 +41,18 @@ export const applyMessagePartToStreamState = (
|
||||
case "reasoning":
|
||||
case "thinking": {
|
||||
const text = asString(part.text);
|
||||
const title = asOptionalTitle(part.title);
|
||||
if (!text && !title) {
|
||||
if (!text) {
|
||||
return prev;
|
||||
}
|
||||
const nextBlocks = text
|
||||
? appendStreamTextBlock(nextState.blocks, "thinking", text, title)
|
||||
: applyStreamThinkingTitle(nextState.blocks, title);
|
||||
const title = asOptionalTitle(part.title);
|
||||
return {
|
||||
...nextState,
|
||||
blocks: nextBlocks,
|
||||
blocks: appendStreamTextBlock(
|
||||
nextState.blocks,
|
||||
"thinking",
|
||||
text,
|
||||
title,
|
||||
),
|
||||
};
|
||||
}
|
||||
case "tool-call":
|
||||
|
||||
Reference in New Issue
Block a user