Skip to content

Commit 1c54250

Browse files
authored
Merge pull request #68 from dasomji/agent/android-authoritative-question-options
feat(android): prioritize authoritative question options
2 parents e8c7f8f + 446e72b commit 1c54250

11 files changed

Lines changed: 603 additions & 33 deletions

File tree

apps/android/app/build.gradle.kts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,13 +46,18 @@ dependencies {
4646
implementation("androidx.compose.ui:ui")
4747
implementation("androidx.compose.ui:ui-tooling-preview")
4848
implementation("androidx.core:core-ktx:1.16.0")
49+
implementation("androidx.fragment:fragment:1.8.9")
4950
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.9.0")
5051
implementation("com.google.firebase:firebase-messaging")
5152
implementation("com.squareup.okhttp3:okhttp:4.12.0")
5253
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2")
5354
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.8.1")
5455

5556
debugImplementation("androidx.compose.ui:ui-tooling")
57+
debugImplementation("androidx.compose.ui:ui-test-manifest")
58+
59+
androidTestImplementation(platform("androidx.compose:compose-bom:2025.05.01"))
60+
androidTestImplementation("androidx.compose.ui:ui-test-junit4")
5661

5762
testImplementation("junit:junit:4.13.2")
5863
testImplementation("com.squareup.okhttp3:mockwebserver:4.12.0")
Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
1+
package dev.pi.postbox.question
2+
3+
import androidx.compose.runtime.mutableStateOf
4+
import androidx.compose.ui.test.assertIsDisplayed
5+
import androidx.compose.ui.test.assertIsSelected
6+
import androidx.compose.ui.test.assertIsNotSelected
7+
import androidx.compose.ui.test.assertTextEquals
8+
import androidx.compose.ui.test.hasAnyDescendant
9+
import androidx.compose.ui.test.hasClickAction
10+
import androidx.compose.ui.test.hasContentDescription
11+
import androidx.compose.ui.test.hasSetTextAction
12+
import androidx.compose.ui.test.hasTestTag
13+
import androidx.compose.ui.test.hasText
14+
import androidx.compose.ui.test.junit4.createComposeRule
15+
import androidx.compose.ui.test.onNodeWithText
16+
import androidx.compose.ui.test.performClick
17+
import androidx.compose.ui.test.performTextInput
18+
import dev.pi.postbox.ui.theme.PostboxTheme
19+
import org.junit.Assert.assertEquals
20+
import org.junit.Rule
21+
import org.junit.Test
22+
23+
class QuestionWorkflowScreenTest {
24+
@get:Rule
25+
val composeRule = createComposeRule()
26+
27+
@Test
28+
fun questionDetailShowsUrgencyRichOptionFieldsAndChatProvenanceAccessibly() {
29+
var toggledValue: String? = null
30+
val question = QuestionDetailUiState(
31+
requestId = "ask-high",
32+
sessionId = "session-1",
33+
mode = QuestionMode.SINGLE,
34+
urgency = QuestionUrgency.HIGH,
35+
prompt = "Choose a storage strategy",
36+
questionContext = null,
37+
relevance = null,
38+
decisionImpact = null,
39+
options = listOf(
40+
QuestionOptionUiState(
41+
value = "sqlite",
42+
label = "SQLite",
43+
description = "Keep deployment self-contained.",
44+
meaning = "Persist decisions beside session state.",
45+
context = "No second service is required."
46+
),
47+
QuestionOptionUiState(
48+
value = "chat_stage",
49+
label = "Stage first",
50+
description = null,
51+
provenance = QuestionOptionProvenance.CHAT
52+
)
53+
),
54+
handoffContext = null,
55+
forkReference = null,
56+
selectedValues = listOf("sqlite"),
57+
canSubmit = true,
58+
availableActions = listOf(QuestionAction.SUBMIT, QuestionAction.CANCEL)
59+
)
60+
61+
setQuestionScreen(
62+
stateProvider = {
63+
QuestionWorkflowState(
64+
baseUrl = "https://postbox.example/",
65+
isLoading = false,
66+
isSyncing = false,
67+
connectionState = QuestionConnectionState.CONNECTED,
68+
pendingQuestions = listOf(
69+
QuestionListItemUiState(
70+
requestId = question.requestId,
71+
sessionId = question.sessionId,
72+
prompt = question.prompt,
73+
mode = question.mode,
74+
createdAt = "2026-07-29T10:00:00.000Z",
75+
expiresAt = null,
76+
urgency = question.urgency
77+
)
78+
),
79+
visibleQuestion = question,
80+
navigationSelection = QuestionNavigationSelection.Question(question.requestId)
81+
)
82+
},
83+
onToggleOption = { toggledValue = it }
84+
)
85+
86+
composeRule.onNode(
87+
hasText("High urgency", substring = true) and
88+
hasContentDescription("Question detail priority: High urgency")
89+
).assertIsDisplayed()
90+
composeRule.onNodeWithText("Keep deployment self-contained.").assertIsDisplayed()
91+
composeRule.onNodeWithText("Meaning: Persist decisions beside session state.").assertIsDisplayed()
92+
composeRule.onNodeWithText("Context: No second service is required.").assertIsDisplayed()
93+
composeRule.onNodeWithText("Suggested in Chat").assertIsDisplayed()
94+
95+
composeRule.onNode(hasText("SQLite", substring = true) and hasClickAction()).assertIsSelected()
96+
composeRule.onNode(
97+
hasText("Stage first", substring = true) and
98+
hasText("Suggested in Chat", substring = true) and
99+
hasClickAction()
100+
)
101+
.assertIsNotSelected()
102+
.performClick()
103+
assertEquals("chat_stage", toggledValue)
104+
}
105+
106+
@Test
107+
fun liveOptionAppendPreservesTheNoteDraftAndExistingSelection() {
108+
val initialQuestion = QuestionDetailUiState(
109+
requestId = "ask-live",
110+
sessionId = "session-1",
111+
mode = QuestionMode.SINGLE,
112+
prompt = "Choose a release path",
113+
questionContext = null,
114+
relevance = null,
115+
decisionImpact = null,
116+
options = listOf(QuestionOptionUiState("ship", "Ship now", null)),
117+
handoffContext = null,
118+
forkReference = null,
119+
selectedValues = listOf("ship"),
120+
canSubmit = true,
121+
availableActions = listOf(QuestionAction.SUBMIT, QuestionAction.CANCEL)
122+
)
123+
val screenState = mutableStateOf(
124+
QuestionWorkflowState(
125+
baseUrl = "https://postbox.example/",
126+
isLoading = false,
127+
isSyncing = false,
128+
connectionState = QuestionConnectionState.CONNECTED,
129+
pendingQuestions = listOf(
130+
QuestionListItemUiState(
131+
requestId = initialQuestion.requestId,
132+
sessionId = initialQuestion.sessionId,
133+
prompt = initialQuestion.prompt,
134+
mode = initialQuestion.mode,
135+
createdAt = "2026-07-29T10:00:00.000Z",
136+
expiresAt = null
137+
)
138+
),
139+
visibleQuestion = initialQuestion,
140+
navigationSelection = QuestionNavigationSelection.Question(initialQuestion.requestId)
141+
)
142+
)
143+
144+
setQuestionScreen(stateProvider = { screenState.value })
145+
146+
composeRule.onNodeWithText("+ Add a note").performClick()
147+
composeRule.onNode(hasSetTextAction()).performTextInput("Keep my draft")
148+
149+
composeRule.runOnUiThread {
150+
val currentQuestion = screenState.value.visibleQuestion ?: error("Expected visible question")
151+
screenState.value = screenState.value.copy(
152+
visibleQuestion = currentQuestion.copy(
153+
options = currentQuestion.options + QuestionOptionUiState(
154+
value = "chat_stage",
155+
label = "Stage first",
156+
description = null,
157+
provenance = QuestionOptionProvenance.CHAT
158+
)
159+
)
160+
)
161+
}
162+
163+
composeRule.onNode(hasSetTextAction()).assertTextEquals("Keep my draft")
164+
composeRule.onNode(hasText("Ship now", substring = true) and hasClickAction()).assertIsSelected()
165+
composeRule.onNode(hasText("Stage first", substring = true) and hasClickAction()).assertIsNotSelected()
166+
}
167+
168+
@Test
169+
fun queueShowsEveryUrgencyLevel() {
170+
setQuestionScreen(
171+
stateProvider = {
172+
QuestionWorkflowState(
173+
baseUrl = "https://postbox.example/",
174+
isLoading = false,
175+
isSyncing = false,
176+
connectionState = QuestionConnectionState.CONNECTED,
177+
pendingQuestions = listOf(
178+
question("high", QuestionUrgency.HIGH),
179+
question("normal", QuestionUrgency.NORMAL),
180+
question("low", QuestionUrgency.LOW)
181+
),
182+
navigationSelection = QuestionNavigationSelection.Queue
183+
)
184+
}
185+
)
186+
187+
listOf("High urgency", "Normal urgency", "Low urgency").forEach { urgencyLabel ->
188+
composeRule.onNode(
189+
hasTestTag(QUESTION_QUEUE_TEST_TAG) and
190+
hasAnyDescendant(hasText(urgencyLabel, substring = true)) and
191+
hasAnyDescendant(
192+
hasContentDescription("Question priority: $urgencyLabel", substring = true)
193+
)
194+
).assertIsDisplayed()
195+
}
196+
}
197+
198+
private fun setQuestionScreen(
199+
stateProvider: () -> QuestionWorkflowState,
200+
onToggleOption: (String) -> Unit = {}
201+
) {
202+
composeRule.setContent {
203+
PostboxTheme {
204+
QuestionWorkflowScreen(
205+
state = stateProvider(),
206+
onShowQueue = {},
207+
onSelectProject = {},
208+
onSelectSession = {},
209+
onSelectQuestion = {},
210+
onToggleOption = onToggleOption,
211+
onSubmitAnswer = {},
212+
onCancelQuestion = {},
213+
onDismissQuestion = {},
214+
onEditServerUrl = {}
215+
)
216+
}
217+
}
218+
}
219+
220+
private fun question(requestId: String, urgency: QuestionUrgency) = QuestionListItemUiState(
221+
requestId = requestId,
222+
sessionId = "session-1",
223+
prompt = "$requestId question",
224+
mode = QuestionMode.SINGLE,
225+
createdAt = "2026-07-29T10:00:00.000Z",
226+
expiresAt = null,
227+
urgency = urgency
228+
)
229+
}

apps/android/app/src/main/java/dev/pi/postbox/protocol/PostboxProtocol.kt

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ data class AskRequestSnapshot(
8686
val requestId: String,
8787
val sessionId: String,
8888
val mode: AskMode,
89+
val urgency: AskUrgency = AskUrgency.NORMAL,
8990
val question: AskQuestion,
9091
val options: List<AskOption>,
9192
val context: HandoffContext? = null,
@@ -103,6 +104,13 @@ enum class AskMode {
103104
@SerialName("multi") MULTI
104105
}
105106

107+
@Serializable
108+
enum class AskUrgency {
109+
@SerialName("low") LOW,
110+
@SerialName("normal") NORMAL,
111+
@SerialName("high") HIGH
112+
}
113+
106114
@Serializable
107115
enum class AskStatus {
108116
@SerialName("pending") PENDING,
@@ -133,9 +141,15 @@ data class AskOption(
133141
val label: String,
134142
val description: String? = null,
135143
val meaning: String? = null,
136-
val context: String? = null
144+
val context: String? = null,
145+
val provenance: AskOptionProvenance? = null
137146
)
138147

148+
@Serializable
149+
enum class AskOptionProvenance {
150+
@SerialName("chat") CHAT
151+
}
152+
139153
@Serializable
140154
data class HandoffContext(
141155
val codebaseContext: String? = null,
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
package dev.pi.postbox.question
2+
3+
import dev.pi.postbox.protocol.AskRequestSnapshot
4+
import dev.pi.postbox.protocol.AskUrgency
5+
import java.time.Instant
6+
import java.time.OffsetDateTime
7+
8+
internal val askRequestPriorityComparator: Comparator<AskRequestSnapshot> =
9+
compareBy<AskRequestSnapshot> { it.urgency.priorityRank }
10+
.thenBy { parseQuestionInstant(it.createdAt) ?: Instant.MAX }
11+
.thenBy { it.requestId }
12+
13+
internal val questionListPriorityComparator: Comparator<QuestionListItemUiState> =
14+
compareBy<QuestionListItemUiState> { it.urgency.priorityRank }
15+
.thenBy { parseQuestionInstant(it.createdAt) ?: Instant.MAX }
16+
.thenBy { it.requestId }
17+
18+
internal fun parseQuestionInstant(timestamp: String): Instant? =
19+
runCatching { Instant.parse(timestamp) }.getOrNull()
20+
?: runCatching { OffsetDateTime.parse(timestamp).toInstant() }.getOrNull()
21+
22+
private val AskUrgency.priorityRank: Int
23+
get() = when (this) {
24+
AskUrgency.HIGH -> 0
25+
AskUrgency.NORMAL -> 1
26+
AskUrgency.LOW -> 2
27+
}
28+
29+
private val QuestionUrgency.priorityRank: Int
30+
get() = when (this) {
31+
QuestionUrgency.HIGH -> 0
32+
QuestionUrgency.NORMAL -> 1
33+
QuestionUrgency.LOW -> 2
34+
}

0 commit comments

Comments
 (0)