From 7131ee9eb126bcae660077e229887a0ddf59bdeb Mon Sep 17 00:00:00 2001 From: kris Date: Fri, 10 Apr 2026 17:15:39 +0800 Subject: [PATCH] Lighten Android chat realtime refreshes --- .../java/com/hyzq/boss/BossApiClient.java | 4 + .../com/hyzq/boss/BossRealtimeClient.java | 16 +- .../com/hyzq/boss/ProjectDetailActivity.java | 129 +++++++++++-- .../v1/projects/[projectId]/messages/route.ts | 35 ++++ ...-chat-lightweight-realtime-refresh.test.ts | 42 +++++ ...oid-chat-realtime-status-indicator.test.ts | 57 ++++++ .../android-realtime-fallback-polling.test.ts | 4 +- .../android-realtime-refresh-debounce.test.ts | 3 +- tests/project-messages-route.test.ts | 170 ++++++++++++++++++ 9 files changed, 439 insertions(+), 21 deletions(-) create mode 100644 tests/android-chat-lightweight-realtime-refresh.test.ts create mode 100644 tests/android-chat-realtime-status-indicator.test.ts create mode 100644 tests/project-messages-route.test.ts diff --git a/android/app/src/main/java/com/hyzq/boss/BossApiClient.java b/android/app/src/main/java/com/hyzq/boss/BossApiClient.java index 5070d38..7746094 100644 --- a/android/app/src/main/java/com/hyzq/boss/BossApiClient.java +++ b/android/app/src/main/java/com/hyzq/boss/BossApiClient.java @@ -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); } diff --git a/android/app/src/main/java/com/hyzq/boss/BossRealtimeClient.java b/android/app/src/main/java/com/hyzq/boss/BossRealtimeClient.java index f77b5f7..121b685 100644 --- a/android/app/src/main/java/com/hyzq/boss/BossRealtimeClient.java +++ b/android/app/src/main/java/com/hyzq/boss/BossRealtimeClient.java @@ -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(); } diff --git a/android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java b/android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java index 06ab698..6604bd5 100644 --- a/android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java +++ b/android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java @@ -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 conversationInfoLauncher; private ActivityResultLauncher 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, diff --git a/src/app/api/v1/projects/[projectId]/messages/route.ts b/src/app/api/v1/projects/[projectId]/messages/route.ts index 8df3ff4..6b4e2b6 100644 --- a/src/app/api/v1/projects/[projectId]/messages/route.ts +++ b/src/app/api/v1/projects/[projectId]/messages/route.ts @@ -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>, + 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 }> }, diff --git a/tests/android-chat-lightweight-realtime-refresh.test.ts b/tests/android-chat-lightweight-realtime-refresh.test.ts new file mode 100644 index 0000000..6994bbb --- /dev/null +++ b/tests/android-chat-lightweight-realtime-refresh.test.ts @@ -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", + ); +}); diff --git a/tests/android-chat-realtime-status-indicator.test.ts b/tests/android-chat-realtime-status-indicator.test.ts new file mode 100644 index 0000000..79fe245 --- /dev/null +++ b/tests/android-chat-realtime-status-indicator.test.ts @@ -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", + ); +}); diff --git a/tests/android-realtime-fallback-polling.test.ts b/tests/android-realtime-fallback-polling.test.ts index 9dad298..3bb22f6 100644 --- a/tests/android-realtime-fallback-polling.test.ts +++ b/tests/android-realtime-fallback-polling.test.ts @@ -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 () => { diff --git a/tests/android-realtime-refresh-debounce.test.ts b/tests/android-realtime-refresh-debounce.test.ts index 587404b..0055e9f 100644 --- a/tests/android-realtime-refresh-debounce.test.ts +++ b/tests/android-realtime-refresh-debounce.test.ts @@ -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\)/, diff --git a/tests/project-messages-route.test.ts b/tests/project-messages-route.test.ts new file mode 100644 index 0000000..e866a70 --- /dev/null +++ b/tests/project-messages-route.test.ts @@ -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>; + +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"); +});