diff --git a/cli/internal/tui/chat.go b/cli/internal/tui/chat.go index 48e5164..b2baef0 100644 --- a/cli/internal/tui/chat.go +++ b/cli/internal/tui/chat.go @@ -662,6 +662,33 @@ func (m *ChatModel) settleActiveStream() { m.activeText = "" } +func (m *ChatModel) streamDelta(kind ActiveStreamKind, text string) { + if text == "" { + return + } + m.turnIsLive() + m.ToolProgressText = "" + m.Status.Running = true + m.Status.Idle = false + phase := daemon.PhaseReasoning + m.Status.Phase = &phase + + if m.activeKind != kind { + m.settleActiveStream() + m.activeKind = kind + } + m.activeText += text + if kind == StreamKindText { + m.streamedHash = fnv1a(m.streamedHash, text) + m.streamedLen += int64(len(text)) + } + + if len(m.activeText) > MaxLiveStreamBytes { + m.settleActiveStream() + m.activeKind = kind + } +} + func (m *ChatModel) refreshViewportContent() int { if m.Viewport.Height != max(1, m.Height-6-m.chromeRows()) { m.SetSize(m.Width, m.Height) @@ -1731,43 +1758,10 @@ func (m *ChatModel) handleStreamEvent(evt daemon.StreamEvent) { } case daemon.EventText: - m.turnIsLive() - m.ToolProgressText = "" - m.Status.Running = true - m.Status.Idle = false - phase := daemon.PhaseReasoning - m.Status.Phase = &phase - - if m.activeKind != StreamKindText { - m.settleActiveStream() - m.activeKind = StreamKindText - } - m.activeText += evt.Text - m.streamedHash = fnv1a(m.streamedHash, evt.Text) - m.streamedLen += int64(len(evt.Text)) - - if len(m.activeText) > MaxLiveStreamBytes { - m.settleActiveStream() - m.activeKind = StreamKindText - } + m.streamDelta(StreamKindText, evt.Text) case daemon.EventThinking: - m.ToolProgressText = "" - m.Status.Running = true - m.Status.Idle = false - phase := daemon.PhaseReasoning - m.Status.Phase = &phase - - if m.activeKind != StreamKindThinking { - m.settleActiveStream() - m.activeKind = StreamKindThinking - } - m.activeText += evt.Text - - if len(m.activeText) > MaxLiveStreamBytes { - m.settleActiveStream() - m.activeKind = StreamKindThinking - } + m.streamDelta(StreamKindThinking, evt.Text) case daemon.EventToolProgress: m.turnIsLive() diff --git a/cli/internal/tui/chat_navigation_test.go b/cli/internal/tui/chat_navigation_test.go index b784454..cd29025 100644 --- a/cli/internal/tui/chat_navigation_test.go +++ b/cli/internal/tui/chat_navigation_test.go @@ -103,6 +103,28 @@ func TestFragmentedThinkingStaysCollapsed(t *testing.T) { } } +func TestInterleavedEmptyTextDoesNotSettleThinking(t *testing.T) { + m := NewChatModel(&daemon.Session{ID: "s"}, nil) + m.SetSize(80, 20) + parts := []string{"First thinking token", " second thinking token", " third thinking token"} + for _, part := range parts { + m.handleStreamEvent(daemon.StreamEvent{Type: daemon.EventThinking, Text: part}) + m.handleStreamEvent(daemon.StreamEvent{Type: daemon.EventText, Text: ""}) + } + m.settleActiveStream() + entries := m.History.Entries() + if len(entries) != 1 { + t.Fatalf("expected 1 settled thinking entry, got %d entries: %+v", len(entries), entries) + } + if entries[0].Kind != EntryThinking { + t.Fatalf("expected EntryThinking, got %v", entries[0].Kind) + } + want := "First thinking token second thinking token third thinking token" + if entries[0].Text != want { + t.Fatalf("expected %q, got %q", want, entries[0].Text) + } +} + func TestCompactionHintLivesOnEventNotFooter(t *testing.T) { m := NewChatModel(&daemon.Session{ID: "s"}, nil) m.SetSize(120, 35) diff --git a/src/albedo/daemon/events.gleam b/src/albedo/daemon/events.gleam index f7e93ea..c6ac2bb 100644 --- a/src/albedo/daemon/events.gleam +++ b/src/albedo/daemon/events.gleam @@ -6,6 +6,7 @@ import albedo/harness/extensions/python/cells as journal import albedo/openai_api/types import gleam/dict import gleam/dynamic/decode +import gleam/int import gleam/json import gleam/list import gleam/option.{type Option, None, Some} @@ -20,6 +21,33 @@ pub fn text(kind: String, value: String) -> String { event(kind, [#("text", json.string(value))]) } +pub fn stream_event( + id: String, + step: Int, + incoming: types.Event, +) -> Option(String) { + case incoming { + types.TextDelta(_, _, "") -> None + types.TextDelta(_, _, value) -> Some(text("text", value)) + types.ThinkingDelta("") -> None + types.ThinkingDelta(value) -> Some(text("thinking", value)) + types.ArgumentsDelta(_, "") -> None + types.ArgumentsDelta(index, value) -> + Some( + event("arguments_delta", [ + #( + "callId", + json.string( + id <> ":" <> int.to_string(step) <> ":" <> int.to_string(index), + ), + ), + #("text", json.string(value)), + ]), + ) + types.Started(_) -> Some(event("turn_started", [])) + } +} + pub fn phase(value: String) -> String { event("phase", [#("phase", json.string(value))]) } @@ -297,9 +325,9 @@ fn chat_thinking(item: types.ReplayItem) -> String { optional_text(item, "reasoning_content"), optional_text(item, "reasoning") { - Some(value), _ -> value - None, Some(value) -> value - None, None -> "" + Some(value), _ if value != "" -> value + _, Some(value) if value != "" -> value + _, _ -> "" } } diff --git a/src/albedo/harness/loop.gleam b/src/albedo/harness/loop.gleam index dbd1c05..d1245b9 100644 --- a/src/albedo/harness/loop.gleam +++ b/src/albedo/harness/loop.gleam @@ -80,24 +80,13 @@ pub fn run( state.record_context(request, prepared.observation) use turn <- result.try( stream_with_retries(state.upstream, request, state.publish, fn(event) { - let event = case event { - types.TextDelta(_, _, text) -> view.text("text", text) - types.ThinkingDelta(text) -> view.text("thinking", text) - types.ArgumentsDelta(index, text) -> - view.event("arguments_delta", [ - #( - "callId", - json.string( - id <> ":" <> int.to_string(step) <> ":" <> int.to_string(index), - ), - ), - #("text", json.string(text)), - ]) - types.Started(_) -> view.event("turn_started", []) - } - case state.publish(event) { - True -> types.Continue - False -> types.Stop + case view.stream_event(id, step, event) { + Some(serialized) -> + case state.publish(serialized) { + True -> types.Continue + False -> types.Stop + } + None -> types.Continue } }) |> result.map_error(describe(state.upstream, _)), diff --git a/src/albedo/openai_api/chat.gleam b/src/albedo/openai_api/chat.gleam index 1cd4c61..88a8899 100644 --- a/src/albedo/openai_api/chat.gleam +++ b/src/albedo/openai_api/chat.gleam @@ -127,16 +127,8 @@ fn delta_decoder() -> decode.Decoder(Delta) { None, decode.optional(decode.string), ) - use content <- decode.optional_field( - "content", - None, - decode.optional(decode.string), - ) - use refusal <- decode.optional_field( - "refusal", - None, - decode.optional(decode.string), - ) + use content <- decode.optional_field("content", None, non_empty_string()) + use refusal <- decode.optional_field("refusal", None, non_empty_string()) use reasoning <- decode.then(reasoning.decoder()) use tools <- decode.optional_field( "tool_calls", @@ -156,9 +148,19 @@ fn delta_decoder() -> decode.Decoder(Delta) { } } +fn non_empty_string() -> decode.Decoder(Option(String)) { + decode.optional(decode.string) + |> decode.map(fn(value) { + case value { + Some("") -> None + other -> other + } + }) +} + fn tool_fragment_decoder() -> decode.Decoder(ToolFragment) { use index <- decode.field("index", decode.int) - use id <- decode.optional_field("id", None, decode.optional(decode.string)) + use id <- decode.optional_field("id", None, non_empty_string()) use kind <- decode.optional_field( "type", None, @@ -181,16 +183,8 @@ fn tool_fragment_decoder() -> decode.Decoder(ToolFragment) { fn function_fragment_decoder() -> decode.Decoder( #(Option(String), Option(String)), ) { - use name <- decode.optional_field( - "name", - None, - decode.optional(decode.string), - ) - use arguments <- decode.optional_field( - "arguments", - None, - decode.optional(decode.string), - ) + use name <- decode.optional_field("name", None, non_empty_string()) + use arguments <- decode.optional_field("arguments", None, non_empty_string()) decode.success(#(name, arguments)) } diff --git a/src/albedo/openai_api/reasoning.gleam b/src/albedo/openai_api/reasoning.gleam index 4bbad7b..9bb4174 100644 --- a/src/albedo/openai_api/reasoning.gleam +++ b/src/albedo/openai_api/reasoning.gleam @@ -172,9 +172,9 @@ pub fn stream_text(delta: Delta) -> String { fn canonical(text: Dict(String, String)) -> String { case dict.get(text, "reasoning_content"), dict.get(text, "reasoning") { - Ok(value), _ -> value - Error(_), Ok(value) -> value - Error(_), Error(_) -> "" + Ok(value), _ if value != "" -> value + _, Ok(value) if value != "" -> value + _, _ -> "" } } diff --git a/test/openai_api/stream_test.gleam b/test/openai_api/stream_test.gleam index b2fcfad..e8e0697 100644 --- a/test/openai_api/stream_test.gleam +++ b/test/openai_api/stream_test.gleam @@ -209,6 +209,22 @@ pub fn chat_streams_one_thinking_event_per_delta_test() { assert events == [types.ThinkingDelta("c1")] } +pub fn chat_suppresses_empty_content_delta_during_thinking_test() { + let chunk = + "{\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"reasoning_content\":\"c1\"}}]}" + let assert Ok(#(_state, events, None)) = + send(stream.new(types.ChatCompletions), "", chunk) + assert events == [types.ThinkingDelta("c1")] +} + +pub fn chat_suppresses_empty_reasoning_delta_during_content_test() { + let chunk = + "{\"choices\":[{\"index\":0,\"delta\":{\"content\":\"hello\",\"reasoning_content\":\"\"}}]}" + let assert Ok(#(_state, events, None)) = + send(stream.new(types.ChatCompletions), "", chunk) + assert events == [types.TextDelta(0, 0, "hello")] +} + pub fn chat_rejects_multiple_choices_and_nonempty_unknown_semantics_test() { let multiple = "{\"choices\":[{\"index\":0,\"delta\":{}},{\"index\":1,\"delta\":{}}]}"