Skip to content

Commit 428c35e

Browse files
mauricecarrier7t
andauthored
fix(triage): exonerate the reducer for the stuck-Send + clear input on Start-over (PP-4846) (#1318)
PP-4846 (repro-first): after Discard → pick a different category → type, the Send arrow intermittently stayed disabled. It did not reproduce deterministically. Pinned the REDUCER side of that flow — the source of truth the Send button's `disabled` predicate reads (`!isFollowUpStep && inputText.trimmed.isEmpty`). `StuckSendReproTests` drives Discard → re-category → type and proves inputText is set and the step is a composition step, i.e. Send is enabled at the reducer level. So the intermittent field report is localized to the SwiftUI TextField binding (consistent with the "UIKit gesture-timeout" log + the non-determinism), NOT the state machine. Writing that test surfaced a real, separate latent bug: `.userTappedStartOver` returned to `.awaitingCategory` but did NOT clear `inputText`, so a half-typed composer survived "start fresh" — unlike Discard, which lands empty. In practice Start-over is reached with inputText already empty (post-submit), but the two reset paths should have identical composer semantics. Now both clear it. Verified: TriageBotCore reducer suites green (StuckSendReproTests + CategoryChipDebounce + StructuredEscalation, 13/13). **Scope:** the Start-over reset clears inputText; reducer-level tests for both reset paths. No SwiftUI change. **Not done:** the intermittent SwiftUI TextField(axis:.vertical) binding re-sync itself — reducer is exonerated; recommended fix is the local-@State mirror pattern already used by TicketPreviewCard, but it can't be verified without a deterministic repro, so it is not shipped blind here. **Deferred:** a simdrive repro harness for the SwiftUI race (needs a dedicated run; the field bug is intermittent). Co-authored-by: t <t@t.io>
1 parent d465a6b commit 428c35e

2 files changed

Lines changed: 91 additions & 0 deletions

File tree

Palace/Packages/PalaceTriageBot/Sources/TriageBotCore/Reducer/ConversationReducer.swift

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -635,6 +635,11 @@ public struct ConversationReducer: Sendable {
635635

636636
case .userTappedStartOver:
637637
next.step = .awaitingCategory
638+
// "Start fresh" means a clean composer: drop any half-typed input so
639+
// the next description begins empty (PP-4846). Mirrors Discard, which
640+
// also returns to .awaitingCategory with no carried-over text — the
641+
// two reset paths now have identical composer semantics.
642+
next.inputText = ""
638643
next.messages.append(.init(
639644
sender: .bot,
640645
kind: .text("OK — let's start fresh. What's happening?")
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import XCTest
2+
@testable import TriageBotCore
3+
4+
/// PP-4846: after Discard → pick a different category → type, the Send arrow
5+
/// intermittently stayed disabled. The report is "repro-first" — it did not
6+
/// reproduce deterministically in the regression pass.
7+
///
8+
/// This suite pins the **reducer** side of that flow, which is the source of
9+
/// truth the Send button's `disabled` predicate reads
10+
/// (`!isFollowUpStep && state.inputText.trimmed.isEmpty`). If the reducer ever
11+
/// left `inputText` empty or the step wrong after Discard→re-category→type, the
12+
/// button would be legitimately (not racily) stuck — this proves it does not, so
13+
/// the intermittent field report is localized to the SwiftUI TextField binding,
14+
/// not the state machine.
15+
final class StuckSendReproTests: XCTestCase {
16+
17+
private func reducer() -> ConversationReducer {
18+
// An entry with NO escalationFollowUp → an escalating match drafts a
19+
// ticket directly (reaching the .drafting/preview step we then Discard).
20+
let entry = KBEntry(id: "KI-DRAFT", category: .audiobook, status: .open,
21+
symptomKeywords: ["glitchy audio"],
22+
userFacingWorkaround: "Please try re-downloading the title.",
23+
confidenceThreshold: 0.1)
24+
return ConversationReducer(knowledgeBase: KnowledgeBase(
25+
catalog: KBCatalog(version: "test", updatedAt: "2026-07-20", entries: [entry])))
26+
}
27+
28+
/// Send-enabled == the UI predicate: a non-follow-up composition step with a
29+
/// non-empty trimmed input.
30+
private func sendWouldBeEnabled(_ state: ConversationState) -> Bool {
31+
let composing: Bool
32+
switch state.step {
33+
case .awaitingDescription, .awaitingCategory, .awaitingFollowUp: composing = true
34+
default: composing = false
35+
}
36+
return composing && !state.inputText.trimmingCharacters(in: .whitespaces).isEmpty
37+
}
38+
39+
func testDiscardThenNewCategoryThenType_leavesSendEnabled() {
40+
let r = reducer()
41+
42+
// Drive to a ticket preview (drafting).
43+
var (s, _) = r.reduce(state: ConversationState(), action: .start)
44+
(s, _) = r.reduce(state: s, action: .userTappedCategory(.audiobook))
45+
(s, _) = r.reduce(state: s, action: .inputChanged("my glitchy audio problem"))
46+
(s, _) = r.reduce(state: s, action: .userSubmittedDescription)
47+
guard case .drafting = s.step else {
48+
return XCTFail("expected a ticket preview (.drafting); got \(s.step)")
49+
}
50+
51+
// Discard the preview.
52+
(s, _) = r.reduce(state: s, action: .userCancelledTicketSubmit)
53+
XCTAssertEqual(s.step, .awaitingCategory, "Discard must return to the category chooser")
54+
XCTAssertTrue(s.inputText.isEmpty, "Discard must leave the composer empty")
55+
XCTAssertFalse(sendWouldBeEnabled(s), "no text yet → Send stays disabled (correct)")
56+
57+
// Pick a DIFFERENT category.
58+
(s, _) = r.reduce(state: s, action: .userTappedCategory(.reader))
59+
guard case .awaitingDescription(let category) = s.step else {
60+
return XCTFail("re-selecting a category must open a fresh description step; got \(s.step)")
61+
}
62+
XCTAssertEqual(category, .reader, "the newly tapped category must win")
63+
64+
// Type a new description — Send MUST re-enable.
65+
(s, _) = r.reduce(state: s, action: .inputChanged("a totally different reader issue"))
66+
XCTAssertEqual(s.inputText, "a totally different reader issue",
67+
"typing after Discard→re-category must update inputText")
68+
XCTAssertTrue(sendWouldBeEnabled(s),
69+
"PP-4846: after Discard→new category→type, Send must be enabled at the reducer level")
70+
}
71+
72+
/// Also guard the Start-over reset path (the other way back to the chooser).
73+
func testStartOverThenCategoryThenType_leavesSendEnabled() {
74+
let r = reducer()
75+
var (s, _) = r.reduce(state: ConversationState(), action: .start)
76+
(s, _) = r.reduce(state: s, action: .userTappedCategory(.audiobook))
77+
(s, _) = r.reduce(state: s, action: .inputChanged("stale text that should be cleared"))
78+
(s, _) = r.reduce(state: s, action: .userTappedStartOver)
79+
XCTAssertEqual(s.step, .awaitingCategory)
80+
XCTAssertTrue(s.inputText.isEmpty, "Start-over must clear any half-typed input")
81+
82+
(s, _) = r.reduce(state: s, action: .userTappedCategory(.signin))
83+
(s, _) = r.reduce(state: s, action: .inputChanged("new signin question"))
84+
XCTAssertTrue(sendWouldBeEnabled(s), "Send must re-enable after Start-over→category→type")
85+
}
86+
}

0 commit comments

Comments
 (0)