feat: complete chat routing and openai onboarding

This commit is contained in:
kris
2026-03-31 03:31:22 +08:00
parent 5b590f7cc1
commit 9c02ebb574
25 changed files with 2241 additions and 133 deletions

View File

@@ -49,6 +49,8 @@ public class BossApiClientDispatchPlansTest {
assertEquals(200, response.statusCode);
assertEquals("/api/v1/projects/p1/dispatch-plans/plan-1/confirm", apiClient.lastPath);
assertEquals("POST", connection.requestMethodValue);
assertEquals(12000, connection.connectTimeoutValue);
assertEquals(65000, connection.readTimeoutValue);
assertEquals("{\"approvedTargetProjectIds\":[\"target-1\",\"target-2\"]}", connection.requestBody());
}
@@ -66,6 +68,71 @@ public class BossApiClientDispatchPlansTest {
assertEquals(65000, connection.readTimeoutValue);
}
@Test
public void sendProjectMessageUsesExtendedReadTimeoutForNormalThread() throws Exception {
RecordingConnection connection = new RecordingConnection(new URL("https://boss.hyzq.net/api/v1/projects/thread-1/messages"));
RecordingBossApiClient apiClient = new RecordingBossApiClient(connection);
BossApiClient.ApiResponse response = apiClient.sendProjectMessage("thread-1", "你好", "text");
assertEquals(200, response.statusCode);
assertEquals("/api/v1/projects/thread-1/messages", apiClient.lastPath);
assertEquals("POST", connection.requestMethodValue);
assertEquals(12000, connection.connectTimeoutValue);
assertEquals(65000, connection.readTimeoutValue);
}
@Test
public void onboardOpenAiApiAccountUsesDedicatedRouteAndSetsActive() throws Exception {
RecordingConnection connection = new RecordingConnection(new URL("https://boss.hyzq.net/api/v1/accounts/onboard/openai-api"));
RecordingBossApiClient apiClient = new RecordingBossApiClient(connection);
JSONObject payload = new JSONObject()
.put("label", "主 GPT")
.put("displayName", "OpenAI 平台账号")
.put("accountIdentifier", "sk-test")
.put("model", "gpt-5.4")
.put("apiKey", "sk-test-key");
BossApiClient.ApiResponse response = apiClient.onboardOpenAiApiAccount(payload);
assertEquals(200, response.statusCode);
assertEquals("/api/v1/accounts/onboard/openai-api", apiClient.lastPath);
assertEquals("POST", connection.requestMethodValue);
assertEquals(
"{\"label\":\"主 GPT\",\"displayName\":\"OpenAI 平台账号\",\"accountIdentifier\":\"sk-test\",\"model\":\"gpt-5.4\",\"apiKey\":\"sk-test-key\",\"setActive\":true}",
connection.requestBody()
);
}
@Test
public void onboardMasterNodeFallsBackToGenericAccountCreationWhenDedicatedRouteMissing() throws Exception {
RecordingConnection dedicated = new RecordingConnection(
new URL("https://boss.hyzq.net/api/v1/accounts/onboard/master-node"),
404,
"{\"ok\":false,\"message\":\"NOT_FOUND\"}",
"{\"ok\":false,\"message\":\"NOT_FOUND\"}"
);
RecordingConnection fallback = new RecordingConnection(new URL("https://boss.hyzq.net/api/v1/accounts"));
ScriptedBossApiClient apiClient = new ScriptedBossApiClient(dedicated, fallback);
JSONObject payload = new JSONObject()
.put("label", "主 GPT")
.put("displayName", "Mac Studio")
.put("accountIdentifier", "mac-studio")
.put("nodeId", "mac-studio")
.put("nodeLabel", "Mac Studio")
.put("model", "gpt-5.4");
BossApiClient.ApiResponse response = apiClient.onboardMasterNodeAccount(payload);
assertEquals(200, response.statusCode);
assertEquals("/api/v1/accounts", apiClient.lastPath);
assertEquals("POST", fallback.requestMethodValue);
assertEquals(
"{\"label\":\"主 GPT\",\"displayName\":\"Mac Studio\",\"accountIdentifier\":\"mac-studio\",\"nodeId\":\"mac-studio\",\"nodeLabel\":\"Mac Studio\",\"model\":\"gpt-5.4\",\"setActive\":true}",
fallback.requestBody()
);
}
private static final class RecordingBossApiClient extends BossApiClient {
private final RecordingConnection connection;
private String lastPath = "";
@@ -92,15 +159,58 @@ public class BossApiClientDispatchPlansTest {
}
}
private static final class ScriptedBossApiClient extends BossApiClient {
private final Map<String, RecordingConnection> connections;
private String lastPath = "";
ScriptedBossApiClient(RecordingConnection... connections) {
super(new InMemorySharedPreferences(), "https://boss.hyzq.net");
this.connections = new HashMap<>();
for (RecordingConnection connection : connections) {
this.connections.put(connection.url().getPath(), connection);
}
}
@Override
HttpURLConnection openConnection(String path) {
lastPath = path;
RecordingConnection connection = connections.get(path);
if (connection == null) {
throw new IllegalStateException("Missing scripted connection for " + path);
}
return connection;
}
@Override
String encode(String value) {
return value;
}
@Override
void rememberIdentity(JSONObject json) {
// no-op for JVM unit test
}
}
private static final class RecordingConnection extends HttpURLConnection {
private final ByteArrayOutputStream requestBody = new ByteArrayOutputStream();
private final Map<String, String> requestHeaders = new HashMap<>();
private String requestMethodValue = "GET";
private int connectTimeoutValue = 0;
private int readTimeoutValue = 0;
private final int responseCodeValue;
private final String responseBody;
private final String errorBody;
RecordingConnection(URL url) {
this(url, 200, "{\"ok\":true}", "{\"ok\":false}");
}
RecordingConnection(URL url, int responseCodeValue, String responseBody, String errorBody) {
super(url);
this.responseCodeValue = responseCodeValue;
this.responseBody = responseBody;
this.errorBody = errorBody;
}
@Override
@@ -146,17 +256,29 @@ public class BossApiClientDispatchPlansTest {
@Override
public int getResponseCode() {
return 200;
return responseCodeValue;
}
@Override
public InputStream getInputStream() {
return new ByteArrayInputStream("{\"ok\":true}".getBytes(StandardCharsets.UTF_8));
return new ByteArrayInputStream(responseBody.getBytes(StandardCharsets.UTF_8));
}
@Override
public InputStream getErrorStream() {
if (responseCodeValue < 400) {
return null;
}
return new ByteArrayInputStream(errorBody.getBytes(StandardCharsets.UTF_8));
}
String requestBody() {
return requestBody.toString(StandardCharsets.UTF_8);
}
URL url() {
return getURL();
}
}
private static final class InMemorySharedPreferences implements SharedPreferences {

View File

@@ -194,4 +194,59 @@ public class ProjectChatUiStateTest {
assertEquals(List.of("p2", "p1"), approvedTargetIds);
}
@Test
public void queuedReplyTaskStartsReplyWaitFromRequestMessageId() throws Exception {
JSONObject response = new JSONObject()
.put("message", new JSONObject().put("id", "msg-user-1"))
.put("task", new JSONObject()
.put("taskId", "task-1")
.put("taskType", "conversation_reply")
.put("status", "queued"));
ProjectChatUiState.ReplyWaitSpec waitSpec = ProjectChatUiState.resolveReplyWaitAfterSend(response);
assertTrue(waitSpec.shouldWait);
assertEquals("msg-user-1", waitSpec.baselineMessageId);
}
@Test
public void completedReplyTaskDoesNotStartReplyWait() throws Exception {
JSONObject response = new JSONObject()
.put("message", new JSONObject().put("id", "msg-user-1"))
.put("task", new JSONObject()
.put("taskId", "task-1")
.put("taskType", "conversation_reply")
.put("status", "completed"));
ProjectChatUiState.ReplyWaitSpec waitSpec = ProjectChatUiState.resolveReplyWaitAfterSend(response);
assertFalse(waitSpec.shouldWait);
assertEquals("", waitSpec.baselineMessageId);
}
@Test
public void dispatchConfirmWaitsFromNoticeMessageId() throws Exception {
JSONObject response = new JSONObject()
.put("notice", new JSONObject().put("id", "msg-notice-1"))
.put("executions", new JSONArray()
.put(new JSONObject().put("executionId", "exec-1")));
ProjectChatUiState.ReplyWaitSpec waitSpec = ProjectChatUiState.resolveReplyWaitAfterDispatchConfirm(response);
assertTrue(waitSpec.shouldWait);
assertEquals("msg-notice-1", waitSpec.baselineMessageId);
}
@Test
public void replyWaitSatisfiedOnlyAfterLatestMessageMovesPastBaseline() throws Exception {
JSONObject project = new JSONObject()
.put("messages", new JSONArray()
.put(new JSONObject().put("id", "msg-user-1"))
.put(new JSONObject().put("id", "msg-thread-1")));
assertTrue(ProjectChatUiState.hasReplyBeyondBaseline(project, "msg-user-1"));
assertFalse(ProjectChatUiState.hasReplyBeyondBaseline(project, "msg-thread-1"));
assertFalse(ProjectChatUiState.hasReplyBeyondBaseline(project, ""));
}
}

View File

@@ -54,4 +54,13 @@ public class ProjectDetailActivityChromeBindingsTest {
assertEquals("北区试产线回归", bindings.title);
assertEquals("归档确认", bindings.subtitle);
}
@Test
public void describeDispatchPlanApprovalStateUsesUserFacingLabels() {
assertEquals("待确认", ProjectDetailActivity.describeDispatchPlanApprovalState("pending_user"));
assertEquals("等待主 Agent 处理", ProjectDetailActivity.describeDispatchPlanApprovalState("pending_agent"));
assertEquals("已确认,等待线程回流", ProjectDetailActivity.describeDispatchPlanApprovalState("approved"));
assertEquals("已拒绝", ProjectDetailActivity.describeDispatchPlanApprovalState("rejected"));
assertEquals("无需确认", ProjectDetailActivity.describeDispatchPlanApprovalState("not_required"));
}
}