Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions cmd/harnesscli/tui/messagebubble_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,62 @@ func TestRegression_MessageBubbleStreamingPreservesTranscriptEntries(t *testing.
}
}

// TestRegression_FinalOnlyAssistantMessagesPreserveTwoTurnConversation proves
// that terminal-only providers render and export a complete two-turn exchange
// without duplicating either final assistant response.
func TestRegression_FinalOnlyAssistantMessagesPreserveTwoTurnConversation(t *testing.T) {
m := initModel(t, 100, 30).WithCancelRun(func() {})

turns := []struct {
user, runID, assistant string
}{
{"FIRST_USER_SENTINEL", "run-final-only-one", "FIRST_ASSISTANT_FINAL_SENTINEL"},
{"SECOND_USER_SENTINEL", "run-final-only-two", "SECOND_ASSISTANT_FINAL_SENTINEL"},
}
for _, turn := range turns {
next, _ := m.Update(inputarea.CommandSubmittedMsg{Value: turn.user})
m = next.(tui.Model)
next, _ = m.Update(tui.RunStartedMsg{RunID: turn.runID})
m = next.(tui.Model)
next, _ = m.Update(tui.SSEEventMsg{
EventType: "assistant.message",
Raw: []byte(fmt.Sprintf(`{"content":%q}`, turn.assistant)),
})
m = next.(tui.Model)
next, _ = m.Update(tui.SSEDoneMsg{EventType: "run.completed"})
m = next.(tui.Model)
}

wantTranscript := []struct{ role, content string }{
{"user", "FIRST_USER_SENTINEL"},
{"assistant", "FIRST_ASSISTANT_FINAL_SENTINEL"},
{"user", "SECOND_USER_SENTINEL"},
{"assistant", "SECOND_ASSISTANT_FINAL_SENTINEL"},
}
transcript := m.Transcript()
if len(transcript) != len(wantTranscript) {
t.Fatalf("transcript = %+v, want exactly user/assistant/user/assistant", transcript)
}
for i, want := range wantTranscript {
if transcript[i].Role != want.role || transcript[i].Content != want.content {
t.Fatalf("transcript[%d] = %+v, want role=%q content=%q", i, transcript[i], want.role, want.content)
}
}

view := m.View()
previous := -1
for _, want := range wantTranscript {
if got := strings.Count(view, want.content); got != 1 {
t.Fatalf("viewport renders %q %d times, want once; view=%q", want.content, got, view)
}
position := strings.Index(view, want.content)
if position <= previous {
t.Fatalf("viewport order is not user/assistant/user/assistant; view=%q", view)
}
previous = position
}
}

func TestRegression_MessageBubbleStreamingKeepsViewportAtBottom(t *testing.T) {
m := initModel(t, 80, 24)

Expand Down
39 changes: 34 additions & 5 deletions cmd/harnesscli/tui/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -189,15 +189,19 @@ type Model struct {
// lastAssistantText accumulates all assistant deltas for the current run.
lastAssistantText string

// responseStarted tracks whether the first assistant delta for the current
// run has been written to the viewport. On the first delta we call
// the messagebubble renderer and then replace only the active assistant tail.
// responseStarted tracks whether the assistant bubble for the current
// provider step has been written to the viewport. On the first delta we
// append it; later deltas replace only that active assistant tail.
responseStarted bool

// activeAssistantLineCount tracks how many viewport lines belong to the
// currently streaming assistant bubble.
activeAssistantLineCount int

// assistantTranscriptFinalized prevents replayed terminal events from
// recording the current run's assistant response more than once.
assistantTranscriptFinalized bool

// thinkingText accumulates reasoning deltas for the current turn.
thinkingText string

Expand Down Expand Up @@ -1681,6 +1685,11 @@ func (m *Model) handleToolStart(callID, name string, input json.RawMessage) {
m.toolViews[callID] = view
m.activeToolCallID = callID
m.appendToolUseView(view)
// The tool card now owns the viewport tail. Close any assistant bubble from
// the preceding provider step so the next step appends a new bubble instead
// of replacing the tool card with stale tail-line ownership.
m.responseStarted = false
m.activeAssistantLineCount = 0
}

func (m *Model) handleToolChunk(callID, chunk string) {
Expand Down Expand Up @@ -3858,6 +3867,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case RunStartedMsg:
m.RunID = msg.RunID
m.runActive = true
m.lastAssistantText = ""
m.assistantTranscriptFinalized = false
m.clearThinkingBar()
m.spinner = spinner.New(spinnerSeed(m.config)).WithStyles(spinnerStylesFromTheme(m.theme)).Start()
cmds = append(cmds, spinnerTickCmd())
Expand Down Expand Up @@ -4238,7 +4249,11 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var p struct {
Content string `json:"content"`
}
if err := json.Unmarshal(msg.Raw, &p); err == nil && p.Content != "" {
if err := json.Unmarshal(msg.Raw, &p); err == nil && p.Content != "" &&
!m.assistantTranscriptFinalized {
if !m.responseStarted {
m.lastAssistantText = ""
}
// Accumulate and re-render the assistant message through the
// glamour-backed message bubble. Re-rendering the full
// accumulated text each delta (rather than appending raw chunks
Expand All @@ -4247,6 +4262,19 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.lastAssistantText += p.Content
m.renderActiveAssistantBubble()
}
case "assistant.message":
var p struct {
Content string `json:"content"`
}
if err := json.Unmarshal(msg.Raw, &p); err == nil && p.Content != "" &&
!m.assistantTranscriptFinalized &&
(!m.responseStarted || p.Content != m.lastAssistantText) {
// assistant.message is the authoritative full response. Most
// providers precede it with deltas, but valid non-streaming
// providers may emit only this terminal message.
m.lastAssistantText = p.Content
m.renderActiveAssistantBubble()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve tool cards when reconciling a final-only turn

When an earlier tool-calling step emitted text deltas, responseStarted remains true after handleToolStart appends the tool card. If the later terminal step is non-streaming, this call reaches renderActiveAssistantBubble, which uses ReplaceTailLines(activeAssistantLineCount, ...) even though the viewport tail is now the tool card; it therefore removes part or all of that card and leaves its recorded line offsets stale. This can occur with the supported per-turn mix of deltas/tool calls followed by a final-only response, so the terminal reconciliation must append or replace the tracked assistant bubble without treating unrelated tail content as that bubble.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed at exact head ebe2d9a85954cea45e0763cabec57ebba747e08c.

The regression now drives the realistic sequence assistant.message.delta -> tool.call.started -> tool.output.delta -> tool.call.completed -> assistant.message, replays the tool completion and terminal event, then replays terminal completion and starts a later run. It asserts the early assistant bubble, tool card, and final-only assistant bubble remain ordered and appear exactly once, the transcript finalizes once, and the later run records normally.

The minimal ownership fix closes the preceding assistant bubble when handleToolStart appends the tool card; the next provider step starts a fresh accumulator. Terminal replay is also ignored once that run's response is finalized.

Verification: focused normal/race, complete TUI normal/race, all cmd/harnesscli/..., and foreground non-TTY ./scripts/test-regression.sh all pass; coverage is 85.6% with zero uncovered functions. The unrelated hosted fixture race remains #1044 / PR #1045 and was not folded into this PR.

}
case "assistant.thinking.delta":
var p struct {
Content string `json:"content"`
Expand Down Expand Up @@ -4476,12 +4504,13 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.cancelRun = nil
}
// Record completed assistant response in transcript.
if m.lastAssistantText != "" {
if m.lastAssistantText != "" && !m.assistantTranscriptFinalized {
m.transcript = append(m.transcript, transcriptexport.TranscriptEntry{
Role: "assistant",
Content: m.lastAssistantText,
Timestamp: time.Now(),
})
m.assistantTranscriptFinalized = true
}
if msg.EventType == "run.failed" {
for _, line := range formatRunError(msg.Error) {
Expand Down
52 changes: 52 additions & 0 deletions cmd/harnesscli/tui/run_control_command_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,58 @@ func TestRunControl_ResumeCommandStartsContinuationRun(t *testing.T) {
}
}

func TestRegression_ResumeWithoutAssistantContentDoesNotDuplicatePriorReply(t *testing.T) {
for _, terminalEvent := range []string{"run.completed", "run.failed"} {
t.Run(terminalEvent, func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost || r.URL.Path != "/v1/runs/run_prev/continue" {
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusAccepted)
_, _ = w.Write([]byte(`{"run_id":"run_next","status":"queued"}`))
}))
defer srv.Close()

m := testRunControlModel(srv.URL).WithCancelRun(func() {})
started, _ := m.Update(RunStartedMsg{RunID: "run_prev"})
m = started.(Model)
assistant, _ := m.Update(SSEEventMsg{
EventType: "assistant.message",
Raw: []byte(`{"content":"PRIOR_ASSISTANT_REPLY"}`),
})
m = assistant.(Model)
completed, _ := m.Update(SSEDoneMsg{EventType: "run.completed"})
m = completed.(Model)
// SSEDoneMsg consumes and clears the active run's cancel function.
// Reinstall the test seam so the continuation's RunStartedMsg does
// not open a real SSE bridge against this command-only HTTP server.
m = m.WithCancelRun(func() {})

cmds, quit := executeResumeCommand(&m, Command{Name: "resume", Args: []string{"run_prev", "continue", "without", "a", "reply"}})
if quit {
t.Fatal("/resume must not quit")
}
continuationStarted := lastCmd(t, cmds)()
next, _ := m.Update(continuationStarted)
m = next.(Model)
terminal, _ := m.Update(SSEDoneMsg{EventType: terminalEvent, Error: "continuation failed"})
m = terminal.(Model)

transcript := m.Transcript()
if len(transcript) != 2 {
t.Fatalf("transcript = %+v, want prior assistant reply and continuation user prompt only", transcript)
}
if transcript[0].Role != "assistant" || transcript[0].Content != "PRIOR_ASSISTANT_REPLY" {
t.Fatalf("prior assistant transcript = %+v", transcript[0])
}
if transcript[1].Role != "user" || transcript[1].Content != "continue without a reply" {
t.Fatalf("continuation user transcript = %+v", transcript[1])
}
})
}
}

func TestRunControl_RunsSnapshot80x24(t *testing.T) {
writeRunsSnapshot(t, 80, 24, "TUI-058-runs-80x24.txt")
}
Expand Down
103 changes: 103 additions & 0 deletions cmd/harnesscli/tui/sse_events_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -378,6 +378,109 @@ func TestSSEDropMsg_NilSseCh_NoCmdRequired(t *testing.T) {
}
}

// ---------------------------------------------------------------------------
// assistant.message — terminal full-message reconciliation (issue #1056)
// ---------------------------------------------------------------------------

func TestSSEEventMsg_AssistantMessage_FinalOnlyRendersAndFinalizesOnce(t *testing.T) {
m := initModel(t, 80, 24).WithCancelRun(func() {})
m2, _ := m.Update(tui.RunStartedMsg{RunID: "run-final-only"})
model := m2.(tui.Model)

m3, _ := model.Update(tui.SSEEventMsg{EventType: "assistant.message", Raw: []byte(`{"content":"LUNA_NONSTREAM_REPLY"}`)})
model = m3.(tui.Model)
if !model.RunActive() || model.LastAssistantText() != "LUNA_NONSTREAM_REPLY" {
t.Fatalf("terminal message did not remain active with final text: active=%v text=%q", model.RunActive(), model.LastAssistantText())
}
if got := strings.Count(model.View(), "LUNA_NONSTREAM_REPLY"); got != 1 {
t.Fatalf("terminal-only assistant message rendered %d times, want once; view=%q", got, model.View())
}
m4, _ := model.Update(tui.SSEDoneMsg{EventType: "run.completed"})
model = m4.(tui.Model)
if got := model.Transcript(); len(got) != 1 || got[0].Role != "assistant" || got[0].Content != "LUNA_NONSTREAM_REPLY" {
t.Fatalf("transcript = %+v, want one terminal assistant reply", got)
}
}

func TestSSEEventMsg_AssistantMessage_DeltaThenFinalDoesNotDuplicate(t *testing.T) {
m := initModel(t, 80, 24).WithCancelRun(func() {})
m2, _ := m.Update(tui.RunStartedMsg{RunID: "run-streamed-final"})
model := m2.(tui.Model)
for _, event := range []tui.SSEEventMsg{
{EventType: "assistant.message.delta", Raw: []byte(`{"content":"streamed reply"}`)},
{EventType: "assistant.message", Raw: []byte(`{"content":"streamed reply"}`)},
} {
next, _ := model.Update(event)
model = next.(tui.Model)
}
if got := strings.Count(model.View(), "streamed reply"); got != 1 {
t.Fatalf("delta-plus-final reply rendered %d times, want once; view=%q", got, model.View())
}
done, _ := model.Update(tui.SSEDoneMsg{EventType: "run.completed"})
if got := done.(tui.Model).Transcript(); len(got) != 1 || got[0].Content != "streamed reply" {
t.Fatalf("transcript = %+v, want one reconciled reply", got)
}
}

func TestSSEEventMsg_AssistantMessage_FinalContentIsAuthoritative(t *testing.T) {
m := initModel(t, 80, 24).WithCancelRun(func() {})
m2, _ := m.Update(tui.RunStartedMsg{RunID: "run-final-authoritative"})
model := m2.(tui.Model)
for _, event := range []tui.SSEEventMsg{
{EventType: "assistant.message.delta", Raw: []byte(`{"content":"partial"}`)},
{EventType: "assistant.message", Raw: []byte(`{"content":"partial response complete"}`)},
} {
next, _ := model.Update(event)
model = next.(tui.Model)
}
if got := model.LastAssistantText(); got != "partial response complete" {
t.Fatalf("LastAssistantText() = %q, want authoritative final content", got)
}
if got := strings.Count(model.View(), "partial response complete"); got != 1 {
t.Fatalf("authoritative final reply rendered %d times, want once; view=%q", got, model.View())
}
}

func TestSSEEventMsg_AssistantMessage_MixedToolStepPreservesViewportAndReplayIdempotency(t *testing.T) {
m := initModel(t, 120, 40).WithCancelRun(func() {})
m2, _ := m.Update(tui.RunStartedMsg{RunID: "run-mixed-terminal"})
model := m2.(tui.Model)
events := []tui.SSEEventMsg{
{EventType: "assistant.message.delta", Raw: []byte(`{"content":"EARLY_STREAMED_STEP"}`)},
{EventType: "tool.call.started", Raw: []byte(`{"tool":"bash","call_id":"call-mixed","arguments":{"command":"printf tool-card"}}`)},
{EventType: "tool.call.completed", Raw: []byte(`{"tool":"bash","call_id":"call-mixed","output":"TOOL_OUTPUT_SENTINEL","duration_ms":8}`)},
{EventType: "assistant.message", Raw: []byte(`{"content":"FINAL_ONLY_AFTER_TOOL"}`)},
}
for _, event := range events {
next, _ := model.Update(event)
model = next.(tui.Model)
}
// Replayed tool completion, terminal event, and completion must be harmless.
for _, event := range events[len(events)-2:] {
next, _ := model.Update(event)
model = next.(tui.Model)
}
next, _ := model.Update(tui.SSEDoneMsg{EventType: "run.completed"})
model = next.(tui.Model)
next, _ = model.Update(events[len(events)-1])
model = next.(tui.Model)
next, _ = model.Update(tui.SSEDoneMsg{EventType: "run.completed"})
model = next.(tui.Model)

view := model.View()
for _, marker := range []string{"EARLY_STREAMED_STEP", "bash(", "FINAL_ONLY_AFTER_TOOL"} {
if got := strings.Count(view, marker); got != 1 {
t.Fatalf("%q rendered %d times, want once; view=%q", marker, got, view)
}
}
if !(strings.Index(view, "EARLY_STREAMED_STEP") < strings.Index(view, "bash(") && strings.Index(view, "bash(") < strings.Index(view, "FINAL_ONLY_AFTER_TOOL")) {
t.Fatalf("mixed-step blocks out of order: view=%q", view)
}
if got := model.Transcript(); len(got) != 1 || got[0].Content != "FINAL_ONLY_AFTER_TOOL" {
t.Fatalf("transcript after replay = %+v, want one final assistant response", got)
}
}

// ---------------------------------------------------------------------------
// SSEDoneMsg for run.failed — appends formatted failure output and blank line
// ---------------------------------------------------------------------------
Expand Down
49 changes: 49 additions & 0 deletions docs/logs/engineering-log.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,55 @@
candidate in the required non-TTY foreground host context passed, isolating
the red to Keychain session access rather than the parser test.

## 2026-08-01 (Issue #1056 — TUI Terminal Assistant Message)

- Symptom: real non-streaming runs persisted `assistant.message` followed by
`run.completed`, while the TUI displayed the user prompts but no assistant
reply and exported no assistant transcript row.
- Cause: the SSE bridge forwarded `assistant.message`, but `Model.Update`
reduced only `assistant.message.delta`; valid final-only responses therefore
left `lastAssistantText` empty.
- Current-main red: two complete final-only turns produced only the two user
transcript entries instead of exact user/assistant/user/assistant order.
- Fix: the existing reducer treats non-empty `assistant.message.content` as the
authoritative response and reuses the active bubble renderer. Tool start
closes the prior bubble's viewport-tail ownership, and a per-run finalization
bit makes terminal replay/completion consumptive while reopening on the next
`RunStartedMsg`.
- Regressions: final-only, delta plus identical/differing final, mixed
delta -> tool -> final, replay idempotency, repeated completion, and two-turn
viewport/transcript ordering.
- Focused evidence: the required two-turn red is green; the focused assistant
suite and complete TUI suite pass normal and race. The full repository gate
passes at 85.7% coverage with zero uncovered production functions.
- Real PTY evidence: an isolated exact-candidate `harnessd` and `harnesscli`
rendered `PTY_1059_USER_ONE`, `PTY_1059_REPLY_ONE`,
`PTY_1059_USER_TWO`, `PTY_1059_REPLY_TWO` once and in order. Both runs used
the same conversation id; raw SSE emitted one authoritative
`assistant.message` before `run.completed` per run; HTTP and SQLite stored
exactly four alternating rows; and a fresh `--resume` TUI replayed the four
rows once with an active composer.
- Exact-head review finding: `/resume` appended its user row but did not clear
`lastAssistantText`; `RunStartedMsg` reopened transcript finalization while
retaining the prior reply. A contentless completed or failed continuation
therefore exported that stale reply again.
- Review fix: `RunStartedMsg`, the shared boundary for initial and continuation
API starts, now clears the assistant accumulator before reopening per-run
finalization. The actual `/resume` command regression failed with a third
stale assistant row for both `run.completed` and `run.failed`, then passed
with exactly the prior assistant row and continuation user prompt. The
focused resume, new-content, replay, and two-turn matrix passes normal and
race on the rebased candidate.
- Hosted-test isolation follow-up: the first terminal event correctly consumed
the injected cancel function, so the regression's continuation start opened
a real SSE bridge against its command-only `httptest.Server`; GitHub race
exposed the unexpected `GET /v1/runs/run_next/events`. Reinstalling the
cancel seam between runs keeps the test on its intended reducer/API path.
The focused regression passes 20 normal and 10 race repetitions. The same
hosted wave also hit the unrelated workflow subscriber-close timing test;
that baseline test passed 20 normal and 10 race repetitions locally and is
being rechecked independently rather than waived.

## 2026-07-31 (Workflow Initial Write Exit Arbitration — Issue #1076)

- Symptom: hosted `test-race` run `30660042116` reported only
Expand Down
Loading
Loading