mirror of
https://github.com/coder/coder.git
synced 2026-10-16 14:48:56 +08:00
fix: fold web searches, answer reasoning, and OpenAI narration into working blocks (#30529)
This commit is contained in:
Generated
+4
@@ -21301,6 +21301,10 @@ const docTemplate = `{
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"narration": {
|
||||
"description": "Narration marks assistant text that the model labeled as commentary\non its work rather than its answer. Only providers that label their\noutput set it.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"parsed_commands": {
|
||||
"description": "ParsedCommands holds parsed programs from an execute tool call's\nshell command, one entry per simple command in source order. Each\nentry is [program] or [program, arg] where arg is the first non-flag\npositional argument. Program names are normalized to their base\nname (e.g. /usr/bin/go becomes go). Only populated when ToolName\nis \"execute\" and the command parses successfully; nil otherwise.",
|
||||
"type": "array",
|
||||
|
||||
Generated
+4
@@ -19231,6 +19231,10 @@
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"narration": {
|
||||
"description": "Narration marks assistant text that the model labeled as commentary\non its work rather than its answer. Only providers that label their\noutput set it.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"parsed_commands": {
|
||||
"description": "ParsedCommands holds parsed programs from an execute tool call's\nshell command, one entry per simple command in source order. Each\nentry is [program] or [program, arg] where arg is the first non-flag\npositional argument. Program names are normalized to their base\nname (e.g. /usr/bin/go becomes go). Only populated when ToolName\nis \"execute\" and the command parses successfully; nil otherwise.",
|
||||
"type": "array",
|
||||
|
||||
@@ -8864,36 +8864,57 @@ func TestActiveServer_AnthropicDropsUnpairedProviderToolBeforePersist(t *testing
|
||||
func TestActiveServer_AnthropicKeepsPairedWebSearchBeforePersist(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
db, ps := dbtestutil.NewDB(t)
|
||||
requests := newAnthropicRequestRecorder()
|
||||
anthropicURL := chattest.NewAnthropic(t, func(req *chattest.AnthropicRequest) chattest.AnthropicResponse {
|
||||
requests.record(req)
|
||||
return chattest.AnthropicStreamingResponse(
|
||||
anthropicWebSearchPairChunks("ws-1", `{"query":"coder"}`, "search done", "end_turn")...,
|
||||
)
|
||||
})
|
||||
user, org, model := seedAnthropicChatDependencies(t, db, anthropicURL)
|
||||
model = enableAnthropicWebSearchForTest(t, db, model)
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
noResults bool
|
||||
}{
|
||||
{name: "WithResults"},
|
||||
// Clients see a search that found nothing while it streams, so it
|
||||
// must persist like any other.
|
||||
{name: "NoResults", noResults: true},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
server := newActiveTestServer(t, db, ps, func(cfg *chatd.Config) {
|
||||
cfg.AIBridgeTransportFactory = chatAIGatewayTransportFactoryPointer(chattest.NewMockAIBridgeTransport(t, anthropicURL, chattest.WithPreservePath()))
|
||||
})
|
||||
chat := createChatThroughServer(ctx, t, db, server, org.ID, user.ID, model.ID, "search for coder")
|
||||
waitForChatStatus(ctx, t, db, chat.ID, database.ChatStatusWaiting)
|
||||
chunks := anthropicWebSearchPairChunks("ws-1", `{"query":"coder"}`, "search done", "end_turn")
|
||||
if tt.noResults {
|
||||
for i := range chunks {
|
||||
if chunks[i].ContentBlock.Type == "web_search_tool_result" {
|
||||
chunks[i].ContentBlock.Content = []map[string]any{}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
generationRequests := filterAnthropicStreamingRequests(requests.all())
|
||||
require.Len(t, generationRequests, 1)
|
||||
parts := chatToolParts(ctx, t, db, chat.ID)
|
||||
toolCall := requireToolCallPart(t, parts, "web_search")
|
||||
require.Equal(t, "ws-1", toolCall.ToolCallID)
|
||||
require.True(t, toolCall.ProviderExecuted)
|
||||
toolResult := requireToolResultPart(t, parts, "web_search")
|
||||
require.Equal(t, "ws-1", toolResult.ToolCallID)
|
||||
require.True(t, toolResult.ProviderExecuted)
|
||||
require.NotEmpty(t, toolResult.ProviderMetadata)
|
||||
messages := chatMessages(ctx, t, db, chat.ID)
|
||||
requireTextPart(t, messages[len(messages)-1], "search done")
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
db, ps := dbtestutil.NewDB(t)
|
||||
requests := newAnthropicRequestRecorder()
|
||||
anthropicURL := chattest.NewAnthropic(t, func(req *chattest.AnthropicRequest) chattest.AnthropicResponse {
|
||||
requests.record(req)
|
||||
return chattest.AnthropicStreamingResponse(chunks...)
|
||||
})
|
||||
user, org, model := seedAnthropicChatDependencies(t, db, anthropicURL)
|
||||
model = enableAnthropicWebSearchForTest(t, db, model)
|
||||
|
||||
server := newActiveTestServer(t, db, ps, func(cfg *chatd.Config) {
|
||||
cfg.AIBridgeTransportFactory = chatAIGatewayTransportFactoryPointer(chattest.NewMockAIBridgeTransport(t, anthropicURL, chattest.WithPreservePath()))
|
||||
})
|
||||
chat := createChatThroughServer(ctx, t, db, server, org.ID, user.ID, model.ID, "search for coder")
|
||||
waitForChatStatus(ctx, t, db, chat.ID, database.ChatStatusWaiting)
|
||||
|
||||
generationRequests := filterAnthropicStreamingRequests(requests.all())
|
||||
require.Len(t, generationRequests, 1)
|
||||
parts := chatToolParts(ctx, t, db, chat.ID)
|
||||
toolCall := requireToolCallPart(t, parts, "web_search")
|
||||
require.Equal(t, "ws-1", toolCall.ToolCallID)
|
||||
require.True(t, toolCall.ProviderExecuted)
|
||||
toolResult := requireToolResultPart(t, parts, "web_search")
|
||||
require.Equal(t, "ws-1", toolResult.ToolCallID)
|
||||
require.True(t, toolResult.ProviderExecuted)
|
||||
require.NotEmpty(t, toolResult.ProviderMetadata)
|
||||
messages := chatMessages(ctx, t, db, chat.ID)
|
||||
requireTextPart(t, messages[len(messages)-1], "search done")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestActiveServer_AnthropicWebSearchFollowUpHasNoSyntheticCancellation
|
||||
|
||||
@@ -308,6 +308,12 @@ type reasoningState struct {
|
||||
startedAt time.Time
|
||||
}
|
||||
|
||||
type textState struct {
|
||||
text string
|
||||
options fantasy.ProviderMetadata
|
||||
narration bool
|
||||
}
|
||||
|
||||
// GenerateAssistant performs one assistant model stream and returns the
|
||||
// durable assistant-side content. It does not execute tools, retry, or persist.
|
||||
func GenerateAssistant(ctx context.Context, opts GenerateAssistantOptions) (_ AssistantOutcome, retErr error) {
|
||||
@@ -973,7 +979,7 @@ func processStepStream(
|
||||
var result stepResult
|
||||
|
||||
providerExecutedCalls := make(map[string]bool)
|
||||
activeTextContent := make(map[string]string)
|
||||
activeTextContent := make(map[string]textState)
|
||||
activeReasoningContent := make(map[string]reasoningState)
|
||||
// Track tool names by ID for input delta publishing.
|
||||
toolNames := make(map[string]string)
|
||||
@@ -982,19 +988,30 @@ func processStepStream(
|
||||
for part := range stream {
|
||||
switch part.Type {
|
||||
case fantasy.StreamPartTypeTextStart:
|
||||
activeTextContent[part.ID] = ""
|
||||
// OpenAI labels narration when the text starts, so every delta
|
||||
// can carry the label before the text ends.
|
||||
activeTextContent[part.ID] = textState{
|
||||
options: part.ProviderMetadata,
|
||||
narration: chatprompt.IsNarration(part.ProviderMetadata),
|
||||
}
|
||||
|
||||
case fantasy.StreamPartTypeTextDelta:
|
||||
if _, exists := activeTextContent[part.ID]; exists {
|
||||
activeTextContent[part.ID] += part.Delta
|
||||
textPart := codersdk.ChatMessageText(part.Delta)
|
||||
if active, exists := activeTextContent[part.ID]; exists {
|
||||
active.text += part.Delta
|
||||
activeTextContent[part.ID] = active
|
||||
textPart.Narration = active.narration
|
||||
}
|
||||
publishMessagePart(codersdk.ChatMessageRoleAssistant, codersdk.ChatMessageText(part.Delta))
|
||||
publishMessagePart(codersdk.ChatMessageRoleAssistant, textPart)
|
||||
|
||||
case fantasy.StreamPartTypeTextEnd:
|
||||
if text, exists := activeTextContent[part.ID]; exists {
|
||||
if active, exists := activeTextContent[part.ID]; exists {
|
||||
if len(part.ProviderMetadata) > 0 {
|
||||
active.options = part.ProviderMetadata
|
||||
}
|
||||
result.content = append(result.content, fantasy.TextContent{
|
||||
Text: text,
|
||||
ProviderMetadata: part.ProviderMetadata,
|
||||
Text: active.text,
|
||||
ProviderMetadata: active.options,
|
||||
})
|
||||
delete(activeTextContent, part.ID)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
|
||||
"charm.land/fantasy"
|
||||
fantasyanthropic "charm.land/fantasy/providers/anthropic"
|
||||
fantasyopenai "charm.land/fantasy/providers/openai"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
@@ -79,3 +80,46 @@ func TestProcessStepStreamPersistsRedactedThinkingOnEnd(t *testing.T) {
|
||||
require.NotNil(t, metadata)
|
||||
require.Equal(t, "redacted-payload", metadata.RedactedData)
|
||||
}
|
||||
|
||||
func TestProcessStepStreamLabelsNarration(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
phase := func(itemID, phase string) fantasy.ProviderMetadata {
|
||||
return fantasy.ProviderMetadata{
|
||||
fantasyopenai.Name: &fantasyopenai.ResponsesTextMetadata{ItemID: itemID, Phase: phase},
|
||||
}
|
||||
}
|
||||
stream := iter.Seq[fantasy.StreamPart](func(yield func(fantasy.StreamPart) bool) {
|
||||
// The narration's end carries no metadata, so the start's label
|
||||
// must persist.
|
||||
yield(fantasy.StreamPart{Type: fantasy.StreamPartTypeTextStart, ID: "msg_1", ProviderMetadata: phase("msg_1", "commentary")})
|
||||
yield(fantasy.StreamPart{Type: fantasy.StreamPartTypeTextDelta, ID: "msg_1", Delta: "Reading"})
|
||||
yield(fantasy.StreamPart{Type: fantasy.StreamPartTypeTextDelta, ID: "msg_1", Delta: " the file."})
|
||||
yield(fantasy.StreamPart{Type: fantasy.StreamPartTypeTextEnd, ID: "msg_1", ProviderMetadata: fantasy.ProviderMetadata{}})
|
||||
yield(fantasy.StreamPart{Type: fantasy.StreamPartTypeTextStart, ID: "msg_2", ProviderMetadata: phase("msg_2", "final_answer")})
|
||||
yield(fantasy.StreamPart{Type: fantasy.StreamPartTypeTextDelta, ID: "msg_2", Delta: "It is flaky."})
|
||||
yield(fantasy.StreamPart{Type: fantasy.StreamPartTypeTextEnd, ID: "msg_2", ProviderMetadata: phase("msg_2", "final_answer")})
|
||||
yield(fantasy.StreamPart{Type: fantasy.StreamPartTypeFinish, FinishReason: fantasy.FinishReasonStop})
|
||||
})
|
||||
|
||||
var published []codersdk.ChatMessagePart
|
||||
result, err := processStepStream(stream, quartz.NewMock(t), func(_ codersdk.ChatMessageRole, part codersdk.ChatMessagePart) {
|
||||
published = append(published, part)
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
narration := codersdk.ChatMessageText("Reading")
|
||||
narration.Narration = true
|
||||
rest := codersdk.ChatMessageText(" the file.")
|
||||
rest.Narration = true
|
||||
require.Equal(t, []codersdk.ChatMessagePart{
|
||||
narration,
|
||||
rest,
|
||||
codersdk.ChatMessageText("It is flaky."),
|
||||
}, published)
|
||||
|
||||
require.Equal(t, []fantasy.Content{
|
||||
fantasy.TextContent{Text: "Reading the file.", ProviderMetadata: phase("msg_1", "commentary")},
|
||||
fantasy.TextContent{Text: "It is flaky.", ProviderMetadata: phase("msg_2", "final_answer")},
|
||||
}, result.content)
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"charm.land/fantasy"
|
||||
fantasyopenai "charm.land/fantasy/providers/openai"
|
||||
"github.com/dustin/go-humanize"
|
||||
"github.com/google/uuid"
|
||||
"github.com/sqlc-dev/pqtype"
|
||||
@@ -651,12 +652,14 @@ func sdkPartFromContent(
|
||||
return codersdk.ChatMessagePart{
|
||||
Type: codersdk.ChatMessagePartTypeText,
|
||||
Text: value.Text,
|
||||
Narration: IsNarration(value.ProviderMetadata),
|
||||
ProviderMetadata: marshalProviderMetadata(value.ProviderMetadata),
|
||||
}
|
||||
case *fantasy.TextContent:
|
||||
return codersdk.ChatMessagePart{
|
||||
Type: codersdk.ChatMessagePartTypeText,
|
||||
Text: value.Text,
|
||||
Narration: IsNarration(value.ProviderMetadata),
|
||||
ProviderMetadata: marshalProviderMetadata(value.ProviderMetadata),
|
||||
}
|
||||
case fantasy.ReasoningContent:
|
||||
@@ -732,6 +735,17 @@ func sdkPartFromContent(
|
||||
}
|
||||
}
|
||||
|
||||
// IsNarration reports whether the provider labeled text as commentary on
|
||||
// the model's work rather than its answer.
|
||||
func IsNarration(metadata fantasy.ProviderMetadata) bool {
|
||||
for _, data := range metadata {
|
||||
if text, ok := data.(*fantasyopenai.ResponsesTextMetadata); ok && text != nil {
|
||||
return text.Phase == "commentary"
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// toolResultContentToPart converts a fantasy ToolResultContent into a
|
||||
// ChatMessagePart.
|
||||
func toolResultContentToPart(
|
||||
|
||||
@@ -158,6 +158,9 @@ type OpenAIChunkChoice struct {
|
||||
ReasoningDelta string `json:"reasoning_delta,omitempty"`
|
||||
ToolCalls []OpenAIToolCall `json:"tool_calls,omitempty"`
|
||||
FinishReason string `json:"finish_reason,omitempty"`
|
||||
// Phase labels the Responses API message item that the first delta
|
||||
// of an output index opens.
|
||||
Phase string `json:"phase,omitempty"`
|
||||
}
|
||||
|
||||
// OpenAIChunk represents a streaming chunk from OpenAI.
|
||||
@@ -520,6 +523,7 @@ func writeResponsesAPIStreaming(t testing.TB, w http.ResponseWriter, r *http.Req
|
||||
itemType string // "message" or "function_call"
|
||||
itemID string
|
||||
text string // accumulated text for message items
|
||||
phase string // phase for message items
|
||||
callID string // call_id for function_call items
|
||||
toolName string // function name for function_call items
|
||||
arguments string // accumulated arguments for function_call items
|
||||
@@ -742,7 +746,7 @@ func writeResponsesAPIStreaming(t testing.TB, w http.ResponseWriter, r *http.Req
|
||||
}
|
||||
if !writeEvent("response.output_item.done", map[string]interface{}{
|
||||
"output_index": outputIndex,
|
||||
"item": map[string]interface{}{
|
||||
"item": withMessagePhase(map[string]interface{}{
|
||||
"type": "message",
|
||||
"id": state.itemID,
|
||||
"role": "assistant",
|
||||
@@ -753,7 +757,7 @@ func writeResponsesAPIStreaming(t testing.TB, w http.ResponseWriter, r *http.Req
|
||||
"text": state.text,
|
||||
},
|
||||
},
|
||||
},
|
||||
}, state.phase),
|
||||
}) {
|
||||
return
|
||||
}
|
||||
@@ -835,17 +839,18 @@ func writeResponsesAPIStreaming(t testing.TB, w http.ResponseWriter, r *http.Req
|
||||
state = &outputItemState{
|
||||
itemType: "message",
|
||||
itemID: fmt.Sprintf("msg_%s", uuid.New().String()[:8]),
|
||||
phase: choice.Phase,
|
||||
}
|
||||
outputs[outputIndex] = state
|
||||
if !writeEvent("response.output_item.added", map[string]interface{}{
|
||||
"output_index": outputIndex,
|
||||
"item": map[string]interface{}{
|
||||
"item": withMessagePhase(map[string]interface{}{
|
||||
"type": "message",
|
||||
"id": state.itemID,
|
||||
"role": "assistant",
|
||||
"status": "in_progress",
|
||||
"content": []interface{}{},
|
||||
},
|
||||
}, state.phase),
|
||||
}) {
|
||||
return
|
||||
}
|
||||
@@ -875,6 +880,13 @@ func writeResponsesAPIStreaming(t testing.TB, w http.ResponseWriter, r *http.Req
|
||||
}
|
||||
}
|
||||
|
||||
func withMessagePhase(item map[string]interface{}, phase string) map[string]interface{} {
|
||||
if phase != "" {
|
||||
item["phase"] = phase
|
||||
}
|
||||
return item
|
||||
}
|
||||
|
||||
func (s *openAIServer) writeChatCompletionsNonStreaming(w http.ResponseWriter, resp *OpenAICompletion) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(resp); err != nil {
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/database/db2sdk"
|
||||
"github.com/coder/coder/v2/coderd/database/dbtestutil"
|
||||
dbpubsub "github.com/coder/coder/v2/coderd/database/pubsub"
|
||||
"github.com/coder/coder/v2/coderd/x/chatd"
|
||||
@@ -235,6 +236,89 @@ func TestOpenAIResponsesFullReplayPairsReasoningAndWebSearch(t *testing.T) {
|
||||
requirePromptItemReferenceOrder(t, followup.Prompt, reasoningID, webSearchID)
|
||||
}
|
||||
|
||||
func TestOpenAIResponsesNarrationKeepsPhase(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db, ps := dbtestutil.NewDB(t)
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
|
||||
var recorder responsesRequestRecorder
|
||||
openAIURL := chattest.NewOpenAI(t, func(req *chattest.OpenAIRequest) chattest.OpenAIResponse {
|
||||
if !req.Stream {
|
||||
return chattest.OpenAINonStreamingResponse("title")
|
||||
}
|
||||
if recorder.record(req) > 1 {
|
||||
return chattest.OpenAIStreamingResponse(chattest.OpenAITextChunks("follow-up answer")...)
|
||||
}
|
||||
return chattest.OpenAIStreamingResponse(
|
||||
chattest.OpenAIChunk{Choices: []chattest.OpenAIChunkChoice{{Index: 0, Delta: "Checking the docs.", Phase: "commentary"}}},
|
||||
chattest.OpenAIChunk{Choices: []chattest.OpenAIChunkChoice{{Index: 1, Delta: "The docs cover it.", Phase: "final_answer"}}},
|
||||
)
|
||||
})
|
||||
|
||||
user, org, _ := seedChatDependenciesWithProvider(t, db, "openai", openAIURL)
|
||||
model := insertOpenAIResponsesModelConfig(t, db, user.ID, true, false)
|
||||
factory := chattest.NewMockAIBridgeTransport(t, openAIURL)
|
||||
server := newOpenAIResponsesTestServer(t, db, ps, func(cfg *chatd.Config) {
|
||||
cfg.AIBridgeTransportFactory = chatAIGatewayTransportFactoryPointer(factory)
|
||||
})
|
||||
|
||||
chat, err := server.CreateChat(ctx, chatd.CreateOptions{
|
||||
OrganizationID: org.ID,
|
||||
OwnerID: user.ID,
|
||||
Title: uniqueResponsesTitle(t, "narration"),
|
||||
ModelConfigID: model.ID,
|
||||
InitialUserContent: []codersdk.ChatMessagePart{
|
||||
codersdk.ChatMessageText("do the docs cover this?"),
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
waitForChatProcessed(ctx, t, db, chat.ID, server)
|
||||
requireResponsesChatWaiting(ctx, t, db, chat.ID)
|
||||
|
||||
messages, err := db.GetChatMessagesByChatID(ctx, database.GetChatMessagesByChatIDParams{ChatID: chat.ID})
|
||||
require.NoError(t, err)
|
||||
var assistant []codersdk.ChatMessagePart
|
||||
for _, message := range messages {
|
||||
if message.Role == database.ChatMessageRoleAssistant {
|
||||
assistant = append(assistant, db2sdk.ChatMessage(message).Content...)
|
||||
}
|
||||
}
|
||||
narration := codersdk.ChatMessageText("Checking the docs.")
|
||||
narration.Narration = true
|
||||
require.Equal(t, []codersdk.ChatMessagePart{
|
||||
narration,
|
||||
codersdk.ChatMessageText("The docs cover it."),
|
||||
}, assistant)
|
||||
|
||||
_, err = server.SendMessage(ctx, chatd.SendMessageOptions{
|
||||
ChatID: chat.ID,
|
||||
CreatedBy: user.ID,
|
||||
ModelConfigID: model.ID,
|
||||
Content: []codersdk.ChatMessagePart{
|
||||
codersdk.ChatMessageText("thanks"),
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
waitForChatProcessed(ctx, t, db, chat.ID, server)
|
||||
requireResponsesChatWaiting(ctx, t, db, chat.ID)
|
||||
|
||||
requests := recorder.all()
|
||||
require.Len(t, requests, 2)
|
||||
var replayed [][2]any
|
||||
for _, item := range requests[1].Prompt {
|
||||
itemMap, ok := item.(map[string]interface{})
|
||||
if !ok || chattest.StringResponseField(itemMap, "role") != "assistant" {
|
||||
continue
|
||||
}
|
||||
replayed = append(replayed, [2]any{itemMap["content"], itemMap["phase"]})
|
||||
}
|
||||
require.Equal(t, [][2]any{
|
||||
{"Checking the docs.", "commentary"},
|
||||
{"The docs cover it.", "final_answer"},
|
||||
}, replayed)
|
||||
}
|
||||
|
||||
type recordedResponsesRequest struct {
|
||||
Prompt []interface{}
|
||||
Store *bool
|
||||
|
||||
@@ -511,6 +511,10 @@ type ChatMessagePart struct {
|
||||
ProviderExecuted bool `json:"provider_executed,omitempty" variants:"tool-call?,tool-result?"`
|
||||
// HookRewritten indicates that a lifecycle hook replaced model-proposed tool input.
|
||||
HookRewritten bool `json:"hook_rewritten,omitempty" variants:"tool-call?"`
|
||||
// Narration marks assistant text that the model labeled as commentary
|
||||
// on its work rather than its answer. Only providers that label their
|
||||
// output set it.
|
||||
Narration bool `json:"narration,omitempty" variants:"text?"`
|
||||
// CreatedAt is the timestamp this part carries. The semantics
|
||||
// depend on the part type: for tool-call and tool-result parts
|
||||
// it is the time the call was emitted or the result was
|
||||
|
||||
Generated
+11
@@ -2898,6 +2898,7 @@ curl -X GET http://coder-server:8080/api/v2/chats/{chat}/messages \
|
||||
"mcp_server_config_id": "a9f436ed-69e7-459c-8308-a67ff5387e3e",
|
||||
"media_type": "string",
|
||||
"name": "string",
|
||||
"narration": true,
|
||||
"parsed_commands": [
|
||||
[
|
||||
"string"
|
||||
@@ -2979,6 +2980,7 @@ curl -X GET http://coder-server:8080/api/v2/chats/{chat}/messages \
|
||||
"mcp_server_config_id": "a9f436ed-69e7-459c-8308-a67ff5387e3e",
|
||||
"media_type": "string",
|
||||
"name": "string",
|
||||
"narration": true,
|
||||
"parsed_commands": [
|
||||
[
|
||||
"string"
|
||||
@@ -3134,6 +3136,7 @@ curl -X POST http://coder-server:8080/api/v2/chats/{chat}/messages \
|
||||
"mcp_server_config_id": "a9f436ed-69e7-459c-8308-a67ff5387e3e",
|
||||
"media_type": "string",
|
||||
"name": "string",
|
||||
"narration": true,
|
||||
"parsed_commands": [
|
||||
[
|
||||
"string"
|
||||
@@ -3214,6 +3217,7 @@ curl -X POST http://coder-server:8080/api/v2/chats/{chat}/messages \
|
||||
"mcp_server_config_id": "a9f436ed-69e7-459c-8308-a67ff5387e3e",
|
||||
"media_type": "string",
|
||||
"name": "string",
|
||||
"narration": true,
|
||||
"parsed_commands": [
|
||||
[
|
||||
"string"
|
||||
@@ -3295,6 +3299,7 @@ curl -X POST http://coder-server:8080/api/v2/chats/{chat}/messages \
|
||||
"mcp_server_config_id": "a9f436ed-69e7-459c-8308-a67ff5387e3e",
|
||||
"media_type": "string",
|
||||
"name": "string",
|
||||
"narration": true,
|
||||
"parsed_commands": [
|
||||
[
|
||||
"string"
|
||||
@@ -3435,6 +3440,7 @@ curl -X PATCH http://coder-server:8080/api/v2/chats/{chat}/messages/{message} \
|
||||
"mcp_server_config_id": "a9f436ed-69e7-459c-8308-a67ff5387e3e",
|
||||
"media_type": "string",
|
||||
"name": "string",
|
||||
"narration": true,
|
||||
"parsed_commands": [
|
||||
[
|
||||
"string"
|
||||
@@ -3515,6 +3521,7 @@ curl -X PATCH http://coder-server:8080/api/v2/chats/{chat}/messages/{message} \
|
||||
"mcp_server_config_id": "a9f436ed-69e7-459c-8308-a67ff5387e3e",
|
||||
"media_type": "string",
|
||||
"name": "string",
|
||||
"narration": true,
|
||||
"parsed_commands": [
|
||||
[
|
||||
"string"
|
||||
@@ -4106,6 +4113,7 @@ curl -X GET http://coder-server:8080/api/v2/chats/{chat}/stream \
|
||||
"mcp_server_config_id": "a9f436ed-69e7-459c-8308-a67ff5387e3e",
|
||||
"media_type": "string",
|
||||
"name": "string",
|
||||
"narration": true,
|
||||
"parsed_commands": [
|
||||
[
|
||||
"string"
|
||||
@@ -4185,6 +4193,7 @@ curl -X GET http://coder-server:8080/api/v2/chats/{chat}/stream \
|
||||
"mcp_server_config_id": "a9f436ed-69e7-459c-8308-a67ff5387e3e",
|
||||
"media_type": "string",
|
||||
"name": "string",
|
||||
"narration": true,
|
||||
"parsed_commands": [
|
||||
[
|
||||
"string"
|
||||
@@ -4251,6 +4260,7 @@ curl -X GET http://coder-server:8080/api/v2/chats/{chat}/stream \
|
||||
"mcp_server_config_id": "a9f436ed-69e7-459c-8308-a67ff5387e3e",
|
||||
"media_type": "string",
|
||||
"name": "string",
|
||||
"narration": true,
|
||||
"parsed_commands": [
|
||||
[
|
||||
"string"
|
||||
@@ -4357,6 +4367,7 @@ Status Code **200**
|
||||
| `»»» mcp_server_config_id` | string(uuid) | false | | |
|
||||
| `»»» media_type` | string | false | | |
|
||||
| `»»» name` | string | false | | |
|
||||
| `»»» narration` | boolean | false | | Narration marks assistant text that the model labeled as commentary on its work rather than its answer. Only providers that label their output set it. |
|
||||
| `»»» parsed_commands` | array | false | | Parsed commands holds parsed programs from an execute tool call's shell command, one entry per simple command in source order. Each entry is [program] or [program, arg] where arg is the first non-flag positional argument. Program names are normalized to their base name (e.g. /usr/bin/go becomes go). Only populated when ToolName is "execute" and the command parses successfully; nil otherwise. |
|
||||
| `»»» provider_executed` | boolean | false | | Provider executed indicates the tool call was executed by the provider (e.g. Anthropic computer use). |
|
||||
| `»»» provider_metadata` | array | false | | Provider metadata holds provider-specific response metadata (e.g. Anthropic cache control hints) as raw JSON. Internal only: stripped by db2sdk before API responses. |
|
||||
|
||||
Generated
+15
@@ -3233,6 +3233,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
|
||||
"mcp_server_config_id": "a9f436ed-69e7-459c-8308-a67ff5387e3e",
|
||||
"media_type": "string",
|
||||
"name": "string",
|
||||
"narration": true,
|
||||
"parsed_commands": [
|
||||
[
|
||||
"string"
|
||||
@@ -3328,6 +3329,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
|
||||
"mcp_server_config_id": "a9f436ed-69e7-459c-8308-a67ff5387e3e",
|
||||
"media_type": "string",
|
||||
"name": "string",
|
||||
"narration": true,
|
||||
"parsed_commands": [
|
||||
[
|
||||
"string"
|
||||
@@ -3388,6 +3390,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
|
||||
| `mcp_server_config_id` | string | false | | |
|
||||
| `media_type` | string | false | | |
|
||||
| `name` | string | false | | |
|
||||
| `narration` | boolean | false | | Narration marks assistant text that the model labeled as commentary on its work rather than its answer. Only providers that label their output set it. |
|
||||
| `parsed_commands` | array of array | false | | Parsed commands holds parsed programs from an execute tool call's shell command, one entry per simple command in source order. Each entry is [program] or [program, arg] where arg is the first non-flag positional argument. Program names are normalized to their base name (e.g. /usr/bin/go becomes go). Only populated when ToolName is "execute" and the command parses successfully; nil otherwise. |
|
||||
| `provider_executed` | boolean | false | | Provider executed indicates the tool call was executed by the provider (e.g. Anthropic computer use). |
|
||||
| `provider_metadata` | array of integer | false | | Provider metadata holds provider-specific response metadata (e.g. Anthropic cache control hints) as raw JSON. Internal only: stripped by db2sdk before API responses. |
|
||||
@@ -3502,6 +3505,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
|
||||
"mcp_server_config_id": "a9f436ed-69e7-459c-8308-a67ff5387e3e",
|
||||
"media_type": "string",
|
||||
"name": "string",
|
||||
"narration": true,
|
||||
"parsed_commands": [
|
||||
[
|
||||
"string"
|
||||
@@ -3583,6 +3587,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
|
||||
"mcp_server_config_id": "a9f436ed-69e7-459c-8308-a67ff5387e3e",
|
||||
"media_type": "string",
|
||||
"name": "string",
|
||||
"narration": true,
|
||||
"parsed_commands": [
|
||||
[
|
||||
"string"
|
||||
@@ -4964,6 +4969,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
|
||||
"mcp_server_config_id": "a9f436ed-69e7-459c-8308-a67ff5387e3e",
|
||||
"media_type": "string",
|
||||
"name": "string",
|
||||
"narration": true,
|
||||
"parsed_commands": [
|
||||
[
|
||||
"string"
|
||||
@@ -5127,6 +5133,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
|
||||
"mcp_server_config_id": "a9f436ed-69e7-459c-8308-a67ff5387e3e",
|
||||
"media_type": "string",
|
||||
"name": "string",
|
||||
"narration": true,
|
||||
"parsed_commands": [
|
||||
[
|
||||
"string"
|
||||
@@ -5206,6 +5213,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
|
||||
"mcp_server_config_id": "a9f436ed-69e7-459c-8308-a67ff5387e3e",
|
||||
"media_type": "string",
|
||||
"name": "string",
|
||||
"narration": true,
|
||||
"parsed_commands": [
|
||||
[
|
||||
"string"
|
||||
@@ -5272,6 +5280,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
|
||||
"mcp_server_config_id": "a9f436ed-69e7-459c-8308-a67ff5387e3e",
|
||||
"media_type": "string",
|
||||
"name": "string",
|
||||
"narration": true,
|
||||
"parsed_commands": [
|
||||
[
|
||||
"string"
|
||||
@@ -5387,6 +5396,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
|
||||
"mcp_server_config_id": "a9f436ed-69e7-459c-8308-a67ff5387e3e",
|
||||
"media_type": "string",
|
||||
"name": "string",
|
||||
"narration": true,
|
||||
"parsed_commands": [
|
||||
[
|
||||
"string"
|
||||
@@ -6245,6 +6255,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
|
||||
"mcp_server_config_id": "a9f436ed-69e7-459c-8308-a67ff5387e3e",
|
||||
"media_type": "string",
|
||||
"name": "string",
|
||||
"narration": true,
|
||||
"parsed_commands": [
|
||||
[
|
||||
"string"
|
||||
@@ -6325,6 +6336,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
|
||||
"mcp_server_config_id": "a9f436ed-69e7-459c-8308-a67ff5387e3e",
|
||||
"media_type": "string",
|
||||
"name": "string",
|
||||
"narration": true,
|
||||
"parsed_commands": [
|
||||
[
|
||||
"string"
|
||||
@@ -6406,6 +6418,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
|
||||
"mcp_server_config_id": "a9f436ed-69e7-459c-8308-a67ff5387e3e",
|
||||
"media_type": "string",
|
||||
"name": "string",
|
||||
"narration": true,
|
||||
"parsed_commands": [
|
||||
[
|
||||
"string"
|
||||
@@ -9331,6 +9344,7 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o
|
||||
"mcp_server_config_id": "a9f436ed-69e7-459c-8308-a67ff5387e3e",
|
||||
"media_type": "string",
|
||||
"name": "string",
|
||||
"narration": true,
|
||||
"parsed_commands": [
|
||||
[
|
||||
"string"
|
||||
@@ -9411,6 +9425,7 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o
|
||||
"mcp_server_config_id": "a9f436ed-69e7-459c-8308-a67ff5387e3e",
|
||||
"media_type": "string",
|
||||
"name": "string",
|
||||
"narration": true,
|
||||
"parsed_commands": [
|
||||
[
|
||||
"string"
|
||||
|
||||
@@ -105,10 +105,14 @@ replace github.com/spf13/afero => github.com/aslilac/afero v0.0.0-20250403163713
|
||||
// and gpt-6+ reasoning classification in getResponsesModelConfig; also
|
||||
// carries upstream #354 (gpt-6+ in IsResponsesModel).
|
||||
// 10) coder/fantasy#58, keep media tool results in the Gemini prompt.
|
||||
// 11) coder/fantasy mike/mcp-tool-media-batch-order (unmerged), keep OpenAI
|
||||
// tool-result batches contiguous before attaching user media.
|
||||
// See: https://github.com/coder/fantasy/commits/9a3598480a71
|
||||
replace charm.land/fantasy => github.com/coder/fantasy v0.0.0-20260917071228-9a3598480a71
|
||||
// 11) coder/fantasy#64, keep OpenAI tool-result batches contiguous before
|
||||
// attaching user media.
|
||||
// 12) coder/fantasy#65, attach empty metadata to Anthropic web searches
|
||||
// with no results so chatd keeps them in saved history.
|
||||
// 13) coder/fantasy#66, capture OpenAI Responses message phase on text
|
||||
// parts and replay it on assistant input messages in both store modes.
|
||||
// See: https://github.com/coder/fantasy/commits/351055280157
|
||||
replace charm.land/fantasy => github.com/coder/fantasy v0.0.0-20261009165329-351055280157
|
||||
|
||||
// coder/coder uses a fork of charmbracelet's fork of the Anthropic Go SDK
|
||||
// with performance improvements and Bedrock header cleanup.
|
||||
|
||||
@@ -333,8 +333,8 @@ github.com/coder/bubbletea v1.2.2-0.20241212190825-007a1cdb2c41 h1:SBN/DA63+ZHwu
|
||||
github.com/coder/bubbletea v1.2.2-0.20241212190825-007a1cdb2c41/go.mod h1:I9ULxr64UaOSUv7hcb3nX4kowodJCVS7vt7VVJk/kW4=
|
||||
github.com/coder/clistat v1.2.1 h1:P9/10njXMyj5cWzIU5wkRsSy5LVQH49+tcGMsAgWX0w=
|
||||
github.com/coder/clistat v1.2.1/go.mod h1:m7SC0uj88eEERgvF8Kn6+w6XF21BeSr+15f7GoLAw0A=
|
||||
github.com/coder/fantasy v0.0.0-20260917071228-9a3598480a71 h1:kJzgNKoyf+duEF2ClR5UPeCZldi6jR+rM8F2nbNbStY=
|
||||
github.com/coder/fantasy v0.0.0-20260917071228-9a3598480a71/go.mod h1:Hd5DBPecZ+CGc8O9bFAV03S7jbEmqd9xXVmjKCzpD7g=
|
||||
github.com/coder/fantasy v0.0.0-20261009165329-351055280157 h1:t1DbZyWlLOPISsLzcFFnKfvSToWpXFIL1pkpFovlNh0=
|
||||
github.com/coder/fantasy v0.0.0-20261009165329-351055280157/go.mod h1:Hd5DBPecZ+CGc8O9bFAV03S7jbEmqd9xXVmjKCzpD7g=
|
||||
github.com/coder/flog v1.1.0 h1:kbAes1ai8fIS5OeV+QAnKBQE22ty1jRF/mcAwHpLBa4=
|
||||
github.com/coder/flog v1.1.0/go.mod h1:UQlQvrkJBvnRGo69Le8E24Tcl5SJleAAR7gYEHzAmdQ=
|
||||
github.com/coder/go-httpstat v0.0.0-20230801153223-321c88088322 h1:m0lPZjlQ7vdVpRBPKfYIFlmgevoTkBxB10wv6l2gOaU=
|
||||
|
||||
Generated
+6
@@ -3756,6 +3756,12 @@ export interface ChatSystemPromptResponse {
|
||||
export interface ChatTextPart {
|
||||
readonly type: "text";
|
||||
readonly text: string;
|
||||
/**
|
||||
* Narration marks assistant text that the model labeled as commentary
|
||||
* on its work rather than its answer. Only providers that label their
|
||||
* output set it.
|
||||
*/
|
||||
readonly narration?: boolean;
|
||||
}
|
||||
|
||||
// From codersdk/chats.go
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
buildStreamRenderState,
|
||||
MockCollapsedStepsPreferences,
|
||||
MockLongTurnPageLoads,
|
||||
MockWebSearchAnswerMessages,
|
||||
MockWorkingMessages,
|
||||
pinFixtureClock,
|
||||
workingFixtureTime,
|
||||
@@ -107,6 +108,27 @@ const focusCopyCommand = (messageId: number) => {
|
||||
};
|
||||
|
||||
describe("ConversationTimeline working blocks", () => {
|
||||
it("copies the whole message from the answer below its expanded block", async () => {
|
||||
const user = userEvent.setup();
|
||||
const writeText = vi
|
||||
.spyOn(navigator.clipboard, "writeText")
|
||||
.mockResolvedValue();
|
||||
renderTimeline({ messages: MockWebSearchAnswerMessages });
|
||||
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "Worked for 15s (3 steps)" }),
|
||||
);
|
||||
await user.click(
|
||||
within(screen.getByTestId("chat-message-message:6")).getByRole("button", {
|
||||
name: "Copy message",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(writeText).toHaveBeenCalledWith(
|
||||
"Looking up the release notes.The workspace runs the latest Coder release.",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps an open block mounted as older pages join it", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { rerenderStage } = renderTimeline({
|
||||
@@ -334,6 +356,37 @@ describe("ConversationTimeline live working blocks", () => {
|
||||
expect(copyCommand).toHaveFocus();
|
||||
});
|
||||
|
||||
it("keeps the live reasoning mounted in an open block when the answer starts", async () => {
|
||||
const user = userEvent.setup();
|
||||
const messages = MockWorkingMessages.slice(0, 5);
|
||||
const reasoning = {
|
||||
type: "reasoning",
|
||||
text: "Summarizing the inspection",
|
||||
created_at: workingFixtureTime(13),
|
||||
} as const;
|
||||
const { rerenderStage } = renderTimeline({
|
||||
messages,
|
||||
chatStatus: "running",
|
||||
...buildStreamRenderState([reasoning]),
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Working for 12s" }));
|
||||
const thinking = await screen.findByRole("button", {
|
||||
name: /Summarizing/,
|
||||
});
|
||||
thinking.focus();
|
||||
|
||||
rerenderStage({
|
||||
messages,
|
||||
chatStatus: "running",
|
||||
...buildStreamRenderState([
|
||||
reasoning,
|
||||
{ type: "text", text: "The workspace looks healthy." },
|
||||
]),
|
||||
});
|
||||
expect(thinking).toHaveFocus();
|
||||
});
|
||||
|
||||
it("keeps the live disclosure mounted while the next step starts", () => {
|
||||
const messages = MockWorkingMessages.slice(0, 5);
|
||||
const { rerenderStage } = renderTimeline({
|
||||
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
buildDisplayMessages,
|
||||
deriveEvictedFileIds,
|
||||
deriveMessageDisplayState,
|
||||
getVisibleContent,
|
||||
} from "./messageHelpers";
|
||||
import { getEditableUserMessagePayload } from "./messageParsing";
|
||||
import { assignTimelineRows } from "./timelineRows";
|
||||
@@ -52,7 +53,11 @@ import type {
|
||||
} from "./types";
|
||||
import { UserMessageContent } from "./UserMessageContent";
|
||||
import { WorkingBlockDisclosure } from "./WorkingBlockDisclosure";
|
||||
import { groupWorkingBlocks } from "./workingBlockGrouping";
|
||||
import {
|
||||
groupWorkingBlocks,
|
||||
type RowSection,
|
||||
splitRowBlocks,
|
||||
} from "./workingBlockGrouping";
|
||||
|
||||
const getChatMessageTextContent = (
|
||||
content: readonly TypesGen.ChatMessagePart[] | undefined,
|
||||
@@ -103,6 +108,7 @@ const ChatMessageItem = memo<{
|
||||
renderKey: string;
|
||||
// Durable messages and live assistant output share one rendering path.
|
||||
message?: TypesGen.ChatMessage;
|
||||
section?: RowSection;
|
||||
parsed?: ParsedMessageContent;
|
||||
liveStatus?: LiveStatusModel;
|
||||
// Live blocks and tools are normalized at the live row callsite, so this
|
||||
@@ -145,6 +151,7 @@ const ChatMessageItem = memo<{
|
||||
organizationId,
|
||||
renderKey,
|
||||
message,
|
||||
section,
|
||||
parsed,
|
||||
liveStatus,
|
||||
liveBlocks = [],
|
||||
@@ -228,10 +235,26 @@ const ChatMessageItem = memo<{
|
||||
const conversationItemProps: { role: "user" | "assistant" } = {
|
||||
role: isUser ? "user" : "assistant",
|
||||
};
|
||||
const isWorkSection = section === "work";
|
||||
const rowBlocks = parsed?.blocks ?? liveBlocks;
|
||||
const rowTools = parsed?.tools ?? liveTools;
|
||||
const blocks =
|
||||
section === undefined
|
||||
? rowBlocks
|
||||
: splitRowBlocks(rowBlocks, rowTools)[section];
|
||||
// An answer whose only work was a search without citations folds
|
||||
// nothing, and an empty row would still add a gap inside the block.
|
||||
if (
|
||||
isWorkSection &&
|
||||
getVisibleContent(blocks, rowTools).visibleBlocks.length === 0
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const outputLiveStatus = isWorkSection ? undefined : liveStatus;
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid={`chat-message-${renderKey}`}
|
||||
data-testid={`chat-message-${renderKey}${isWorkSection ? "-work" : ""}`}
|
||||
className={cn(
|
||||
isAfterEditingMessage && "opacity-40 pointer-events-none",
|
||||
"group/msg relative transition-opacity duration-200",
|
||||
@@ -255,10 +278,11 @@ const ChatMessageItem = memo<{
|
||||
<AssistantOutput
|
||||
organizationId={organizationId}
|
||||
keyPrefix={renderKey}
|
||||
blocks={parsed?.blocks ?? liveBlocks}
|
||||
tools={parsed?.tools ?? liveTools}
|
||||
isStreaming={liveStatus?.phase === "streaming"}
|
||||
liveStatus={liveStatus}
|
||||
blocks={blocks}
|
||||
// Folded tools would otherwise render again as block-less tools.
|
||||
tools={section === "answer" ? [] : rowTools}
|
||||
isStreaming={outputLiveStatus?.phase === "streaming"}
|
||||
liveStatus={outputLiveStatus}
|
||||
subagentStatusOverrides={subagentStatusOverrides}
|
||||
subagentTitles={subagentTitles}
|
||||
subagentVariants={subagentVariants}
|
||||
@@ -603,13 +627,17 @@ export const ConversationTimeline = memo<ConversationTimelineProps>(
|
||||
? askUserQuestionResponseTextByToolId
|
||||
: undefined;
|
||||
|
||||
const rowContents = renderRows.map((row, index) => {
|
||||
// The work section keeps the row's key, so a row that becomes an answer
|
||||
// row inside an expanded block does not remount its reasoning.
|
||||
const renderRowContent = (index: number, section?: RowSection) => {
|
||||
const row = renderRows[index];
|
||||
if (row.type === "live") {
|
||||
return (
|
||||
<ChatMessageItem
|
||||
key={row.key}
|
||||
organizationId={organizationId}
|
||||
renderKey={row.key}
|
||||
section={section}
|
||||
liveStatus={liveStatus}
|
||||
liveBlocks={liveBlocks}
|
||||
liveTools={liveTools}
|
||||
@@ -627,9 +655,11 @@ export const ConversationTimeline = memo<ConversationTimelineProps>(
|
||||
const { message, parsed } = row.entry;
|
||||
const isUser = message.role === "user";
|
||||
const neighbors = userNeighborsByKey.get(row.key);
|
||||
// A block's item dims and inerts its rows as a whole.
|
||||
// A block's item dims and inerts its rows as a whole; the answer
|
||||
// section renders outside it, so it dims itself.
|
||||
const isAfterEditingMessage =
|
||||
!groupedRows.has(index) && afterEditingMessageIds.has(message.id);
|
||||
(section === "answer" || !groupedRows.has(index)) &&
|
||||
afterEditingMessageIds.has(message.id);
|
||||
|
||||
return (
|
||||
<ChatMessageItem
|
||||
@@ -637,6 +667,7 @@ export const ConversationTimeline = memo<ConversationTimelineProps>(
|
||||
organizationId={organizationId}
|
||||
renderKey={row.key}
|
||||
message={message}
|
||||
section={section}
|
||||
parsed={parsed}
|
||||
onEditUserMessage={isUser ? onEditUserMessage : undefined}
|
||||
editingMessageId={editingMessageId}
|
||||
@@ -650,7 +681,9 @@ export const ConversationTimeline = memo<ConversationTimelineProps>(
|
||||
hasUserResponseAfterAskQuestion={hasUserResponseAfterAskQuestion}
|
||||
urlTransform={urlTransform}
|
||||
isAfterEditingMessage={isAfterEditingMessage}
|
||||
hideActions={!isUser && !row.isLastInAssistantChain}
|
||||
hideActions={
|
||||
section === "work" || (!isUser && !row.isLastInAssistantChain)
|
||||
}
|
||||
hasActiveStream={hasActiveStream}
|
||||
isAwaitingFirstStreamChunk={isAwaitingFirstStreamChunk}
|
||||
isLastMessage={row.isLastMessage}
|
||||
@@ -663,11 +696,11 @@ export const ConversationTimeline = memo<ConversationTimelineProps>(
|
||||
onJumpToUserMessage={isUser ? jumpToUserMessage : undefined}
|
||||
/>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<FileProbeProvider evictedFileIds={evictedFileIds}>
|
||||
{renderRows.map((row, index) => {
|
||||
{renderRows.flatMap((row, index) => {
|
||||
const block = blockByFirstRow.get(index);
|
||||
if (block) {
|
||||
// Keyed on durable member rows and the item key, excluding the live
|
||||
@@ -687,8 +720,11 @@ export const ConversationTimeline = memo<ConversationTimelineProps>(
|
||||
const isAfterEditingMessage =
|
||||
row.type === "message" &&
|
||||
afterEditingMessageIds.has(row.entry.message.id);
|
||||
const answerRowIndex = block.endsWithAnswer
|
||||
? block.rowIndices.at(-1)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
return [
|
||||
<MessageScroller.Item
|
||||
key={itemKey}
|
||||
messageId={itemKey}
|
||||
@@ -712,21 +748,36 @@ export const ConversationTimeline = memo<ConversationTimelineProps>(
|
||||
}
|
||||
>
|
||||
{expanded &&
|
||||
block.rowIndices.map((rowIndex) => rowContents[rowIndex])}
|
||||
block.rowIndices.map((rowIndex) =>
|
||||
renderRowContent(
|
||||
rowIndex,
|
||||
rowIndex === answerRowIndex ? "work" : undefined,
|
||||
),
|
||||
)}
|
||||
</WorkingBlockDisclosure>
|
||||
</MessageScroller.Item>
|
||||
);
|
||||
</MessageScroller.Item>,
|
||||
...(answerRowIndex === undefined
|
||||
? []
|
||||
: [
|
||||
<MessageScroller.Item
|
||||
key={renderRows[answerRowIndex].key}
|
||||
messageId={renderRows[answerRowIndex].key}
|
||||
>
|
||||
{renderRowContent(answerRowIndex, "answer")}
|
||||
</MessageScroller.Item>,
|
||||
]),
|
||||
];
|
||||
}
|
||||
|
||||
if (groupedRows.has(index)) {
|
||||
return null;
|
||||
return [];
|
||||
}
|
||||
|
||||
const suppressInitialAnchor =
|
||||
row.type === "message" &&
|
||||
initialActiveTurnMaxMessageId !== undefined &&
|
||||
row.entry.message.id <= initialActiveTurnMaxMessageId;
|
||||
return (
|
||||
return [
|
||||
<MessageScroller.Item
|
||||
key={row.key}
|
||||
messageId={row.key}
|
||||
@@ -734,9 +785,9 @@ export const ConversationTimeline = memo<ConversationTimelineProps>(
|
||||
row.key === anchorUserRowKey && !suppressInitialAnchor
|
||||
}
|
||||
>
|
||||
{rowContents[index]}
|
||||
</MessageScroller.Item>
|
||||
);
|
||||
{renderRowContent(index)}
|
||||
</MessageScroller.Item>,
|
||||
];
|
||||
})}
|
||||
</FileProbeProvider>
|
||||
);
|
||||
|
||||
+53
@@ -131,6 +131,59 @@ export const ReasoningBeforeFirstToolFolds: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
// Once the final step starts its answer, its reasoning stays in the live
|
||||
// block and the text streams after it.
|
||||
export const AnswerStreamsAfterBlock: Story = {
|
||||
args: {
|
||||
parsedMessages: parseMessagesWithMergedTools(
|
||||
MockWorkingMessages.slice(0, 5),
|
||||
),
|
||||
...buildStreamRenderState([
|
||||
{
|
||||
type: "reasoning",
|
||||
text: "Summarizing the inspection",
|
||||
created_at: workingFixtureTime(13),
|
||||
},
|
||||
{ type: "text", text: "The workspace looks healthy." },
|
||||
]),
|
||||
},
|
||||
play: expandBlock("Working for 12s"),
|
||||
};
|
||||
|
||||
// Narration before a web search stays in the live block, and the answer, cited
|
||||
// as it streams, renders after it.
|
||||
export const SearchAnswerStreamsAfterBlock: Story = {
|
||||
args: {
|
||||
parsedMessages: parseMessagesWithMergedTools(
|
||||
MockWorkingMessages.slice(0, 1),
|
||||
),
|
||||
...buildStreamRenderState([
|
||||
{ type: "text", text: "I'll look up the latest Coder release." },
|
||||
{
|
||||
type: "tool-call",
|
||||
tool_call_id: "search",
|
||||
tool_name: "web_search",
|
||||
provider_executed: true,
|
||||
created_at: workingFixtureTime(1),
|
||||
},
|
||||
{
|
||||
type: "tool-result",
|
||||
tool_call_id: "search",
|
||||
tool_name: "web_search",
|
||||
provider_executed: true,
|
||||
},
|
||||
{ type: "text", text: "The latest Coder release" },
|
||||
{
|
||||
type: "source",
|
||||
url: "https://releases.example.com",
|
||||
title: "Coder releases",
|
||||
},
|
||||
{ type: "text", text: " is v2.30." },
|
||||
]),
|
||||
},
|
||||
play: expandBlock("Working for 12s"),
|
||||
};
|
||||
|
||||
export const RequiresActionCompletesBlock: Story = {
|
||||
args: {
|
||||
chatStatus: "requires_action",
|
||||
|
||||
+91
@@ -11,8 +11,11 @@ import { parseMessagesWithMergedTools } from "./messageParsing";
|
||||
import {
|
||||
buildLiveStatus,
|
||||
MockCollapsedStepsPreferences,
|
||||
MockLeadInSearchMessages,
|
||||
MockLongTurnPageLoads,
|
||||
MockQuestionCallMessage,
|
||||
MockUncitedSearchAnswerMessages,
|
||||
MockWebSearchAnswerMessages,
|
||||
MockWorkingMessages,
|
||||
pinFixtureClock,
|
||||
workingFixtureTime,
|
||||
@@ -73,6 +76,87 @@ export const Expanded: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
// Only the answer's final text shows after the summary.
|
||||
export const AnswerWorkFolds: Story = {
|
||||
args: {
|
||||
parsedMessages: parseMessagesWithMergedTools(MockWebSearchAnswerMessages),
|
||||
},
|
||||
};
|
||||
|
||||
export const AnswerWorkFoldsExpanded: Story = {
|
||||
args: {
|
||||
parsedMessages: parseMessagesWithMergedTools(MockWebSearchAnswerMessages),
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
await userEvent.click(
|
||||
within(canvasElement).getByRole("button", {
|
||||
name: "Worked for 15s (3 steps)",
|
||||
}),
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const SearchOnlyAnswerFolds: Story = {
|
||||
args: {
|
||||
parsedMessages: parseMessagesWithMergedTools([
|
||||
MockWebSearchAnswerMessages[0],
|
||||
...MockWebSearchAnswerMessages.slice(5),
|
||||
]),
|
||||
},
|
||||
};
|
||||
|
||||
export const UncitedSearchAnswerFoldsExpanded: Story = {
|
||||
args: {
|
||||
parsedMessages: parseMessagesWithMergedTools(
|
||||
MockUncitedSearchAnswerMessages,
|
||||
),
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
await userEvent.click(
|
||||
within(canvasElement).getByRole("button", {
|
||||
name: "Worked for 14s (3 steps)",
|
||||
}),
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const LabeledNarrationFolds: Story = {
|
||||
args: {
|
||||
parsedMessages: parseMessagesWithMergedTools([
|
||||
MockWorkingMessages[0],
|
||||
{
|
||||
...MockChatMessage,
|
||||
id: 2,
|
||||
role: "assistant",
|
||||
created_at: workingFixtureTime(4),
|
||||
content: [
|
||||
{
|
||||
type: "reasoning",
|
||||
text: "Finding the docs page",
|
||||
created_at: workingFixtureTime(1),
|
||||
completed_at: workingFixtureTime(2),
|
||||
},
|
||||
{ type: "text", text: "Checking the docs.", narration: true },
|
||||
{ type: "text", text: "The docs cover workspace sharing." },
|
||||
],
|
||||
},
|
||||
]),
|
||||
},
|
||||
};
|
||||
|
||||
export const LeadInBeforeSearchFolds: Story = {
|
||||
args: {
|
||||
parsedMessages: parseMessagesWithMergedTools(MockLeadInSearchMessages),
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
await userEvent.click(
|
||||
within(canvasElement).getByRole("button", {
|
||||
name: "Worked for 2s (1 step)",
|
||||
}),
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const Paginated: Story = {
|
||||
args: {
|
||||
hasMoreMessages: true,
|
||||
@@ -210,3 +294,10 @@ export const EditingPrecedingMessage: Story = {
|
||||
await userEvent.click(canvas.getByRole("button", { name: "Edit prompt" }));
|
||||
},
|
||||
};
|
||||
|
||||
export const EditingBeforeAnswerWorkFolds: Story = {
|
||||
args: {
|
||||
parsedMessages: parseMessagesWithMergedTools(MockWebSearchAnswerMessages),
|
||||
editingMessageId: 1,
|
||||
},
|
||||
};
|
||||
|
||||
+15
@@ -54,6 +54,21 @@ export const UnknownDuration: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
export const WithoutSteps: Story = {
|
||||
args: { block: { ...MockWorkingBlock, stepCount: 0 } },
|
||||
};
|
||||
|
||||
export const WithoutStepsUnknownDuration: Story = {
|
||||
args: {
|
||||
block: {
|
||||
...MockWorkingBlock,
|
||||
stepCount: 0,
|
||||
startedAt: undefined,
|
||||
endedAt: undefined,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Live: Story = {
|
||||
args: {
|
||||
block: {
|
||||
|
||||
@@ -68,15 +68,18 @@ const LiveLabel: React.FC<LiveLabelProps> = ({ block }) => {
|
||||
};
|
||||
|
||||
const getCompletedWorkingLabel = (block: WorkingBlock): string => {
|
||||
const steps = countLabel(block, block.stepCount, "step");
|
||||
// A block of narration without tool calls has no steps to count.
|
||||
const steps =
|
||||
block.stepCount > 0 ? countLabel(block, block.stepCount, "step") : "";
|
||||
if (block.startedAt === undefined || block.endedAt === undefined) {
|
||||
return `Completed ${steps}`;
|
||||
return steps ? `Completed ${steps}` : "Completed";
|
||||
}
|
||||
|
||||
const duration = humanDurationShort(
|
||||
Math.max(0, block.endedAt - block.startedAt),
|
||||
);
|
||||
return `Worked for ${atLeast(block)}${duration} (${steps})`;
|
||||
const worked = `Worked for ${atLeast(block)}${duration}`;
|
||||
return steps ? `${worked} (${steps})` : worked;
|
||||
};
|
||||
|
||||
const getScrollParent = (element: HTMLElement): HTMLElement | null => {
|
||||
|
||||
@@ -6,28 +6,41 @@ export const asNonEmptyString = (value: unknown): string | undefined => {
|
||||
return next.length > 0 ? next : undefined;
|
||||
};
|
||||
|
||||
const textBlock = (
|
||||
type: "response" | "thinking",
|
||||
text: string,
|
||||
narration: boolean,
|
||||
): RenderBlock =>
|
||||
type === "response" && narration ? { type, text, narration } : { type, text };
|
||||
|
||||
/**
|
||||
* Append a text or thinking block to a render block list, merging
|
||||
* with the previous block when the types match.
|
||||
* with the previous block when the types match. Text never merges
|
||||
* across a provider-executed call or between narration and an answer.
|
||||
*/
|
||||
export const appendTextBlock = (
|
||||
blocks: RenderBlock[],
|
||||
type: "response" | "thinking",
|
||||
text: string,
|
||||
narration = false,
|
||||
): RenderBlock[] => {
|
||||
if (!text.trim()) {
|
||||
return blocks;
|
||||
}
|
||||
const nextBlocks = [...blocks];
|
||||
const last = nextBlocks[nextBlocks.length - 1];
|
||||
if (last && last.type === type) {
|
||||
nextBlocks[nextBlocks.length - 1] = {
|
||||
const keepsApart =
|
||||
last?.type === "response" &&
|
||||
(last.beforeProviderTool || (last.narration ?? false) !== narration);
|
||||
if (last && last.type === type && !keepsApart) {
|
||||
nextBlocks[nextBlocks.length - 1] = textBlock(
|
||||
type,
|
||||
text: `${last.text}${text}`,
|
||||
};
|
||||
`${last.text}${text}`,
|
||||
narration,
|
||||
);
|
||||
return nextBlocks;
|
||||
}
|
||||
nextBlocks.push({ type, text });
|
||||
nextBlocks.push(textBlock(type, text, narration));
|
||||
return nextBlocks;
|
||||
};
|
||||
|
||||
|
||||
@@ -54,11 +54,12 @@ const isWorkspaceFileReferenceBlock = (
|
||||
): block is WorkspaceFileReferenceBlock =>
|
||||
block.type === "workspace-file-reference";
|
||||
|
||||
export const isProviderToolResult = (part: TypesGen.ChatMessagePart): boolean =>
|
||||
part.type === "tool-result" && part.provider_executed === true;
|
||||
|
||||
const isProviderToolResultOnlyMessage = (
|
||||
parts: readonly TypesGen.ChatMessagePart[],
|
||||
): boolean =>
|
||||
parts.length > 0 &&
|
||||
parts.every((part) => part.type === "tool-result" && part.provider_executed);
|
||||
): boolean => parts.length > 0 && parts.every(isProviderToolResult);
|
||||
|
||||
const isMetadataOnlyMessage = (
|
||||
parts: readonly TypesGen.ChatMessagePart[],
|
||||
|
||||
@@ -86,6 +86,22 @@ export const ensureToolBlock = (
|
||||
return [...blocks, { type: "tool", id }];
|
||||
};
|
||||
|
||||
/**
|
||||
* Provider-executed calls render no block, so without this mark the text
|
||||
* before a web search would render as the answer. It skips citations that
|
||||
* streamed after the text.
|
||||
*/
|
||||
export const markTextBeforeProviderTool = (
|
||||
blocks: RenderBlock[],
|
||||
): RenderBlock[] => {
|
||||
const index = blocks.findLastIndex((block) => block.type !== "sources");
|
||||
const block = blocks[index];
|
||||
if (block?.type !== "response" || block.beforeProviderTool) {
|
||||
return blocks;
|
||||
}
|
||||
return blocks.with(index, { ...block, beforeProviderTool: true });
|
||||
};
|
||||
|
||||
const isToolCallPart = (
|
||||
part: TypesGen.ChatMessagePart,
|
||||
): part is TypesGen.ChatToolCallPart => part.type === "tool-call";
|
||||
@@ -226,7 +242,12 @@ export const parseMessageContent = (
|
||||
switch (part.type) {
|
||||
case "text": {
|
||||
parsed.markdown = appendText(parsed.markdown, part.text);
|
||||
parsed.blocks = appendTextBlock(parsed.blocks, "response", part.text);
|
||||
parsed.blocks = appendTextBlock(
|
||||
parsed.blocks,
|
||||
"response",
|
||||
part.text,
|
||||
part.narration,
|
||||
);
|
||||
break;
|
||||
}
|
||||
case "reasoning": {
|
||||
@@ -240,6 +261,7 @@ export const parseMessageContent = (
|
||||
// tool card UI and let the sources component render
|
||||
// their results.
|
||||
if (part.provider_executed) {
|
||||
parsed.blocks = markTextBeforeProviderTool(parsed.blocks);
|
||||
break;
|
||||
}
|
||||
const id = part.tool_call_id || `tool-call-${index}`;
|
||||
|
||||
@@ -123,6 +123,7 @@ export const MockWorkingBlock: WorkingBlock = {
|
||||
startedAt: FIXTURE_NOW - 12_000,
|
||||
endedAt: FIXTURE_NOW,
|
||||
stepCount: 2,
|
||||
endsWithAnswer: false,
|
||||
isLive: false,
|
||||
isPartial: false,
|
||||
};
|
||||
@@ -187,6 +188,137 @@ export const MockWorkingMessages: TypesGen.ChatMessage[] = [
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* MockWorkingMessages whose final answer also reasoned, narrated, and searched
|
||||
* the web from 13s to 16s before writing its text.
|
||||
*/
|
||||
export const MockWebSearchAnswerMessages: TypesGen.ChatMessage[] = [
|
||||
...MockWorkingMessages.slice(0, 5),
|
||||
{
|
||||
...MockChatMessage,
|
||||
id: 6,
|
||||
role: "assistant",
|
||||
created_at: workingFixtureTime(16),
|
||||
content: [
|
||||
{
|
||||
type: "reasoning",
|
||||
text: "Checking the release notes",
|
||||
created_at: workingFixtureTime(13),
|
||||
completed_at: workingFixtureTime(14),
|
||||
},
|
||||
{ type: "text", text: "Looking up the release notes." },
|
||||
{
|
||||
type: "tool-call",
|
||||
tool_call_id: "search",
|
||||
tool_name: "web_search",
|
||||
args: { query: "coder release notes" },
|
||||
provider_executed: true,
|
||||
created_at: workingFixtureTime(14),
|
||||
},
|
||||
{
|
||||
type: "tool-result",
|
||||
tool_call_id: "search",
|
||||
tool_name: "web_search",
|
||||
result: {},
|
||||
provider_executed: true,
|
||||
created_at: workingFixtureTime(15),
|
||||
},
|
||||
{
|
||||
type: "source",
|
||||
url: "https://changelog.example.com",
|
||||
title: "Coder changelog",
|
||||
},
|
||||
{
|
||||
type: "source",
|
||||
url: "https://releases.example.com",
|
||||
title: "Coder releases",
|
||||
},
|
||||
{
|
||||
type: "reasoning",
|
||||
text: "Comparing the two sources",
|
||||
created_at: workingFixtureTime(15),
|
||||
completed_at: workingFixtureTime(16),
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
text: "The workspace runs the latest Coder release.",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* MockWorkingMessages whose final answer searched the web from 14s to 15s and
|
||||
* cited nothing.
|
||||
*/
|
||||
export const MockUncitedSearchAnswerMessages: TypesGen.ChatMessage[] = [
|
||||
...MockWorkingMessages.slice(0, 5),
|
||||
{
|
||||
...MockChatMessage,
|
||||
id: 6,
|
||||
role: "assistant",
|
||||
created_at: workingFixtureTime(16),
|
||||
content: [
|
||||
{
|
||||
type: "tool-call",
|
||||
tool_call_id: "search",
|
||||
tool_name: "web_search",
|
||||
args: { query: "coder release notes" },
|
||||
provider_executed: true,
|
||||
created_at: workingFixtureTime(14),
|
||||
},
|
||||
{
|
||||
type: "tool-result",
|
||||
tool_call_id: "search",
|
||||
tool_name: "web_search",
|
||||
result: {},
|
||||
provider_executed: true,
|
||||
created_at: workingFixtureTime(15),
|
||||
},
|
||||
{ type: "text", text: "No release notes mention the workspace." },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* A turn without tools whose answer narrates, then searches the web from 1s to
|
||||
* 3s before writing its text, with no reasoning.
|
||||
*/
|
||||
export const MockLeadInSearchMessages: TypesGen.ChatMessage[] = [
|
||||
MockWorkingMessages[0],
|
||||
{
|
||||
...MockChatMessage,
|
||||
id: 2,
|
||||
role: "assistant",
|
||||
created_at: workingFixtureTime(4),
|
||||
content: [
|
||||
{ type: "text", text: "I'll look up the latest Coder release." },
|
||||
{
|
||||
type: "tool-call",
|
||||
tool_call_id: "search",
|
||||
tool_name: "web_search",
|
||||
args: { query: "latest coder release" },
|
||||
provider_executed: true,
|
||||
created_at: workingFixtureTime(1),
|
||||
},
|
||||
{
|
||||
type: "source",
|
||||
url: "https://releases.example.com",
|
||||
title: "Coder releases",
|
||||
},
|
||||
{
|
||||
type: "tool-result",
|
||||
tool_call_id: "search",
|
||||
tool_name: "web_search",
|
||||
result: {},
|
||||
provider_executed: true,
|
||||
created_at: workingFixtureTime(3),
|
||||
},
|
||||
{ type: "text", text: "The latest Coder release is v2.30." },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
/** A question the agent asks after the first step of MockWorkingMessages. */
|
||||
export const MockQuestionCallMessage: TypesGen.ChatMessage = {
|
||||
...MockChatMessage,
|
||||
|
||||
@@ -716,17 +716,20 @@ describe("applyMessagePartToStreamState", () => {
|
||||
expect(prev.toolCalls).toEqual({});
|
||||
});
|
||||
|
||||
it("skips provider_executed tool-result parts", () => {
|
||||
const prev = createEmptyStreamState();
|
||||
const result = applyMessagePartToStreamState(prev, {
|
||||
it("records a provider_executed tool-result part without a tool card", () => {
|
||||
const part: ChatMessagePart = {
|
||||
type: "tool-result",
|
||||
tool_name: "web_search",
|
||||
tool_call_id: "tc-1",
|
||||
provider_executed: true,
|
||||
result: { output: "search results" },
|
||||
};
|
||||
const state = applyMessagePartToStreamState(null, part);
|
||||
expect(state).toEqual({
|
||||
...createEmptyStreamState(),
|
||||
providerToolRan: true,
|
||||
});
|
||||
expect(result).toBe(prev);
|
||||
expect(prev.toolResults).toEqual({});
|
||||
expect(applyMessagePartToStreamState(state, part)).toBe(state);
|
||||
});
|
||||
|
||||
it("adds a file block from a file part with data", () => {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { appendTextBlock } from "./blockUtils";
|
||||
import {
|
||||
ensureToolBlock,
|
||||
getToolResultStatus,
|
||||
markTextBeforeProviderTool,
|
||||
parseToolResultIsError,
|
||||
} from "./messageParsing";
|
||||
import { mergeStreamPayload } from "./streamingJson";
|
||||
@@ -33,7 +34,12 @@ export const applyMessagePartToStreamState = (
|
||||
}
|
||||
return {
|
||||
...nextState,
|
||||
blocks: appendTextBlock(nextState.blocks, "response", part.text),
|
||||
blocks: appendTextBlock(
|
||||
nextState.blocks,
|
||||
"response",
|
||||
part.text,
|
||||
part.narration,
|
||||
),
|
||||
};
|
||||
}
|
||||
case "reasoning": {
|
||||
@@ -51,7 +57,11 @@ export const applyMessagePartToStreamState = (
|
||||
// handled natively by the provider — skip rendering them
|
||||
// as tool cards.
|
||||
if (part.provider_executed) {
|
||||
return prev;
|
||||
const blocks = markTextBeforeProviderTool(nextState.blocks);
|
||||
const startedAt = nextState.startedAt ?? part.created_at;
|
||||
return blocks === nextState.blocks && startedAt === nextState.startedAt
|
||||
? prev
|
||||
: { ...nextState, startedAt, blocks };
|
||||
}
|
||||
const existingByName = Object.values(nextState.toolCalls).find(
|
||||
(call) => call.name === part.tool_name,
|
||||
@@ -96,9 +106,13 @@ export const applyMessagePartToStreamState = (
|
||||
};
|
||||
}
|
||||
case "tool-result": {
|
||||
// Skip synthetic results for provider-executed tools.
|
||||
// Provider-executed results render no card, but the search they end
|
||||
// is a step. Recording it may create an empty state, which still
|
||||
// shows the Thinking indicator.
|
||||
if (part.provider_executed) {
|
||||
return prev;
|
||||
return nextState.providerToolRan
|
||||
? nextState
|
||||
: { ...nextState, providerToolRan: true };
|
||||
}
|
||||
const existingByName = Object.values(nextState.toolResults).find(
|
||||
(result) => result.name === part.tool_name,
|
||||
|
||||
@@ -44,6 +44,13 @@ export type RenderBlock =
|
||||
| {
|
||||
type: "response";
|
||||
text: string;
|
||||
/**
|
||||
* A provider-executed call, such as web search, followed this text,
|
||||
* so later text starts a new block.
|
||||
*/
|
||||
beforeProviderTool?: boolean;
|
||||
/** The model labeled this text as commentary on its work. */
|
||||
narration?: boolean;
|
||||
}
|
||||
| {
|
||||
type: "thinking";
|
||||
@@ -121,4 +128,9 @@ export type StreamState = {
|
||||
toolResults: Record<string, StreamToolResult>;
|
||||
sources: Array<{ url: string; title: string }>;
|
||||
startedAt?: string;
|
||||
/**
|
||||
* Set by a provider-executed result, so a search counts as a step even
|
||||
* before or without citations.
|
||||
*/
|
||||
providerToolRan?: boolean;
|
||||
};
|
||||
|
||||
@@ -11,6 +11,7 @@ import { assignTimelineRows, type TimelineRow } from "./timelineRows";
|
||||
import {
|
||||
type GroupWorkingBlocksOptions,
|
||||
groupWorkingBlocks,
|
||||
splitRowBlocks,
|
||||
type WorkingBlock,
|
||||
} from "./workingBlockGrouping";
|
||||
|
||||
@@ -32,6 +33,11 @@ const text = (value: string): TypesGen.ChatMessagePart => ({
|
||||
type: "text",
|
||||
text: value,
|
||||
});
|
||||
const narration = (value: string): TypesGen.ChatMessagePart => ({
|
||||
type: "text",
|
||||
text: value,
|
||||
narration: true,
|
||||
});
|
||||
const reasoning = (
|
||||
value: string,
|
||||
createdAt?: string,
|
||||
@@ -42,6 +48,32 @@ const reasoning = (
|
||||
created_at: createdAt,
|
||||
completed_at: completedAt,
|
||||
});
|
||||
const source = (url: string, title: string): TypesGen.ChatMessagePart => ({
|
||||
type: "source",
|
||||
url,
|
||||
title,
|
||||
});
|
||||
const citation = source("https://go.example.com", "Go");
|
||||
const searchCall = (
|
||||
id: string,
|
||||
createdAt?: string,
|
||||
): TypesGen.ChatMessagePart => ({
|
||||
type: "tool-call",
|
||||
tool_call_id: id,
|
||||
tool_name: "web_search",
|
||||
provider_executed: true,
|
||||
created_at: createdAt,
|
||||
});
|
||||
const searchResult = (
|
||||
id: string,
|
||||
createdAt?: string,
|
||||
): TypesGen.ChatMessagePart => ({
|
||||
type: "tool-result",
|
||||
tool_call_id: id,
|
||||
tool_name: "web_search",
|
||||
provider_executed: true,
|
||||
created_at: createdAt,
|
||||
});
|
||||
const call = (
|
||||
id: string,
|
||||
createdAt?: string,
|
||||
@@ -164,11 +196,133 @@ describe("groupWorkingBlocks", () => {
|
||||
expect(rowIds(rows, blocks[0].rowIndices)).toEqual([
|
||||
steps[0].id,
|
||||
steps[2].id,
|
||||
answer.id,
|
||||
]);
|
||||
// Reasoning start counts toward the wall-clock span.
|
||||
expect(blocks[0].startedAt).toBe(WORKING_FIXTURE_START + 500);
|
||||
});
|
||||
|
||||
it("folds the final answer's reasoning and web search into the block it ends", () => {
|
||||
const prompt = user("Go");
|
||||
const steps = [...step("a", 1, 2), ...step("b", 3, 4)];
|
||||
const answer = message(
|
||||
"assistant",
|
||||
[
|
||||
reasoning("Check the docs", at(5), at(9)),
|
||||
source("https://example.com/a", "A"),
|
||||
source("https://example.com/b", "B"),
|
||||
text("Done."),
|
||||
],
|
||||
at(10),
|
||||
);
|
||||
const { rows, blocks } = group([prompt, ...steps, answer]);
|
||||
|
||||
expect(blocks).toHaveLength(1);
|
||||
expect(rowIds(rows, blocks[0].rowIndices)).toEqual([
|
||||
steps[0].id,
|
||||
steps[2].id,
|
||||
answer.id,
|
||||
]);
|
||||
expect(blocks[0]).toMatchObject({
|
||||
stepCount: 3,
|
||||
endsWithAnswer: true,
|
||||
endedAt: WORKING_FIXTURE_START + 9000,
|
||||
key: `working:through:message:${answer.id}`,
|
||||
});
|
||||
});
|
||||
|
||||
it("treats an answer whose citations trail its text as the block's answer", () => {
|
||||
const prompt = user("Go");
|
||||
const steps = step("a", 1, 2);
|
||||
const answer = message("assistant", [
|
||||
text("Done."),
|
||||
source("https://example.com", "Example"),
|
||||
]);
|
||||
const { blocks } = group([prompt, ...steps, answer]);
|
||||
|
||||
expect(blocks[0]).toMatchObject({ stepCount: 2, endsWithAnswer: true });
|
||||
});
|
||||
|
||||
it("starts a one-step block from an answer that searched in a turn without tools", () => {
|
||||
const prompt = user("Go");
|
||||
const answer = message("assistant", [
|
||||
reasoning("Look it up"),
|
||||
source("https://example.com", "Example"),
|
||||
text("Found it."),
|
||||
]);
|
||||
const { rows, blocks } = group([prompt, answer]);
|
||||
|
||||
expect(blocks).toHaveLength(1);
|
||||
expect(rowIds(rows, blocks[0].rowIndices)).toEqual([answer.id]);
|
||||
expect(blocks[0]).toMatchObject({ stepCount: 1, endsWithAnswer: true });
|
||||
});
|
||||
|
||||
it("counts a reissued search once, ignoring its unanswered first call", () => {
|
||||
const prompt = user("Go");
|
||||
const steps = step("a", 1, 2, { leading: [searchCall("unanswered")] });
|
||||
const answer = message("assistant", [
|
||||
searchCall("search"),
|
||||
citation,
|
||||
searchResult("search"),
|
||||
text("Go 1.27 is out."),
|
||||
]);
|
||||
const { blocks } = group([prompt, ...steps, answer]);
|
||||
|
||||
expect(blocks).toMatchObject([{ stepCount: 2, endsWithAnswer: true }]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ name: "no answer", answers: [] },
|
||||
{
|
||||
name: "a cited answer",
|
||||
answers: [[text("Go 1.27"), citation, text(" is out."), citation]],
|
||||
},
|
||||
])(
|
||||
"keeps the step of a search an interrupt committed apart from its row with $name",
|
||||
({ answers }) => {
|
||||
const prompt = user("Go");
|
||||
const steps = step("a", 1, 2);
|
||||
// chatd commits the call and result as separate messages that render
|
||||
// no row.
|
||||
const search = [
|
||||
message("assistant", [searchCall("s", at(3))]),
|
||||
message("tool", [searchResult("s", at(4))]),
|
||||
];
|
||||
const after = answers.map((parts) => message("assistant", parts));
|
||||
|
||||
expect(
|
||||
group([prompt, ...steps, ...search, ...after]).blocks,
|
||||
).toMatchObject([{ stepCount: 2 }]);
|
||||
},
|
||||
);
|
||||
|
||||
it("counts a search that rendered no row before the block's first step", () => {
|
||||
const prompt = user("Go");
|
||||
// The answer's own search stays out of the block that follows it.
|
||||
const answer = message("assistant", [
|
||||
searchCall("first", at(1)),
|
||||
searchResult("first", at(2)),
|
||||
text("Nothing found."),
|
||||
]);
|
||||
const search = message("assistant", [
|
||||
searchCall("second", at(3)),
|
||||
searchResult("second", at(4)),
|
||||
]);
|
||||
const steps = step("a", 5, 6);
|
||||
|
||||
expect(group([prompt, answer, search, ...steps]).blocks).toMatchObject([
|
||||
{ stepCount: 2, startedAt: WORKING_FIXTURE_START + 3000 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("leaves an answer's reasoning unfolded in a turn without steps", () => {
|
||||
const prompt = user("Go");
|
||||
const answer = message("assistant", [reasoning("Easy"), text("Done.")]);
|
||||
const { blocks } = group([prompt, answer]);
|
||||
|
||||
expect(blocks).toEqual([]);
|
||||
});
|
||||
|
||||
it("leaves a standalone reasoning-only row unfolded", () => {
|
||||
const prompt = user("Go");
|
||||
const thinking = message("assistant", [reasoning("Just thinking", at(1))]);
|
||||
@@ -194,6 +348,68 @@ describe("groupWorkingBlocks", () => {
|
||||
expect(blocks[0].stepCount).toBe(2);
|
||||
});
|
||||
|
||||
it("folds a web search row that sits between tool steps", () => {
|
||||
const prompt = user("Go");
|
||||
const first = step("a", 1, 2);
|
||||
const search = message("assistant", [
|
||||
reasoning("Search"),
|
||||
source("https://example.com", "Example"),
|
||||
]);
|
||||
const second = step("b", 5, 6);
|
||||
const { rows, blocks } = group([prompt, ...first, search, ...second]);
|
||||
|
||||
expect(blocks).toHaveLength(1);
|
||||
expect(rowIds(rows, blocks[0].rowIndices)).toEqual([
|
||||
first[0].id,
|
||||
search.id,
|
||||
second[0].id,
|
||||
]);
|
||||
expect(blocks[0]).toMatchObject({ stepCount: 3, endsWithAnswer: false });
|
||||
});
|
||||
|
||||
it("starts a new block after an answer row closes one", () => {
|
||||
const prompt = user("Go");
|
||||
const first = step("a", 1, 2);
|
||||
const interlude = message("assistant", [
|
||||
reasoning("Halfway"),
|
||||
text("Halfway there."),
|
||||
]);
|
||||
const second = step("b", 4, 5);
|
||||
const { rows, blocks } = group([prompt, ...first, interlude, ...second]);
|
||||
|
||||
expect(blocks).toHaveLength(2);
|
||||
expect(rowIds(rows, blocks[0].rowIndices)).toEqual([
|
||||
first[0].id,
|
||||
interlude.id,
|
||||
]);
|
||||
expect(blocks[0].endsWithAnswer).toBe(true);
|
||||
expect(rowIds(rows, blocks[1].rowIndices)).toEqual([second[0].id]);
|
||||
});
|
||||
|
||||
it("counts a search between two blocks once", () => {
|
||||
const prompt = user("Go");
|
||||
const first = step("a", 1, 2);
|
||||
const interlude = message("assistant", [
|
||||
reasoning("Halfway"),
|
||||
text("Halfway there."),
|
||||
]);
|
||||
const search = [
|
||||
message("assistant", [searchCall("s", at(3))]),
|
||||
message("tool", [searchResult("s", at(4))]),
|
||||
];
|
||||
const second = step("b", 5, 6);
|
||||
const { blocks } = group([
|
||||
prompt,
|
||||
...first,
|
||||
interlude,
|
||||
...search,
|
||||
...second,
|
||||
]);
|
||||
|
||||
expect(blocks).toHaveLength(2);
|
||||
expect(blocks.reduce((total, block) => total + block.stepCount, 0)).toBe(3);
|
||||
});
|
||||
|
||||
it("splits a turn at an interleaved answer row", () => {
|
||||
const prompt = user("Go");
|
||||
const first = step("a", 1, 2);
|
||||
@@ -414,6 +630,256 @@ describe("groupWorkingBlocks", () => {
|
||||
expect(blocks[0].isLive).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps a live answer row with reasoning in the live block", () => {
|
||||
const prompt = user("Go");
|
||||
const steps = step("a", 1, 2);
|
||||
const { rows, blocks } = groupLive(
|
||||
[prompt, ...steps],
|
||||
[reasoning("Wrap up", at(3)), text("Here is what I found")],
|
||||
);
|
||||
|
||||
expect(blocks).toHaveLength(1);
|
||||
expect(rowIds(rows, blocks[0].rowIndices)).toEqual([steps[0].id, "live"]);
|
||||
expect(blocks[0]).toMatchObject({ isLive: true, endsWithAnswer: true });
|
||||
});
|
||||
|
||||
it("folds an answer's search without citations into the block it ends", () => {
|
||||
const prompt = user("Go");
|
||||
const steps = step("a", 1, 2);
|
||||
const parts = [
|
||||
searchCall("s", at(3)),
|
||||
searchResult("s", at(4)),
|
||||
text("Nothing new."),
|
||||
];
|
||||
const answer = message("assistant", parts);
|
||||
const live = groupLive([prompt, ...steps], parts);
|
||||
const persisted = group([prompt, ...steps, answer]);
|
||||
|
||||
expect(rowIds(live.rows, live.blocks[0].rowIndices)).toEqual([
|
||||
steps[0].id,
|
||||
"live",
|
||||
]);
|
||||
expect(live.blocks).toMatchObject([
|
||||
{ stepCount: 2, endsWithAnswer: true, isLive: true },
|
||||
]);
|
||||
expect(rowIds(persisted.rows, persisted.blocks[0].rowIndices)).toEqual([
|
||||
steps[0].id,
|
||||
answer.id,
|
||||
]);
|
||||
expect(persisted.blocks).toMatchObject([
|
||||
{
|
||||
stepCount: 2,
|
||||
endsWithAnswer: true,
|
||||
endedAt: WORKING_FIXTURE_START + 4000,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("starts no block from a search without citations alone", () => {
|
||||
const prompt = user("Go");
|
||||
const parts = [searchCall("s", at(1)), searchResult("s"), text("Hi.")];
|
||||
|
||||
for (const index of parts.keys()) {
|
||||
expect(groupLive([prompt], parts.slice(0, index + 1)).blocks).toEqual(
|
||||
[],
|
||||
);
|
||||
}
|
||||
expect(group([prompt, message("assistant", parts)]).blocks).toEqual([]);
|
||||
});
|
||||
|
||||
it("unfolds a tool-less live turn's reasoning once its answer starts", () => {
|
||||
const { blocks } = groupLive(
|
||||
[user("Go")],
|
||||
[reasoning("Planning", at(1)), text("Here you go")],
|
||||
);
|
||||
|
||||
expect(blocks).toEqual([]);
|
||||
});
|
||||
|
||||
it("folds labeled narration into the live block from its first delta", () => {
|
||||
const prompt = user("Go");
|
||||
const parts = [
|
||||
reasoning("Planning", at(1)),
|
||||
narration("Now I'll "),
|
||||
narration("update the tests."),
|
||||
call("a", at(2)),
|
||||
];
|
||||
|
||||
for (const index of parts.keys()) {
|
||||
const { rows, blocks } = groupLive([prompt], parts.slice(0, index + 1));
|
||||
expect(blocks).toMatchObject([
|
||||
{
|
||||
key: `working:live:message:${prompt.id}:0`,
|
||||
endsWithAnswer: false,
|
||||
},
|
||||
]);
|
||||
expect(rowIds(rows, blocks[0].rowIndices)).toEqual(["live"]);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps a tool-less turn's labeled narration folded above its answer", () => {
|
||||
const prompt = user("Go");
|
||||
const parts = [narration("Checking the docs."), text("They cover it.")];
|
||||
const live = groupLive([prompt], parts);
|
||||
const persisted = group([prompt, message("assistant", parts)]);
|
||||
|
||||
for (const { blocks } of [live, persisted]) {
|
||||
expect(blocks).toMatchObject([{ stepCount: 0, endsWithAnswer: true }]);
|
||||
}
|
||||
});
|
||||
|
||||
// OpenAI streams a search's citations inside the answer text that follows
|
||||
// it, and stores them before that text.
|
||||
it.each([
|
||||
{
|
||||
name: "reasoning before the search",
|
||||
live: [
|
||||
reasoning("Look it up", at(1)),
|
||||
searchCall("a"),
|
||||
searchResult("a"),
|
||||
text("Go 1.27"),
|
||||
citation,
|
||||
text(" is out."),
|
||||
],
|
||||
persisted: [
|
||||
reasoning("Look it up", at(1), at(2)),
|
||||
searchCall("a"),
|
||||
searchResult("a"),
|
||||
citation,
|
||||
text("Go 1.27 is out."),
|
||||
],
|
||||
stepCount: 1,
|
||||
},
|
||||
{
|
||||
name: "the search before its reasoning",
|
||||
live: [
|
||||
searchCall("a", at(1)),
|
||||
searchResult("a"),
|
||||
reasoning("Compare", at(2)),
|
||||
text("Go 1.27"),
|
||||
citation,
|
||||
text(" is out."),
|
||||
],
|
||||
persisted: [
|
||||
searchCall("a", at(1)),
|
||||
searchResult("a"),
|
||||
reasoning("Compare", at(2), at(3)),
|
||||
citation,
|
||||
text("Go 1.27 is out."),
|
||||
],
|
||||
stepCount: 1,
|
||||
},
|
||||
{
|
||||
name: "narration before the search",
|
||||
live: [
|
||||
text("Searching."),
|
||||
searchCall("a", at(1)),
|
||||
searchResult("a"),
|
||||
text("Go 1.27"),
|
||||
citation,
|
||||
text(" is out."),
|
||||
],
|
||||
persisted: [
|
||||
text("Searching."),
|
||||
searchCall("a", at(1)),
|
||||
searchResult("a"),
|
||||
citation,
|
||||
text("Go 1.27 is out."),
|
||||
],
|
||||
stepCount: 1,
|
||||
},
|
||||
{
|
||||
name: "two searches",
|
||||
live: [
|
||||
reasoning("Look it up", at(1)),
|
||||
searchCall("a"),
|
||||
searchResult("a"),
|
||||
searchCall("b"),
|
||||
searchResult("b"),
|
||||
text("Go 1.27"),
|
||||
citation,
|
||||
text(" is out."),
|
||||
],
|
||||
persisted: [
|
||||
reasoning("Look it up", at(1), at(2)),
|
||||
searchCall("a"),
|
||||
searchResult("a"),
|
||||
searchCall("b"),
|
||||
searchResult("b"),
|
||||
citation,
|
||||
text("Go 1.27 is out."),
|
||||
],
|
||||
stepCount: 2,
|
||||
},
|
||||
{
|
||||
name: "a search without citations",
|
||||
live: [
|
||||
reasoning("Look it up", at(1)),
|
||||
searchCall("a"),
|
||||
searchResult("a"),
|
||||
text("Nothing new."),
|
||||
],
|
||||
persisted: [
|
||||
reasoning("Look it up", at(1), at(2)),
|
||||
searchCall("a"),
|
||||
searchResult("a"),
|
||||
text("Nothing new."),
|
||||
],
|
||||
stepCount: 1,
|
||||
},
|
||||
])(
|
||||
"keeps one search block through streaming and persistence with $name",
|
||||
({ live, persisted, stepCount }) => {
|
||||
const prompt = user("Go");
|
||||
const liveKey = `working:live:message:${prompt.id}:0`;
|
||||
const prefixBlocks = live.map(
|
||||
(_, index) => groupLive([prompt], live.slice(0, index + 1)).blocks,
|
||||
);
|
||||
const firstBlockIndex = prefixBlocks.findIndex(
|
||||
(blocks) => blocks.length > 0,
|
||||
);
|
||||
|
||||
expect(firstBlockIndex).not.toBe(-1);
|
||||
for (const blocks of prefixBlocks.slice(firstBlockIndex)) {
|
||||
expect(blocks).toMatchObject([
|
||||
{
|
||||
key: liveKey,
|
||||
isLive: true,
|
||||
startedAt: WORKING_FIXTURE_START + 1000,
|
||||
},
|
||||
]);
|
||||
}
|
||||
expect(
|
||||
group([prompt, message("assistant", persisted)]).blocks,
|
||||
).toMatchObject([
|
||||
{
|
||||
liveKey,
|
||||
stepCount,
|
||||
endsWithAnswer: true,
|
||||
isLive: false,
|
||||
startedAt: WORKING_FIXTURE_START + 1000,
|
||||
},
|
||||
]);
|
||||
},
|
||||
);
|
||||
|
||||
it("completes a block once its answer row persists while the chat still runs", () => {
|
||||
const prompt = user("Go");
|
||||
const steps = step("a", 1, 2);
|
||||
const answer = message(
|
||||
"assistant",
|
||||
[reasoning("Wrap up", at(3), at(4)), text("Done.")],
|
||||
at(5),
|
||||
);
|
||||
const { blocks } = group([prompt, ...steps, answer], { isWorking: true });
|
||||
|
||||
expect(blocks).toHaveLength(1);
|
||||
expect(blocks[0]).toMatchObject({
|
||||
isLive: false,
|
||||
endedAt: WORKING_FIXTURE_START + 4000,
|
||||
});
|
||||
});
|
||||
|
||||
it("folds an idle live row into the block it follows", () => {
|
||||
const prompt = user("Go");
|
||||
const steps = step("a", 1, 2);
|
||||
@@ -587,3 +1053,145 @@ describe("groupWorkingBlocks", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("splitRowBlocks", () => {
|
||||
const answerOf = (content: TypesGen.ChatMessagePart[]) => {
|
||||
const [{ parsed }] = parseMessagesWithMergedTools([
|
||||
message("assistant", content),
|
||||
]);
|
||||
return splitRowBlocks(parsed.blocks, parsed.tools).answer;
|
||||
};
|
||||
|
||||
it("folds narration that precedes the answer's last reasoning", () => {
|
||||
expect(
|
||||
answerOf([
|
||||
reasoning("Plan"),
|
||||
text("Looking it up."),
|
||||
source("https://example.com", "Example"),
|
||||
reasoning("Compare"),
|
||||
text("Done."),
|
||||
]),
|
||||
).toEqual([{ type: "response", text: "Done." }]);
|
||||
});
|
||||
|
||||
// Persisted rows store text at its end, after the citations streamed in it.
|
||||
it.each([
|
||||
{
|
||||
name: "narration before search results",
|
||||
persisted: [
|
||||
text("Searching."),
|
||||
searchCall("a"),
|
||||
citation,
|
||||
searchResult("a"),
|
||||
text("Go 1.27 is out."),
|
||||
],
|
||||
live: [
|
||||
text("Searching."),
|
||||
searchCall("a"),
|
||||
citation,
|
||||
searchResult("a"),
|
||||
text("Go 1.27 is out."),
|
||||
],
|
||||
answer: "Go 1.27 is out.",
|
||||
},
|
||||
{
|
||||
name: "narration before cited text",
|
||||
persisted: [
|
||||
text("Searching."),
|
||||
searchCall("a"),
|
||||
searchResult("a"),
|
||||
citation,
|
||||
text("Go 1.27 is out. Rust 1.98 is out."),
|
||||
],
|
||||
live: [
|
||||
text("Searching."),
|
||||
searchCall("a"),
|
||||
searchResult("a"),
|
||||
text("Go 1.27 is out."),
|
||||
citation,
|
||||
text(" Rust 1.98 is out."),
|
||||
],
|
||||
answer: "Go 1.27 is out. Rust 1.98 is out.",
|
||||
},
|
||||
{
|
||||
name: "cited text before a search",
|
||||
persisted: [
|
||||
citation,
|
||||
text("Go 1.27 is out."),
|
||||
searchCall("a"),
|
||||
searchResult("a"),
|
||||
text("Rust 1.98 is out."),
|
||||
],
|
||||
live: [
|
||||
text("Go 1.27 is out."),
|
||||
citation,
|
||||
searchCall("a"),
|
||||
searchResult("a"),
|
||||
text("Rust 1.98 is out."),
|
||||
],
|
||||
answer: "Rust 1.98 is out.",
|
||||
},
|
||||
{
|
||||
name: "cited text without a search call",
|
||||
persisted: [citation, text("Go 1.27 is out. Rust 1.98 is out.")],
|
||||
live: [text("Go 1.27 is out."), citation, text(" Rust 1.98 is out.")],
|
||||
answer: "Go 1.27 is out. Rust 1.98 is out.",
|
||||
},
|
||||
{
|
||||
name: "labeled narration before the answer",
|
||||
persisted: [narration("Checking the docs."), text("They cover it.")],
|
||||
live: [
|
||||
narration("Checking "),
|
||||
narration("the docs."),
|
||||
text("They cover it."),
|
||||
],
|
||||
answer: "They cover it.",
|
||||
},
|
||||
])(
|
||||
"splits $name the same live and persisted",
|
||||
({ persisted, live, answer }) => {
|
||||
const { streamState, streamTools } = buildStreamRenderState(live);
|
||||
if (!streamState) {
|
||||
throw new Error("The live parts built no stream state.");
|
||||
}
|
||||
const expected = [{ type: "response", text: answer }];
|
||||
|
||||
expect(answerOf(persisted)).toEqual(expected);
|
||||
expect(splitRowBlocks(streamState.blocks, streamTools).answer).toEqual(
|
||||
expected,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
const hiddenCall = (id: string): TypesGen.ChatMessagePart => ({
|
||||
type: "tool-call",
|
||||
tool_call_id: id,
|
||||
tool_name: "execute",
|
||||
args: { command: "" },
|
||||
});
|
||||
|
||||
it("ignores hidden tools when splitting the answer", () => {
|
||||
expect(
|
||||
answerOf([
|
||||
reasoning("Plan"),
|
||||
hiddenCall("before"),
|
||||
text("Done."),
|
||||
hiddenCall("after"),
|
||||
]),
|
||||
).toEqual([{ type: "response", text: "Done." }]);
|
||||
});
|
||||
|
||||
it("keeps answer text on either side of a hidden tool apart", () => {
|
||||
expect(
|
||||
answerOf([
|
||||
reasoning("Plan"),
|
||||
text("Checked the logs."),
|
||||
hiddenCall("between"),
|
||||
text("The fix is in."),
|
||||
]),
|
||||
).toEqual([
|
||||
{ type: "response", text: "Checked the logs." },
|
||||
{ type: "response", text: "The fix is in." },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { getVisibleContent } from "./messageHelpers";
|
||||
import type * as TypesGen from "#/api/typesGenerated";
|
||||
import { appendTextBlock } from "./blockUtils";
|
||||
import { getVisibleContent, isProviderToolResult } from "./messageHelpers";
|
||||
import type { TimelineRow } from "./timelineRows";
|
||||
import type {
|
||||
MergedTool,
|
||||
@@ -22,8 +24,10 @@ export type WorkingBlock = {
|
||||
* at the front when history is prepended into that row.
|
||||
*/
|
||||
memberIds: number[];
|
||||
/** Distinct visible tools, not rows. */
|
||||
/** Distinct visible tools plus web searches, not rows. */
|
||||
stepCount: number;
|
||||
/** The last row's answer renders after the block; only its work folds in. */
|
||||
endsWithAnswer: boolean;
|
||||
isLive: boolean;
|
||||
/** Unloaded older history may hold earlier rows of this block. */
|
||||
isPartial: boolean;
|
||||
@@ -53,19 +57,74 @@ const UNCOLLAPSIBLE_TOOLS: ReadonlySet<string> = new Set([
|
||||
"chat_cleared",
|
||||
]);
|
||||
|
||||
type RowContent = ReturnType<typeof getVisibleContent>;
|
||||
/**
|
||||
* A row that ends a working block renders its work inside the block and its
|
||||
* answer after it.
|
||||
*/
|
||||
export type RowSection = "work" | "answer";
|
||||
|
||||
/**
|
||||
* A step row is assistant output that ends in tool activity or reasoning
|
||||
* rather than an answer. Text that precedes a tool call is narration and
|
||||
* folds with it; text that ends a row is an answer and stays visible. A
|
||||
* live row with no output yet is the turn working on its next step.
|
||||
* The answer is the content after a row's last reasoning, tool call, or
|
||||
* labeled narration, web searches included; text before it is narration.
|
||||
* Sources fold without splitting the answer, since OpenAI streams citations
|
||||
* between the deltas of one text part.
|
||||
*/
|
||||
const getStepRowContent = (
|
||||
export const splitRowBlocks = (
|
||||
blocks: readonly RenderBlock[],
|
||||
tools: readonly MergedTool[],
|
||||
) => {
|
||||
const visible = new Set(getVisibleContent(blocks, tools).visibleBlocks);
|
||||
const lastWorkIndex = blocks.findLastIndex(
|
||||
(block) =>
|
||||
visible.has(block) &&
|
||||
(block.type === "thinking" ||
|
||||
block.type === "tool" ||
|
||||
(block.type === "response" &&
|
||||
(block.beforeProviderTool || block.narration))),
|
||||
);
|
||||
const work: RenderBlock[] = [];
|
||||
let answer: RenderBlock[] = [];
|
||||
let previous: RenderBlock | undefined;
|
||||
for (const [index, block] of blocks.entries()) {
|
||||
if (
|
||||
index <= lastWorkIndex ||
|
||||
!visible.has(block) ||
|
||||
block.type === "sources"
|
||||
) {
|
||||
work.push(block);
|
||||
} else if (block.type === "response" && previous?.type === "response") {
|
||||
answer = appendTextBlock(answer, "response", block.text);
|
||||
} else {
|
||||
answer = [...answer, block];
|
||||
}
|
||||
if (block.type !== "sources") {
|
||||
previous = block;
|
||||
}
|
||||
}
|
||||
|
||||
return { work, answer } satisfies Record<RowSection, RenderBlock[]>;
|
||||
};
|
||||
|
||||
type RowContent = ReturnType<typeof getVisibleContent>;
|
||||
|
||||
type MemberRow = {
|
||||
content: RowContent;
|
||||
endsWithAnswer: boolean;
|
||||
showsWork: boolean;
|
||||
narrates: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* A row ending in an answer joins only when it also did work, even a search
|
||||
* without citations, and then closes the block. A live row with no output yet
|
||||
* is the turn working on its next step.
|
||||
*/
|
||||
const getMemberRow = (
|
||||
row: TimelineRow,
|
||||
options: GroupWorkingBlocksOptions,
|
||||
): RowContent | undefined => {
|
||||
): MemberRow | undefined => {
|
||||
let content: RowContent;
|
||||
let searched: boolean;
|
||||
|
||||
if (row.type === "live") {
|
||||
if (!options.isLiveRowCollapsible) {
|
||||
@@ -73,6 +132,7 @@ const getStepRowContent = (
|
||||
}
|
||||
|
||||
content = getVisibleContent(options.liveBlocks, options.liveTools);
|
||||
searched = options.streamState?.providerToolRan ?? false;
|
||||
} else {
|
||||
const { message, parsed } = row.entry;
|
||||
if (message.role !== "assistant" || parsed.hookNotices.length > 0) {
|
||||
@@ -80,11 +140,14 @@ const getStepRowContent = (
|
||||
}
|
||||
|
||||
content = getVisibleContent(parsed.blocks, parsed.tools);
|
||||
searched = (message.content ?? []).some(isProviderToolResult);
|
||||
}
|
||||
|
||||
const { visibleBlocks, visibleTools } = content;
|
||||
if (visibleBlocks.length === 0) {
|
||||
return row.type === "live" ? content : undefined;
|
||||
return row.type === "live"
|
||||
? { content, endsWithAnswer: false, showsWork: false, narrates: false }
|
||||
: undefined;
|
||||
}
|
||||
|
||||
if (visibleTools.some((tool) => UNCOLLAPSIBLE_TOOLS.has(tool.name))) {
|
||||
@@ -100,12 +163,17 @@ const getStepRowContent = (
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const last = visibleBlocks[visibleBlocks.length - 1];
|
||||
if (last.type !== "tool" && last.type !== "thinking") {
|
||||
return undefined;
|
||||
const { work, answer } = splitRowBlocks(visibleBlocks, visibleTools);
|
||||
const narrates = work.some(
|
||||
(block) => block.type === "response" && block.narration,
|
||||
);
|
||||
if (answer.length === 0) {
|
||||
return { content, endsWithAnswer: false, showsWork: true, narrates };
|
||||
}
|
||||
|
||||
return content;
|
||||
return work.length > 0 || searched
|
||||
? { content, endsWithAnswer: true, showsWork: work.length > 0, narrates }
|
||||
: undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -126,6 +194,13 @@ const getPartTimestamps = (entry: ParsedMessageEntry): string[] =>
|
||||
})
|
||||
.filter((timestamp) => timestamp !== undefined);
|
||||
|
||||
/**
|
||||
* Results, not calls: a step can end with a provider call unanswered, and the
|
||||
* next step issues that call again.
|
||||
*/
|
||||
const countProviderResults = (message: TypesGen.ChatMessage): number =>
|
||||
(message.content ?? []).filter(isProviderToolResult).length;
|
||||
|
||||
const rowMessageIds = (row: TimelineRow): readonly number[] =>
|
||||
row.type === "live" ? [] : (row.entry.mergedFrom ?? [row.entry.message.id]);
|
||||
|
||||
@@ -141,9 +216,12 @@ export const groupWorkingBlocks = (
|
||||
type Draft = {
|
||||
rowIndices: number[];
|
||||
toolIds: Set<string>;
|
||||
sourceBlocks: number;
|
||||
endsWithAnswer: boolean;
|
||||
anchorKey?: string;
|
||||
ordinal: number;
|
||||
containsLiveRow: boolean;
|
||||
narrates: boolean;
|
||||
};
|
||||
|
||||
const drafts: Draft[] = [];
|
||||
@@ -152,8 +230,8 @@ export const groupWorkingBlocks = (
|
||||
let ordinal = 0;
|
||||
|
||||
for (const [index, row] of rows.entries()) {
|
||||
const content = getStepRowContent(row, options);
|
||||
if (!content) {
|
||||
const member = getMemberRow(row, options);
|
||||
if (!member) {
|
||||
current = undefined;
|
||||
if (row.type === "message" && row.entry.message.role !== "assistant") {
|
||||
anchorKey = row.key;
|
||||
@@ -163,19 +241,22 @@ export const groupWorkingBlocks = (
|
||||
}
|
||||
|
||||
if (!current) {
|
||||
// The stream opens empty before every step. That row extends a
|
||||
// block that is already working but never starts one, so a turn's
|
||||
// first moments keep the plain thinking indicator.
|
||||
if (content.visibleBlocks.length === 0) {
|
||||
// Rows that render nothing, like the empty stream before each step or
|
||||
// an uncited search, extend a block but never start one: a turn's
|
||||
// first moments keep the thinking indicator, and no block is empty.
|
||||
if (!member.showsWork) {
|
||||
continue;
|
||||
}
|
||||
|
||||
current = {
|
||||
rowIndices: [],
|
||||
toolIds: new Set(),
|
||||
sourceBlocks: 0,
|
||||
endsWithAnswer: false,
|
||||
anchorKey,
|
||||
ordinal,
|
||||
containsLiveRow: false,
|
||||
narrates: false,
|
||||
};
|
||||
ordinal += 1;
|
||||
drafts.push(current);
|
||||
@@ -183,19 +264,20 @@ export const groupWorkingBlocks = (
|
||||
|
||||
current.rowIndices.push(index);
|
||||
current.containsLiveRow ||= row.type === "live";
|
||||
current.narrates ||= member.narrates;
|
||||
|
||||
for (const tool of content.visibleTools) {
|
||||
for (const tool of member.content.visibleTools) {
|
||||
current.toolIds.add(tool.id);
|
||||
}
|
||||
}
|
||||
current.sourceBlocks += member.content.visibleBlocks.filter(
|
||||
(block) => block.type === "sources",
|
||||
).length;
|
||||
|
||||
// A completed block is a run of tool activity; a reasoning-only row on
|
||||
// its own stays visible. The live turn folds from its first reasoning,
|
||||
// so thinking never shows and then vanishes once a tool call arrives.
|
||||
const blockDrafts = drafts.filter(
|
||||
(draft) =>
|
||||
draft.toolIds.size > 0 || (draft.containsLiveRow && options.isTurnActive),
|
||||
);
|
||||
if (member.endsWithAnswer) {
|
||||
current.endsWithAnswer = true;
|
||||
current = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const lastMessageRowIndex = rows.findLastIndex(
|
||||
(row) => row.type === "message",
|
||||
@@ -212,58 +294,89 @@ export const groupWorkingBlocks = (
|
||||
return Number.POSITIVE_INFINITY;
|
||||
};
|
||||
|
||||
// Entries and blocks are both in ascending message ID order and block
|
||||
// spans never overlap, so one cursor walks the entries once.
|
||||
let entryIndex = 0;
|
||||
// Each span starts where the previous one ended, so no entry counts toward
|
||||
// two blocks.
|
||||
let spanStartId = Number.NEGATIVE_INFINITY;
|
||||
const blocks: WorkingBlock[] = [];
|
||||
|
||||
return blockDrafts.map((draft) => {
|
||||
for (const draft of drafts) {
|
||||
const firstRowIndex = draft.rowIndices[0];
|
||||
const lastRowIndex = draft.rowIndices[draft.rowIndices.length - 1];
|
||||
const memberIds = draft.rowIndices.flatMap((i) => rowMessageIds(rows[i]));
|
||||
|
||||
// A persisted answer completes its block even while the chat still runs.
|
||||
const isLive =
|
||||
options.isWorking &&
|
||||
!(draft.endsWithAnswer && rows[lastRowIndex].type === "message") &&
|
||||
(draft.containsLiveRow || lastRowIndex >= lastMessageRowIndex);
|
||||
|
||||
// The span covers hidden tool-result messages up to the next row.
|
||||
// The span covers hidden tool-result messages up to the next row and,
|
||||
// before the first row, searches that rendered nothing since the
|
||||
// previous row.
|
||||
const fromId = Math.min(...memberIds);
|
||||
const toId = messageIdAfter(lastRowIndex);
|
||||
while (
|
||||
entryIndex < entries.length &&
|
||||
entries[entryIndex].message.id < fromId
|
||||
) {
|
||||
entryIndex++;
|
||||
}
|
||||
const previousRowId = Math.max(
|
||||
...(firstRowIndex > 0 ? rowMessageIds(rows[firstRowIndex - 1]) : []),
|
||||
);
|
||||
|
||||
const spanTimestamps: string[] = [];
|
||||
while (
|
||||
entryIndex < entries.length &&
|
||||
entries[entryIndex].message.id < toId
|
||||
) {
|
||||
spanTimestamps.push(...getPartTimestamps(entries[entryIndex]));
|
||||
entryIndex++;
|
||||
const spanEntries = entries.filter(({ message }) => {
|
||||
const { id } = message;
|
||||
if (id < spanStartId || id >= toId) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
id >= fromId ||
|
||||
(id > previousRowId && countProviderResults(message) > 0)
|
||||
);
|
||||
});
|
||||
spanStartId = toId;
|
||||
|
||||
// Until the step persists, one flag stands in for its searches.
|
||||
const searches = spanEntries.reduce(
|
||||
(count, { message }) => count + countProviderResults(message),
|
||||
draft.containsLiveRow && options.streamState?.providerToolRan ? 1 : 0,
|
||||
);
|
||||
|
||||
// Source blocks count as searches only when no provider result does:
|
||||
// some providers cite without a search, and an interrupt commits a
|
||||
// search's result apart from the answer citing it.
|
||||
const stepCount = draft.toolIds.size + (searches || draft.sourceBlocks);
|
||||
|
||||
// Completed reasoning alone stays visible. The live turn folds from its
|
||||
// first reasoning, so thinking never shows and then vanishes once a tool
|
||||
// call arrives, and unfolds only when a turn without steps answers.
|
||||
// Labeled narration keeps its block so it never reads as the answer.
|
||||
const foldsLiveTurn =
|
||||
draft.containsLiveRow && options.isTurnActive && !draft.endsWithAnswer;
|
||||
const keepsBlock = stepCount > 0 || foldsLiveTurn || draft.narrates;
|
||||
if (!keepsBlock) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const streamStartedAt = options.streamState?.startedAt;
|
||||
if (draft.containsLiveRow && streamStartedAt !== undefined) {
|
||||
spanTimestamps.push(streamStartedAt);
|
||||
}
|
||||
|
||||
const times = spanTimestamps.map((timestamp) => Date.parse(timestamp));
|
||||
const times = [
|
||||
...spanEntries.flatMap(getPartTimestamps),
|
||||
...(draft.containsLiveRow && streamStartedAt !== undefined
|
||||
? [streamStartedAt]
|
||||
: []),
|
||||
].map((timestamp) => Date.parse(timestamp));
|
||||
|
||||
const liveKey = `working:live:${draft.anchorKey ?? "head"}:${draft.ordinal}`;
|
||||
const key = isLive ? liveKey : `working:through:${rows[lastRowIndex].key}`;
|
||||
|
||||
return {
|
||||
blocks.push({
|
||||
key,
|
||||
liveKey,
|
||||
rowIndices: draft.rowIndices,
|
||||
memberIds,
|
||||
stepCount: draft.toolIds.size,
|
||||
stepCount,
|
||||
endsWithAnswer: draft.endsWithAnswer,
|
||||
isLive,
|
||||
isPartial: options.hasMoreMessages && firstRowIndex === 0,
|
||||
startedAt: times.length > 0 ? Math.min(...times) : undefined,
|
||||
endedAt: isLive || times.length === 0 ? undefined : Math.max(...times),
|
||||
};
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return blocks;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user