Lighten Android chat realtime refreshes
This commit is contained in:
@@ -111,6 +111,10 @@ public class BossApiClient {
|
||||
return requestWithRestore("GET", "/api/v1/projects/" + encode(projectId), null);
|
||||
}
|
||||
|
||||
public ApiResponse getProjectMessages(String projectId) throws IOException, JSONException {
|
||||
return requestWithRestore("GET", "/api/v1/projects/" + encode(projectId) + "/messages", null);
|
||||
}
|
||||
|
||||
public ApiResponse getDispatchPlans(String projectId) throws IOException, JSONException {
|
||||
return requestWithRestore("GET", "/api/v1/projects/" + encode(projectId) + "/dispatch-plans", null);
|
||||
}
|
||||
|
||||
@@ -29,6 +29,8 @@ final class BossRealtimeClient {
|
||||
|
||||
interface Listener {
|
||||
void onRealtimeEvent(BossRealtimeEvent event);
|
||||
|
||||
default void onRealtimeConnectionChanged(boolean connected) {}
|
||||
}
|
||||
|
||||
private final BossApiClient apiClient;
|
||||
@@ -54,7 +56,7 @@ final class BossRealtimeClient {
|
||||
|
||||
synchronized void stop() {
|
||||
running = false;
|
||||
connected = false;
|
||||
setConnected(false);
|
||||
if (activeConnection != null) {
|
||||
activeConnection.disconnect();
|
||||
activeConnection = null;
|
||||
@@ -69,6 +71,14 @@ final class BossRealtimeClient {
|
||||
return connected;
|
||||
}
|
||||
|
||||
private void setConnected(boolean nextConnected) {
|
||||
if (connected == nextConnected) {
|
||||
return;
|
||||
}
|
||||
connected = nextConnected;
|
||||
listener.onRealtimeConnectionChanged(nextConnected);
|
||||
}
|
||||
|
||||
private void runLoop() {
|
||||
long backoffMs = INITIAL_BACKOFF_MS;
|
||||
while (running) {
|
||||
@@ -129,7 +139,7 @@ final class BossRealtimeClient {
|
||||
if (statusCode < 200 || statusCode >= 300) {
|
||||
throw new IOException("REALTIME_STREAM_HTTP_" + statusCode);
|
||||
}
|
||||
connected = true;
|
||||
setConnected(true);
|
||||
Log.i(TAG, "Realtime stream connected");
|
||||
|
||||
try (InputStream inputStream = connection.getInputStream();
|
||||
@@ -152,7 +162,7 @@ final class BossRealtimeClient {
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
connected = false;
|
||||
setConnected(false);
|
||||
activeConnection = null;
|
||||
connection.disconnect();
|
||||
}
|
||||
|
||||
@@ -72,12 +72,14 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
||||
private boolean masterAgentReplyTimedOut;
|
||||
private @Nullable String masterAgentReplyBaselineMessageId;
|
||||
private String currentScreenTitle;
|
||||
private String currentBaseSubtitle;
|
||||
private String currentScreenSubtitle;
|
||||
private String projectCollaborationMode = "development";
|
||||
private String projectApprovalState = "not_required";
|
||||
private boolean lightDispatchReminderEnabled;
|
||||
private @Nullable JSONObject currentPendingDispatchPlan;
|
||||
private @Nullable JSONObject currentRejectedDispatchPlan;
|
||||
private @Nullable JSONObject currentParticipantsPayload;
|
||||
private ProjectChatUiState.SelectionState selectionState = ProjectChatUiState.emptySelection();
|
||||
private ActivityResultLauncher<Intent> conversationInfoLauncher;
|
||||
private ActivityResultLauncher<Intent> masterAgentPromptLauncher;
|
||||
@@ -93,7 +95,9 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
||||
private final Handler uiHandler = new Handler(Looper.getMainLooper());
|
||||
private boolean conversationAutoRefreshArmed;
|
||||
private boolean conversationAutoRefreshEnabled;
|
||||
private boolean lastKnownRealtimeConnected;
|
||||
private boolean realtimeReloadScheduled;
|
||||
private boolean realtimeReloadRequiresFullSnapshot;
|
||||
private boolean reloadInFlight;
|
||||
private boolean pendingReload;
|
||||
private boolean pendingReloadForcedScrollToBottom;
|
||||
@@ -114,7 +118,9 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
||||
@Override
|
||||
public void run() {
|
||||
realtimeReloadScheduled = false;
|
||||
triggerRealtimeReload();
|
||||
boolean requireFullSnapshot = realtimeReloadRequiresFullSnapshot;
|
||||
realtimeReloadRequiresFullSnapshot = false;
|
||||
triggerRealtimeReload(requireFullSnapshot);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -248,7 +254,20 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
||||
new ActivityResultContracts.GetContent(),
|
||||
uri -> onAttachmentPicked(uri, "file")
|
||||
);
|
||||
realtimeClient = new BossRealtimeClient(apiClient, this::handleRealtimeEvent);
|
||||
realtimeClient = new BossRealtimeClient(apiClient, new BossRealtimeClient.Listener() {
|
||||
@Override
|
||||
public void onRealtimeEvent(BossRealtimeEvent event) {
|
||||
handleRealtimeEvent(event);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onRealtimeConnectionChanged(boolean connected) {
|
||||
runOnUiThread(() -> {
|
||||
lastKnownRealtimeConnected = connected;
|
||||
syncRealtimeStatusIndicator();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
BossWindowInsets.applyKeyboardAvoidingInset(composerRow);
|
||||
BossWindowInsets.applyKeyboardAvoidingInset(multiSelectActionsLayout);
|
||||
@@ -299,6 +318,7 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
||||
conversationAutoRefreshEnabled = true;
|
||||
updateConversationAutoRefresh();
|
||||
updateRealtimeSubscription();
|
||||
syncRealtimeStatusIndicator();
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -307,6 +327,7 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
||||
cancelConversationAutoRefresh();
|
||||
cancelRealtimeReloadSchedule();
|
||||
stopRealtimeUpdates();
|
||||
syncRealtimeStatusIndicator();
|
||||
super.onPause();
|
||||
}
|
||||
|
||||
@@ -335,7 +356,7 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
||||
if (isDuplicateRealtimeEvent(eventFingerprint, now)) {
|
||||
return;
|
||||
}
|
||||
runOnUiThread(this::scheduleRealtimeReload);
|
||||
runOnUiThread(() -> scheduleRealtimeReload(!"project.messages.updated".equals(event.eventName)));
|
||||
}
|
||||
|
||||
private boolean isDuplicateRealtimeEvent(String eventFingerprint, long now) {
|
||||
@@ -369,11 +390,18 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
||||
|| "master_agent.task.updated".equals(event.eventName);
|
||||
}
|
||||
|
||||
void triggerRealtimeReload() {
|
||||
reload();
|
||||
void triggerRealtimeReload(boolean requireFullSnapshot) {
|
||||
if (requireFullSnapshot) {
|
||||
reload();
|
||||
return;
|
||||
}
|
||||
reloadMessagesOnly();
|
||||
}
|
||||
|
||||
private void scheduleRealtimeReload() {
|
||||
private void scheduleRealtimeReload(boolean requireFullSnapshot) {
|
||||
if (requireFullSnapshot) {
|
||||
realtimeReloadRequiresFullSnapshot = true;
|
||||
}
|
||||
if (realtimeReloadScheduled) {
|
||||
return;
|
||||
}
|
||||
@@ -387,6 +415,14 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
||||
}
|
||||
|
||||
private void reload(boolean forcedScrollToBottom) {
|
||||
reloadSnapshot(forcedScrollToBottom, false);
|
||||
}
|
||||
|
||||
private void reloadMessagesOnly() {
|
||||
reloadSnapshot(false, true);
|
||||
}
|
||||
|
||||
private void reloadSnapshot(boolean forcedScrollToBottom, boolean messagesOnly) {
|
||||
if (projectId == null || projectId.isEmpty()) {
|
||||
showMessage("缺少 projectId");
|
||||
finish();
|
||||
@@ -403,13 +439,21 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
||||
setRefreshing(true);
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
ProjectSnapshot snapshot = loadProjectSnapshotForRefresh();
|
||||
ProjectSnapshot snapshot = messagesOnly
|
||||
? loadProjectMessagesSnapshotForRefresh()
|
||||
: loadProjectSnapshotForRefresh();
|
||||
runOnUiThread(() -> {
|
||||
renderLoadedProjectSnapshot(snapshot);
|
||||
finishReloadCycle();
|
||||
});
|
||||
} catch (Exception error) {
|
||||
runOnUiThread(() -> {
|
||||
if (messagesOnly) {
|
||||
reloadInFlight = false;
|
||||
setRefreshing(false);
|
||||
reload(forcedScrollToBottom);
|
||||
return;
|
||||
}
|
||||
handleProjectReloadFailure(error);
|
||||
finishReloadCycle();
|
||||
});
|
||||
@@ -421,6 +465,10 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
||||
return fetchProjectSnapshot();
|
||||
}
|
||||
|
||||
ProjectSnapshot loadProjectMessagesSnapshotForRefresh() throws Exception {
|
||||
return fetchProjectMessagesSnapshot();
|
||||
}
|
||||
|
||||
void renderLoadedProjectSnapshot(ProjectSnapshot snapshot) {
|
||||
renderProject(snapshot.payload, snapshot.dispatchPlans, snapshot.participantsPayload);
|
||||
}
|
||||
@@ -482,10 +530,18 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
||||
JSONObject agentControls = project == null ? null : project.optJSONObject("agentControls");
|
||||
currentAgentModelOverride = normalizeControlValue(agentControls == null ? null : agentControls.optString("modelOverride", null));
|
||||
currentReasoningEffortOverride = normalizeControlValue(agentControls == null ? null : agentControls.optString("reasoningEffortOverride", null));
|
||||
currentPendingDispatchPlan = ProjectChatUiState.latestPendingDispatchPlan(dispatchPlans);
|
||||
currentRejectedDispatchPlan = currentPendingDispatchPlan == null
|
||||
? ProjectChatUiState.latestRejectedDispatchPlan(dispatchPlans)
|
||||
: null;
|
||||
if (dispatchPlans != null) {
|
||||
currentPendingDispatchPlan = ProjectChatUiState.latestPendingDispatchPlan(dispatchPlans);
|
||||
currentRejectedDispatchPlan = currentPendingDispatchPlan == null
|
||||
? ProjectChatUiState.latestRejectedDispatchPlan(dispatchPlans)
|
||||
: null;
|
||||
}
|
||||
if (participantsPayload != null) {
|
||||
currentParticipantsPayload = participantsPayload;
|
||||
}
|
||||
JSONObject effectiveParticipantsPayload = participantsPayload == null
|
||||
? currentParticipantsPayload
|
||||
: participantsPayload;
|
||||
conversationInfoReady = project != null;
|
||||
updateProjectHeader(title, buildProjectSubtitle(projectFolderName, devices));
|
||||
|
||||
@@ -497,8 +553,10 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
||||
} else if (projectIsGroup && "rejected".equals(projectApprovalState) && currentRejectedDispatchPlan != null) {
|
||||
appendContent(buildRejectedDispatchPlanView(currentRejectedDispatchPlan));
|
||||
}
|
||||
if (projectIsGroup && participantsPayload != null && participantsPayload.optBoolean("repairRequired", false)) {
|
||||
appendContent(buildRepairGroupMembersView(participantsPayload));
|
||||
if (projectIsGroup
|
||||
&& effectiveParticipantsPayload != null
|
||||
&& effectiveParticipantsPayload.optBoolean("repairRequired", false)) {
|
||||
appendContent(buildRepairGroupMembersView(effectiveParticipantsPayload));
|
||||
}
|
||||
|
||||
JSONArray messages = project == null ? null : project.optJSONArray("messages");
|
||||
@@ -538,6 +596,7 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
||||
private void updateRealtimeSubscription() {
|
||||
if (apiClient != null && apiClient.hasSessionHints()) {
|
||||
realtimeClient.start();
|
||||
syncRealtimeStatusIndicator();
|
||||
return;
|
||||
}
|
||||
stopRealtimeUpdates();
|
||||
@@ -547,6 +606,7 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
||||
if (realtimeClient != null) {
|
||||
realtimeClient.stop();
|
||||
}
|
||||
syncRealtimeStatusIndicator();
|
||||
}
|
||||
|
||||
private boolean shouldMaintainConversationAutoRefresh() {
|
||||
@@ -1830,11 +1890,42 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
||||
|
||||
private void updateProjectHeader(String title, String subtitle) {
|
||||
currentScreenTitle = title;
|
||||
currentScreenSubtitle = subtitle;
|
||||
currentBaseSubtitle = subtitle;
|
||||
currentScreenSubtitle = withRealtimeStatus(subtitle);
|
||||
if (selectionState != null && selectionState.multiSelecting) {
|
||||
return;
|
||||
}
|
||||
configureScreen(title, subtitle);
|
||||
configureScreen(title, currentScreenSubtitle);
|
||||
}
|
||||
|
||||
private void syncRealtimeStatusIndicator() {
|
||||
boolean connected = isRealtimeConnected();
|
||||
if (lastKnownRealtimeConnected == connected
|
||||
&& currentScreenSubtitle != null
|
||||
&& currentScreenSubtitle.equals(withRealtimeStatus(currentBaseSubtitle))) {
|
||||
return;
|
||||
}
|
||||
lastKnownRealtimeConnected = connected;
|
||||
if (currentScreenTitle == null || currentScreenTitle.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
currentScreenSubtitle = withRealtimeStatus(currentBaseSubtitle);
|
||||
if (selectionState != null && selectionState.multiSelecting) {
|
||||
return;
|
||||
}
|
||||
configureScreen(currentScreenTitle, currentScreenSubtitle);
|
||||
}
|
||||
|
||||
private String withRealtimeStatus(String subtitle) {
|
||||
String baseSubtitle = subtitle == null ? "" : subtitle.trim();
|
||||
if (baseSubtitle.isEmpty()) {
|
||||
return realtimeStatusLabel();
|
||||
}
|
||||
return baseSubtitle + " · " + realtimeStatusLabel();
|
||||
}
|
||||
|
||||
private String realtimeStatusLabel() {
|
||||
return isRealtimeConnected() ? "实时已连接" : "实时重连中";
|
||||
}
|
||||
|
||||
private String joinDeviceNames(@Nullable JSONArray devices) {
|
||||
@@ -2280,6 +2371,14 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
||||
return new ProjectSnapshot(detailResponse.json, dispatchPlans, participantsPayload);
|
||||
}
|
||||
|
||||
private ProjectSnapshot fetchProjectMessagesSnapshot() throws Exception {
|
||||
BossApiClient.ApiResponse response = apiClient.getProjectMessages(projectId);
|
||||
if (!response.ok()) {
|
||||
throw new IllegalStateException(response.message());
|
||||
}
|
||||
return new ProjectSnapshot(response.json, null, null);
|
||||
}
|
||||
|
||||
private void startReplyWait(
|
||||
ProjectChatUiState.ReplyWaitSpec waitSpec,
|
||||
boolean includeDispatchPlans,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { requireRequestSession } from "@/lib/boss-auth";
|
||||
import { appendProjectMessage, buildCollaborationGate, readState } from "@/lib/boss-data";
|
||||
import { jsonNoStore } from "@/lib/api-response";
|
||||
import {
|
||||
getThreadConversationExecutionConflict,
|
||||
queueGroupDispatchPlan,
|
||||
@@ -35,6 +36,40 @@ function threadConversationFailureMessage(error?: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function buildProjectMessagesPayload(
|
||||
state: Awaited<ReturnType<typeof readState>>,
|
||||
projectId: string,
|
||||
) {
|
||||
const project = state.projects.find((item) => item.id === projectId);
|
||||
if (!project) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
project,
|
||||
devices: state.devices.filter((device) => project.deviceIds.includes(device.id)),
|
||||
};
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
context: { params: Promise<{ projectId: string }> },
|
||||
) {
|
||||
const session = await requireRequestSession(request);
|
||||
if (!session) {
|
||||
return jsonNoStore({ ok: false, message: "UNAUTHORIZED" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { projectId } = await context.params;
|
||||
const state = await readState();
|
||||
const payload = buildProjectMessagesPayload(state, projectId);
|
||||
if (!payload) {
|
||||
return jsonNoStore({ ok: false, message: "PROJECT_NOT_FOUND" }, { status: 404 });
|
||||
}
|
||||
|
||||
return jsonNoStore(payload);
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
context: { params: Promise<{ projectId: string }> },
|
||||
|
||||
42
tests/android-chat-lightweight-realtime-refresh.test.ts
Normal file
42
tests/android-chat-lightweight-realtime-refresh.test.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
|
||||
async function readSource(path: string) {
|
||||
return readFile(new URL(path, import.meta.url), "utf8");
|
||||
}
|
||||
|
||||
test("BossApiClient exposes a lightweight project messages endpoint", async () => {
|
||||
const source = await readSource("../android/app/src/main/java/com/hyzq/boss/BossApiClient.java");
|
||||
|
||||
assert.match(
|
||||
source,
|
||||
/public ApiResponse getProjectMessages\(String projectId\) throws IOException, JSONException \{/,
|
||||
"expected Android client to expose a lightweight messages endpoint",
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/return requestWithRestore\("GET", "\/api\/v1\/projects\/" \+ encode\(projectId\) \+ "\/messages", null\);/,
|
||||
"expected lightweight message refreshes to reuse the dedicated messages route",
|
||||
);
|
||||
});
|
||||
|
||||
test("ProjectDetailActivity reserves full realtime reloads for non-message events", async () => {
|
||||
const source = await readSource("../android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java");
|
||||
|
||||
assert.match(
|
||||
source,
|
||||
/private boolean realtimeReloadRequiresFullSnapshot;/,
|
||||
"expected chat page debounce state to remember whether a full snapshot is required",
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/runOnUiThread\(\(\) -> scheduleRealtimeReload\(!"project\.messages\.updated"\.equals\(event\.eventName\)\)\);/,
|
||||
"expected message-only realtime updates to avoid forcing a full snapshot",
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/void triggerRealtimeReload\(boolean requireFullSnapshot\) \{\s*if \(requireFullSnapshot\) \{\s*reload\(\);\s*return;\s*\}\s*reloadMessagesOnly\(\);\s*\}/s,
|
||||
"expected debounced realtime reloads to choose between full and lightweight refresh paths",
|
||||
);
|
||||
});
|
||||
57
tests/android-chat-realtime-status-indicator.test.ts
Normal file
57
tests/android-chat-realtime-status-indicator.test.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
|
||||
async function readSource(path: string) {
|
||||
return readFile(new URL(path, import.meta.url), "utf8");
|
||||
}
|
||||
|
||||
test("ProjectDetailActivity derives a subtitle suffix from realtime connection state", async () => {
|
||||
const source = await readSource("../android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java");
|
||||
|
||||
assert.match(
|
||||
source,
|
||||
/private String realtimeStatusLabel\(\)/,
|
||||
"expected chat page to centralize realtime status wording",
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/return isRealtimeConnected\(\) \? "实时已连接" : "实时重连中";/,
|
||||
"expected chat page to distinguish healthy realtime from reconnecting state",
|
||||
);
|
||||
});
|
||||
|
||||
test("ProjectDetailActivity appends realtime status to the header subtitle", async () => {
|
||||
const source = await readSource("../android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java");
|
||||
|
||||
assert.match(
|
||||
source,
|
||||
/updateProjectHeader\(title,\s*buildProjectSubtitle\([^)]*\)\);/,
|
||||
"expected project chat header to still flow through updateProjectHeader",
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/currentScreenSubtitle = withRealtimeStatus\(subtitle\);/,
|
||||
"expected current subtitle cache to include realtime status",
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/configureScreen\(title,\s*currentScreenSubtitle\);/,
|
||||
"expected rendered subtitle to include realtime status",
|
||||
);
|
||||
});
|
||||
|
||||
test("ProjectDetailActivity refreshes the subtitle when realtime connectivity changes", async () => {
|
||||
const source = await readSource("../android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java");
|
||||
|
||||
assert.match(
|
||||
source,
|
||||
/private boolean lastKnownRealtimeConnected;/,
|
||||
"expected chat page to remember the last rendered realtime state",
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/syncRealtimeStatusIndicator\(\);/,
|
||||
"expected chat page to refresh subtitle state from lifecycle and realtime hooks",
|
||||
);
|
||||
});
|
||||
@@ -11,8 +11,8 @@ test("BossRealtimeClient tracks whether the SSE stream is currently connected",
|
||||
|
||||
assert.match(source, /private volatile boolean connected;/, "expected realtime client to cache connection state");
|
||||
assert.match(source, /boolean isConnected\(\)\s*\{\s*return connected;\s*\}/, "expected realtime client to expose connection state");
|
||||
assert.match(source, /connected = true;/, "expected realtime client to flip connected once the SSE stream is ready");
|
||||
assert.match(source, /connected = false;/, "expected realtime client to clear connected when the stream stops");
|
||||
assert.match(source, /setConnected\(true\);/, "expected realtime client to flip connected once the SSE stream is ready");
|
||||
assert.match(source, /setConnected\(false\);/, "expected realtime client to clear connected when the stream stops");
|
||||
});
|
||||
|
||||
test("MainActivity only performs conversation polling when realtime is unavailable", async () => {
|
||||
|
||||
@@ -25,8 +25,9 @@ test("ProjectDetailActivity debounces realtime chat reload bursts", async () =>
|
||||
|
||||
assert.match(source, /private static final long REALTIME_REFRESH_DEBOUNCE_MS = [\d_]+L;/);
|
||||
assert.match(source, /private boolean realtimeReloadScheduled(?: = false)?;/);
|
||||
assert.match(source, /private boolean realtimeReloadRequiresFullSnapshot;/);
|
||||
assert.match(source, /private final Runnable realtimeReloadRunnable = new Runnable\(\)/);
|
||||
assert.match(source, /scheduleRealtimeReload\(\)/);
|
||||
assert.match(source, /scheduleRealtimeReload\(boolean requireFullSnapshot\)/);
|
||||
assert.doesNotMatch(
|
||||
source,
|
||||
/runOnUiThread\(this::triggerRealtimeReload\)/,
|
||||
|
||||
170
tests/project-messages-route.test.ts
Normal file
170
tests/project-messages-route.test.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
let runtimeRoot = "";
|
||||
let getMessagesRoute: (typeof import("../src/app/api/v1/projects/[projectId]/messages/route"))["GET"];
|
||||
let createAuthSession: (typeof import("../src/lib/boss-data"))["createAuthSession"];
|
||||
let readState: (typeof import("../src/lib/boss-data"))["readState"];
|
||||
let writeState: (typeof import("../src/lib/boss-data"))["writeState"];
|
||||
let AUTH_SESSION_COOKIE = "";
|
||||
let baseState: Awaited<ReturnType<typeof import("../src/lib/boss-data")["readState"]>>;
|
||||
|
||||
async function setup() {
|
||||
if (runtimeRoot) return;
|
||||
runtimeRoot = await mkdtemp(path.join(os.tmpdir(), "boss-project-messages-route-"));
|
||||
process.env.BOSS_RUNTIME_ROOT = runtimeRoot;
|
||||
process.env.BOSS_STATE_FILE = path.join(runtimeRoot, "boss-state.json");
|
||||
|
||||
const [messageModule, data, auth] = await Promise.all([
|
||||
import("../src/app/api/v1/projects/[projectId]/messages/route.ts"),
|
||||
import("../src/lib/boss-data.ts"),
|
||||
import("../src/lib/boss-auth.ts"),
|
||||
]);
|
||||
|
||||
getMessagesRoute = messageModule.GET;
|
||||
createAuthSession = data.createAuthSession;
|
||||
readState = data.readState;
|
||||
writeState = data.writeState;
|
||||
baseState = structuredClone(await readState());
|
||||
AUTH_SESSION_COOKIE = auth.AUTH_SESSION_COOKIE;
|
||||
}
|
||||
|
||||
test.after(async () => {
|
||||
if (runtimeRoot) {
|
||||
await rm(runtimeRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test.beforeEach(async () => {
|
||||
await setup();
|
||||
await writeState(structuredClone(baseState));
|
||||
});
|
||||
|
||||
function buildSingleThreadProject(projectId: string) {
|
||||
return {
|
||||
id: projectId,
|
||||
name: "轻量消息线程",
|
||||
pinned: false,
|
||||
systemPinned: false,
|
||||
deviceIds: ["device-message-lite"],
|
||||
preview: "等待增量刷新。",
|
||||
updatedAt: "2026-04-10T16:20:00+08:00",
|
||||
lastMessageAt: "2026-04-10T16:20:00+08:00",
|
||||
isGroup: false,
|
||||
threadMeta: {
|
||||
projectId,
|
||||
threadId: "thread-message-lite",
|
||||
threadDisplayName: "轻量消息线程",
|
||||
folderName: "Boss",
|
||||
activityIconCount: 0,
|
||||
updatedAt: "2026-04-10T16:20:00+08:00",
|
||||
codexThreadRef: "thread-message-lite",
|
||||
codexFolderRef: "boss",
|
||||
},
|
||||
groupMembers: [],
|
||||
createdByAgent: true,
|
||||
collaborationMode: "development" as const,
|
||||
approvalState: "not_required" as const,
|
||||
unreadCount: 0,
|
||||
riskLevel: "low" as const,
|
||||
messages: [
|
||||
{
|
||||
id: "message-lite-1",
|
||||
sender: "assistant",
|
||||
senderLabel: "Codex",
|
||||
body: "新的消息已经到了。",
|
||||
kind: "text" as const,
|
||||
sentAt: "2026-04-10T16:20:00+08:00",
|
||||
},
|
||||
],
|
||||
goals: [],
|
||||
versions: [],
|
||||
};
|
||||
}
|
||||
|
||||
async function createAuthedRequest(projectId: string) {
|
||||
const session = await createAuthSession({
|
||||
account: "17600003315",
|
||||
role: "highest_admin",
|
||||
displayName: "Boss 超级管理员",
|
||||
loginMethod: "password",
|
||||
});
|
||||
|
||||
return new NextRequest(`http://127.0.0.1:3000/api/v1/projects/${projectId}/messages`, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
cookie: `${AUTH_SESSION_COOKIE}=${session.sessionToken}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
test("GET /api/v1/projects/[projectId]/messages returns a lightweight chat payload", async () => {
|
||||
await setup();
|
||||
const state = await readState();
|
||||
const project = buildSingleThreadProject("message-lite");
|
||||
|
||||
await writeState({
|
||||
...state,
|
||||
devices: state.devices.concat({
|
||||
id: "device-message-lite",
|
||||
name: "Mac Studio",
|
||||
avatar: "M",
|
||||
account: "17600003315",
|
||||
source: "production",
|
||||
status: "online",
|
||||
projects: [project.id],
|
||||
quota5h: 0,
|
||||
quota7d: 0,
|
||||
lastSeenAt: "2026-04-10T16:20:00+08:00",
|
||||
note: "",
|
||||
}),
|
||||
projects: state.projects.concat(project),
|
||||
});
|
||||
|
||||
const response = await getMessagesRoute(
|
||||
await createAuthedRequest(project.id),
|
||||
{ params: Promise.resolve({ projectId: project.id }) },
|
||||
);
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(response.headers.get("Cache-Control"), "private, no-store, max-age=0");
|
||||
|
||||
const payload = (await response.json()) as {
|
||||
ok: boolean;
|
||||
project: { id: string; messages: Array<{ id: string }> };
|
||||
devices: Array<{ id: string }>;
|
||||
activeThreadContexts?: unknown;
|
||||
recentAppLogs?: unknown;
|
||||
openFaults?: unknown;
|
||||
};
|
||||
|
||||
assert.equal(payload.ok, true);
|
||||
assert.equal(payload.project.id, project.id);
|
||||
assert.deepEqual(
|
||||
payload.project.messages.map((message) => message.id),
|
||||
["message-lite-1"],
|
||||
);
|
||||
assert.deepEqual(
|
||||
payload.devices.map((device) => device.id),
|
||||
["device-message-lite"],
|
||||
);
|
||||
assert.equal("activeThreadContexts" in payload, false);
|
||||
assert.equal("recentAppLogs" in payload, false);
|
||||
assert.equal("openFaults" in payload, false);
|
||||
});
|
||||
|
||||
test("GET /api/v1/projects/[projectId]/messages disables caching when unauthorized", async () => {
|
||||
await setup();
|
||||
|
||||
const response = await getMessagesRoute(
|
||||
new NextRequest("http://127.0.0.1:3000/api/v1/projects/message-lite/messages"),
|
||||
{ params: Promise.resolve({ projectId: "message-lite" }) },
|
||||
);
|
||||
|
||||
assert.equal(response.status, 401);
|
||||
assert.equal(response.headers.get("Cache-Control"), "private, no-store, max-age=0");
|
||||
});
|
||||
Reference in New Issue
Block a user