fix: persist master-agent wait state in chat

This commit is contained in:
kris
2026-03-31 22:58:13 +08:00
parent 5c69eaa26d
commit c3ee76909d
4 changed files with 202 additions and 21 deletions

View File

@@ -64,6 +64,7 @@ public class ProjectDetailActivity extends BossScreenActivity {
private boolean renderForcedScrollToBottom;
private boolean conversationInfoReady;
private boolean masterAgentReplyWaiting;
private boolean masterAgentReplyTimedOut;
private @Nullable String masterAgentReplyBaselineMessageId;
private String currentScreenTitle;
private String currentScreenSubtitle;
@@ -327,15 +328,17 @@ public class ProjectDetailActivity extends BossScreenActivity {
appendContent(BossUi.buildMessagePlaceholder(this, "还没有项目消息,先发一条开始对话。"));
}
boolean masterAgentStillWaiting = isMasterAgentConversation()
&& masterAgentReplyWaiting
&& !ProjectChatUiState.hasReplyBeyondBaseline(project, masterAgentReplyBaselineMessageId);
if (isMasterAgentConversation() && masterAgentReplyWaiting && !masterAgentStillWaiting) {
masterAgentReplyWaiting = false;
masterAgentReplyBaselineMessageId = null;
boolean masterAgentHasReply = isMasterAgentConversation()
&& ProjectChatUiState.hasReplyBeyondBaseline(project, masterAgentReplyBaselineMessageId);
if (masterAgentHasReply) {
clearMasterAgentReplyState();
}
if (masterAgentStillWaiting) {
appendContent(buildMasterAgentThinkingPlaceholder());
if (isMasterAgentConversation()) {
if (masterAgentReplyWaiting) {
appendContent(buildMasterAgentReplyStateView(false));
} else if (masterAgentReplyTimedOut) {
appendContent(buildMasterAgentReplyStateView(true));
}
}
setRefreshing(false);
@@ -1471,8 +1474,31 @@ public class ProjectDetailActivity extends BossScreenActivity {
return value.trim();
}
private View buildMasterAgentThinkingPlaceholder() {
return BossUi.buildHintPill(this, "主 Agent 思考中");
private View buildMasterAgentReplyStateView(boolean timedOut) {
LinearLayout container = new LinearLayout(this);
container.setOrientation(LinearLayout.VERTICAL);
container.addView(BossUi.buildCard(
this,
timedOut ? "主 Agent 回复超时" : "主 Agent 思考中",
timedOut
? "消息已经发送,但暂时还没有收到回复。你可以继续等待最新结果。"
: "消息已发送,正在等待主 Agent 回复。",
timedOut
? "超时后不会丢失状态,收到新回复会自动清掉。"
: "稍后收到回复后,这个状态会自动消失。"
));
if (timedOut) {
Button retryButton = BossUi.buildMiniActionButton(this, "重试等待", true);
retryButton.setOnClickListener(v -> retryMasterAgentReplyWait());
container.addView(BossUi.buildInlineActionRow(this, retryButton));
}
return container;
}
private void clearMasterAgentReplyState() {
masterAgentReplyWaiting = false;
masterAgentReplyTimedOut = false;
masterAgentReplyBaselineMessageId = null;
}
private void scrollChatToBottom() {
@@ -1866,7 +1892,7 @@ public class ProjectDetailActivity extends BossScreenActivity {
updateComposerSendButtonState();
setRefreshing(true);
showMessage(waitingMessage);
executor.execute(() -> pollUntilReply(waitSpec, includeDispatchPlans));
enqueueReplyWaitPoll(waitSpec.baselineMessageId, includeDispatchPlans);
}
private void startMasterAgentReplyWait(
@@ -1875,17 +1901,22 @@ public class ProjectDetailActivity extends BossScreenActivity {
String waitingMessage
) {
masterAgentReplyWaiting = true;
masterAgentReplyTimedOut = false;
masterAgentReplyBaselineMessageId = waitSpec.baselineMessageId;
composerSending = false;
updateComposerSendButtonState();
setRefreshing(false);
showMessage(waitingMessage);
reload(true);
replyWaitExecutor.execute(() -> pollUntilReply(waitSpec, includeDispatchPlans));
enqueueReplyWaitPoll(waitSpec.baselineMessageId, includeDispatchPlans);
}
protected void enqueueReplyWaitPoll(@Nullable String baselineMessageId, boolean includeDispatchPlans) {
replyWaitExecutor.execute(() -> pollUntilReply(baselineMessageId, includeDispatchPlans));
}
private void pollUntilReply(
ProjectChatUiState.ReplyWaitSpec waitSpec,
@Nullable String baselineMessageId,
boolean includeDispatchPlans
) {
long deadlineAt = System.currentTimeMillis() + REPLY_WAIT_TIMEOUT_MS;
@@ -1894,7 +1925,7 @@ public class ProjectDetailActivity extends BossScreenActivity {
while (!Thread.currentThread().isInterrupted() && System.currentTimeMillis() < deadlineAt) {
ProjectSnapshot snapshot = fetchProjectSnapshot(includeDispatchPlans);
JSONObject project = snapshot.payload.optJSONObject("project");
boolean hasReply = ProjectChatUiState.hasReplyBeyondBaseline(project, waitSpec.baselineMessageId);
boolean hasReply = ProjectChatUiState.hasReplyBeyondBaseline(project, baselineMessageId);
if (!renderedInitialSnapshot || hasReply) {
runOnUiThread(() -> {
@@ -1910,10 +1941,7 @@ public class ProjectDetailActivity extends BossScreenActivity {
if (hasReply) {
runOnUiThread(() -> {
if (isMasterAgentConversation()) {
masterAgentReplyWaiting = false;
masterAgentReplyBaselineMessageId = null;
}
clearMasterAgentReplyState();
composerSending = false;
updateComposerSendButtonState();
setRefreshing(false);
@@ -1928,19 +1956,19 @@ public class ProjectDetailActivity extends BossScreenActivity {
runOnUiThread(() -> {
if (isMasterAgentConversation()) {
masterAgentReplyWaiting = false;
masterAgentReplyBaselineMessageId = null;
masterAgentReplyTimedOut = true;
}
composerSending = false;
updateComposerSendButtonState();
setRefreshing(false);
showMessage("对方还在处理中,稍后下拉刷新查看最新回复。");
showMessage("主 Agent 回复超时,可重试等待最新回复。");
reload(false);
});
} catch (Exception error) {
runOnUiThread(() -> {
if (isMasterAgentConversation()) {
masterAgentReplyWaiting = false;
masterAgentReplyBaselineMessageId = null;
masterAgentReplyTimedOut = true;
}
composerSending = false;
updateComposerSendButtonState();
@@ -1951,6 +1979,20 @@ public class ProjectDetailActivity extends BossScreenActivity {
}
}
private void retryMasterAgentReplyWait() {
if (!isMasterAgentConversation() || TextUtils.isEmpty(masterAgentReplyBaselineMessageId)) {
return;
}
masterAgentReplyWaiting = true;
masterAgentReplyTimedOut = false;
composerSending = false;
updateComposerSendButtonState();
setRefreshing(false);
showMessage("已重新开始等待主 Agent 回复");
reload(true);
enqueueReplyWaitPoll(masterAgentReplyBaselineMessageId, false);
}
static ChromeBindings buildChromeBindings(
ProjectChatUiState.ChromeState chromeState,
boolean composerBusy

View File

@@ -253,6 +253,132 @@ public class ProjectDetailActivityUiTest {
assertEquals("...", headerAction.getText().toString());
}
@Test
public void renderProjectKeepsMasterAgentWaitingStateVisibleInMessageFlow() throws Exception {
Intent intent = new Intent()
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "master-agent")
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "主 Agent");
TestProjectDetailActivity activity = Robolectric
.buildActivity(TestProjectDetailActivity.class, intent)
.setup()
.get();
ReflectionHelpers.setField(activity, "conversationInfoReady", true);
ReflectionHelpers.setField(activity, "currentScreenTitle", "主 Agent");
ReflectionHelpers.setField(activity, "currentScreenSubtitle", "单聊会话");
ReflectionHelpers.setField(activity, "masterAgentReplyWaiting", true);
ReflectionHelpers.setField(activity, "masterAgentReplyTimedOut", false);
ReflectionHelpers.setField(activity, "masterAgentReplyBaselineMessageId", "msg-user-1");
JSONObject project = new JSONObject()
.put("project", new JSONObject()
.put("id", "master-agent")
.put("name", "主 Agent")
.put("messages", new JSONArray()
.put(new JSONObject().put("id", "msg-user-1").put("sender", "user"))));
ReflectionHelpers.callInstanceMethod(
activity,
"renderProject",
ReflectionHelpers.ClassParameter.from(JSONObject.class, project),
ReflectionHelpers.ClassParameter.from(JSONArray.class, null),
ReflectionHelpers.ClassParameter.from(JSONObject.class, null)
);
View content = activity.findViewById(R.id.screen_content);
assertTrue(viewTreeContainsText(content, "主 Agent 思考中"));
assertFalse(viewTreeContainsText(content, "重试等待"));
assertFalse(ReflectionHelpers.getField(activity, "masterAgentReplyTimedOut"));
}
@Test
public void renderProjectShowsRetryEntryAfterMasterAgentWaitTimesOut() throws Exception {
Intent intent = new Intent()
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "master-agent")
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "主 Agent");
TestProjectDetailActivity activity = Robolectric
.buildActivity(TestProjectDetailActivity.class, intent)
.setup()
.get();
ReflectionHelpers.setField(activity, "conversationInfoReady", true);
ReflectionHelpers.setField(activity, "currentScreenTitle", "主 Agent");
ReflectionHelpers.setField(activity, "currentScreenSubtitle", "单聊会话");
ReflectionHelpers.setField(activity, "masterAgentReplyWaiting", false);
ReflectionHelpers.setField(activity, "masterAgentReplyTimedOut", true);
ReflectionHelpers.setField(activity, "masterAgentReplyBaselineMessageId", "msg-user-1");
JSONObject project = new JSONObject()
.put("project", new JSONObject()
.put("id", "master-agent")
.put("name", "主 Agent")
.put("messages", new JSONArray()
.put(new JSONObject().put("id", "msg-user-1").put("sender", "user"))));
ReflectionHelpers.callInstanceMethod(
activity,
"renderProject",
ReflectionHelpers.ClassParameter.from(JSONObject.class, project),
ReflectionHelpers.ClassParameter.from(JSONArray.class, null),
ReflectionHelpers.ClassParameter.from(JSONObject.class, null)
);
View content = activity.findViewById(R.id.screen_content);
View retryButton = findClickableViewContainingText(content, "重试等待");
assertNotNull(retryButton);
assertTrue(viewTreeContainsText(content, "主 Agent 回复超时"));
retryButton.performClick();
assertTrue(ReflectionHelpers.<Boolean>getField(activity, "masterAgentReplyWaiting"));
assertFalse(ReflectionHelpers.<Boolean>getField(activity, "masterAgentReplyTimedOut"));
assertEquals(1, activity.replyWaitPollCount);
assertEquals("msg-user-1", activity.lastReplyWaitBaselineMessageId);
assertFalse(activity.lastReplyWaitIncludeDispatchPlans);
}
@Test
public void renderProjectClearsMasterAgentWaitStateAfterNewReplyArrives() throws Exception {
Intent intent = new Intent()
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "master-agent")
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "主 Agent");
TestProjectDetailActivity activity = Robolectric
.buildActivity(TestProjectDetailActivity.class, intent)
.setup()
.get();
ReflectionHelpers.setField(activity, "conversationInfoReady", true);
ReflectionHelpers.setField(activity, "currentScreenTitle", "主 Agent");
ReflectionHelpers.setField(activity, "currentScreenSubtitle", "单聊会话");
ReflectionHelpers.setField(activity, "masterAgentReplyWaiting", true);
ReflectionHelpers.setField(activity, "masterAgentReplyTimedOut", true);
ReflectionHelpers.setField(activity, "masterAgentReplyBaselineMessageId", "msg-user-1");
JSONObject project = new JSONObject()
.put("project", new JSONObject()
.put("id", "master-agent")
.put("name", "主 Agent")
.put("messages", new JSONArray()
.put(new JSONObject().put("id", "msg-user-1").put("sender", "user"))
.put(new JSONObject().put("id", "msg-thread-1").put("sender", "assistant"))
.put(new JSONObject().put("id", "msg-thread-2").put("sender", "assistant"))));
ReflectionHelpers.callInstanceMethod(
activity,
"renderProject",
ReflectionHelpers.ClassParameter.from(JSONObject.class, project),
ReflectionHelpers.ClassParameter.from(JSONArray.class, null),
ReflectionHelpers.ClassParameter.from(JSONObject.class, null)
);
View content = activity.findViewById(R.id.screen_content);
assertFalse(viewTreeContainsText(content, "主 Agent 思考中"));
assertFalse(viewTreeContainsText(content, "主 Agent 回复超时"));
assertFalse(ReflectionHelpers.<Boolean>getField(activity, "masterAgentReplyWaiting"));
assertFalse(ReflectionHelpers.<Boolean>getField(activity, "masterAgentReplyTimedOut"));
assertEquals(null, ReflectionHelpers.getField(activity, "masterAgentReplyBaselineMessageId"));
}
@Test
public void outgoingAttachmentMetaPrefersTimeOnly() throws Exception {
Intent intent = new Intent()
@@ -521,10 +647,21 @@ public class ProjectDetailActivityUiTest {
}
public static class TestProjectDetailActivity extends ProjectDetailActivity {
int replyWaitPollCount;
String lastReplyWaitBaselineMessageId;
boolean lastReplyWaitIncludeDispatchPlans;
@Override
boolean shouldLoadOnCreate() {
return false;
}
@Override
protected void enqueueReplyWaitPoll(String baselineMessageId, boolean includeDispatchPlans) {
replyWaitPollCount += 1;
lastReplyWaitBaselineMessageId = baselineMessageId;
lastReplyWaitIncludeDispatchPlans = includeDispatchPlans;
}
}
private static final class InMemorySharedPreferences implements SharedPreferences {