feat: add thread status read views
This commit is contained in:
@@ -231,6 +231,10 @@ public class BossApiClient {
|
||||
return requestWithRestore("GET", "/api/v1/projects/" + encode(projectId) + "/participants", null);
|
||||
}
|
||||
|
||||
public ApiResponse getThreadStatus(String projectId) throws IOException, JSONException {
|
||||
return requestWithRestore("GET", "/api/v1/projects/" + encode(projectId) + "/thread-status", null);
|
||||
}
|
||||
|
||||
public ApiResponse replaceConversationParticipants(String projectId, JSONArray memberProjectIds) throws IOException, JSONException {
|
||||
JSONObject payload = new JSONObject();
|
||||
payload.put("memberProjectIds", memberProjectIds == null ? new JSONArray() : memberProjectIds);
|
||||
|
||||
@@ -102,6 +102,15 @@ public class ConversationInfoActivity extends BossScreenActivity {
|
||||
v -> openProject(projectId, projectName)
|
||||
));
|
||||
|
||||
appendContent(BossUi.buildWechatMenuRow(
|
||||
this,
|
||||
"线程状态",
|
||||
"只读状态文档和最近进展事件",
|
||||
projectFolderName.isEmpty() ? "只读" : projectFolderName + " · 只读",
|
||||
null,
|
||||
v -> openThreadStatus()
|
||||
));
|
||||
|
||||
appendContent(BossUi.buildWechatMenuRow(
|
||||
this,
|
||||
"参与线程",
|
||||
@@ -175,6 +184,17 @@ public class ConversationInfoActivity extends BossScreenActivity {
|
||||
startActivity(intent);
|
||||
}
|
||||
|
||||
private void openThreadStatus() {
|
||||
if (projectId == null || projectId.isEmpty()) {
|
||||
showMessage("缺少 projectId");
|
||||
return;
|
||||
}
|
||||
Intent intent = new Intent(this, ThreadStatusActivity.class);
|
||||
intent.putExtra(ThreadStatusActivity.EXTRA_PROJECT_ID, projectId);
|
||||
intent.putExtra(ThreadStatusActivity.EXTRA_PROJECT_NAME, projectName);
|
||||
startActivity(intent);
|
||||
}
|
||||
|
||||
private void openRenameDialog() {
|
||||
final EditText input = BossUi.buildInput(this, "线程名", false);
|
||||
input.setText(projectName == null ? "" : projectName);
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import android.os.Bundle;
|
||||
import android.widget.LinearLayout;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
|
||||
public class ThreadStatusActivity extends BossScreenActivity {
|
||||
public static final String EXTRA_PROJECT_ID = "project_id";
|
||||
public static final String EXTRA_PROJECT_NAME = "project_name";
|
||||
|
||||
private String projectId;
|
||||
private String projectName;
|
||||
|
||||
@Override
|
||||
protected void onCreate(@Nullable Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
projectId = getIntent().getStringExtra(EXTRA_PROJECT_ID);
|
||||
projectName = getIntent().getStringExtra(EXTRA_PROJECT_NAME);
|
||||
configureScreen("线程状态", projectName == null ? "只读状态文档" : projectName);
|
||||
setHeaderAction("只读", v -> showMessage("线程状态只读"));
|
||||
reload();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void reload() {
|
||||
if (projectId == null || projectId.isEmpty()) {
|
||||
replaceContent(BossUi.buildEmptyCard(this, "缺少 projectId。"));
|
||||
setRefreshing(false);
|
||||
return;
|
||||
}
|
||||
setRefreshing(true);
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
BossApiClient.ApiResponse response = apiClient.getThreadStatus(projectId);
|
||||
if (!response.ok()) throw new IllegalStateException(response.message());
|
||||
runOnUiThread(() -> renderThreadStatus(response.json));
|
||||
} catch (Exception error) {
|
||||
runOnUiThread(() -> {
|
||||
setRefreshing(false);
|
||||
replaceContent(BossUi.buildEmptyCard(this, "线程状态加载失败:" + error.getMessage()));
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void renderThreadStatus(JSONObject payload) {
|
||||
replaceContent();
|
||||
JSONObject document = payload.optJSONObject("threadStatusDocument");
|
||||
JSONArray recentProgressEvents = payload.optJSONArray("recentProgressEvents");
|
||||
|
||||
if (document == null) {
|
||||
appendContent(BossUi.buildEmptyCard(this, "当前还没有线程状态文档。"));
|
||||
setRefreshing(false);
|
||||
return;
|
||||
}
|
||||
|
||||
String threadDisplayName = document.optString(
|
||||
"threadDisplayName",
|
||||
projectName == null ? "线程状态" : projectName
|
||||
);
|
||||
configureScreen("线程状态", buildSubtitle(document, recentProgressEvents));
|
||||
|
||||
appendContent(BossUi.buildSimpleProfileHeader(
|
||||
this,
|
||||
threadDisplayName,
|
||||
"只读状态文档",
|
||||
buildHeaderDetail(document, recentProgressEvents)
|
||||
));
|
||||
|
||||
appendStatusCard("当前目标", document.optString("projectGoal", "暂无目标"));
|
||||
appendStatusCard("当前阶段", document.optString("currentPhase", "暂无阶段"));
|
||||
appendStatusCard("当前进度", document.optString("currentProgress", "暂无进度"));
|
||||
appendStatusCard("技术架构", document.optString("technicalArchitecture", "暂无架构"));
|
||||
appendStatusCard("当前阻塞", document.optString("currentBlockers", "暂无阻塞"));
|
||||
appendStatusCard("建议下一步", document.optString("recommendedNextStep", "暂无建议"));
|
||||
appendRecentEvents(recentProgressEvents);
|
||||
|
||||
setRefreshing(false);
|
||||
}
|
||||
|
||||
private void appendStatusCard(String title, String body) {
|
||||
appendContent(BossUi.buildCard(this, title, body, ""));
|
||||
}
|
||||
|
||||
private void appendRecentEvents(@Nullable JSONArray recentProgressEvents) {
|
||||
int count = recentProgressEvents == null ? 0 : recentProgressEvents.length();
|
||||
appendContent(BossUi.buildWechatMenuRow(
|
||||
this,
|
||||
"最近进展事件",
|
||||
count <= 0 ? "当前还没有进展事件。" : "共 " + count + " 条",
|
||||
"只读视图 · 最近 5 条",
|
||||
null,
|
||||
null
|
||||
));
|
||||
if (recentProgressEvents == null || recentProgressEvents.length() == 0) {
|
||||
appendContent(BossUi.buildEmptyCard(this, "当前还没有进展事件。"));
|
||||
return;
|
||||
}
|
||||
for (int i = 0; i < recentProgressEvents.length(); i++) {
|
||||
JSONObject event = recentProgressEvents.optJSONObject(i);
|
||||
if (event == null) continue;
|
||||
LinearLayout row = BossUi.buildWechatMenuRow(
|
||||
this,
|
||||
event.optString("summary", "线程状态更新"),
|
||||
event.optString("phase", event.optString("eventType", "progress_updated")),
|
||||
buildEventMeta(event),
|
||||
null,
|
||||
null
|
||||
);
|
||||
appendContent(row);
|
||||
}
|
||||
}
|
||||
|
||||
private String buildSubtitle(JSONObject document, @Nullable JSONArray recentProgressEvents) {
|
||||
int count = recentProgressEvents == null ? 0 : recentProgressEvents.length();
|
||||
String folderName = document.optString("folderName", "");
|
||||
String suffix = count <= 0 ? "暂无进展事件" : "最近 " + count + " 条进展事件";
|
||||
if (folderName.isEmpty()) {
|
||||
return suffix;
|
||||
}
|
||||
return folderName + " · " + suffix;
|
||||
}
|
||||
|
||||
private String buildHeaderDetail(JSONObject document, @Nullable JSONArray recentProgressEvents) {
|
||||
int count = recentProgressEvents == null ? 0 : recentProgressEvents.length();
|
||||
StringBuilder builder = new StringBuilder();
|
||||
String threadId = document.optString("threadId", "");
|
||||
if (!threadId.isEmpty()) {
|
||||
builder.append(threadId);
|
||||
}
|
||||
String deviceId = document.optString("deviceId", "");
|
||||
if (!deviceId.isEmpty()) {
|
||||
if (builder.length() > 0) {
|
||||
builder.append(" · ");
|
||||
}
|
||||
builder.append(deviceId);
|
||||
}
|
||||
if (builder.length() > 0) {
|
||||
builder.append(" · ");
|
||||
}
|
||||
builder.append(count <= 0 ? "暂无进展事件" : count + " 条进展事件");
|
||||
return builder.toString();
|
||||
}
|
||||
|
||||
private String buildEventMeta(JSONObject event) {
|
||||
StringBuilder builder = new StringBuilder();
|
||||
String deviceId = event.optString("deviceId", "");
|
||||
if (!deviceId.isEmpty()) {
|
||||
builder.append(deviceId);
|
||||
}
|
||||
String createdAt = event.optString("createdAt", "");
|
||||
if (!createdAt.isEmpty()) {
|
||||
if (builder.length() > 0) {
|
||||
builder.append(" · ");
|
||||
}
|
||||
builder.append(createdAt);
|
||||
}
|
||||
if (builder.length() == 0) {
|
||||
return event.optString("eventType", "progress_updated");
|
||||
}
|
||||
return builder.toString();
|
||||
}
|
||||
}
|
||||
@@ -100,6 +100,38 @@ public class ConversationInfoActivityTest {
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void conversationInfoShowsThreadStatusEntryForThreadConversation() throws Exception {
|
||||
Intent intent = new Intent()
|
||||
.putExtra(ConversationInfoActivity.EXTRA_PROJECT_ID, "project-1")
|
||||
.putExtra(ConversationInfoActivity.EXTRA_PROJECT_NAME, "北区试产线回归");
|
||||
TestConversationInfoActivity activity = Robolectric
|
||||
.buildActivity(TestConversationInfoActivity.class, intent)
|
||||
.setup()
|
||||
.get();
|
||||
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"renderConversation",
|
||||
ReflectionHelpers.ClassParameter.from(JSONObject.class, buildDetailPayload()),
|
||||
ReflectionHelpers.ClassParameter.from(JSONObject.class, buildParticipantsPayload())
|
||||
);
|
||||
|
||||
View threadStatusRow = findClickableViewContainingText(
|
||||
activity.findViewById(R.id.screen_content),
|
||||
"线程状态"
|
||||
);
|
||||
assertNotNull(threadStatusRow);
|
||||
|
||||
threadStatusRow.performClick();
|
||||
|
||||
Intent nextIntent = Shadows.shadowOf(activity).getNextStartedActivity();
|
||||
assertNotNull(nextIntent);
|
||||
assertEquals(ThreadStatusActivity.class.getName(), nextIntent.getComponent().getClassName());
|
||||
assertEquals("project-1", nextIntent.getStringExtra(ThreadStatusActivity.EXTRA_PROJECT_ID));
|
||||
assertEquals("北区试产线回归", nextIntent.getStringExtra(ThreadStatusActivity.EXTRA_PROJECT_NAME));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void conversationInfoUsesOverflowMenuInTopBar() {
|
||||
Intent intent = new Intent()
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertNotNull;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.LinearLayout;
|
||||
import android.widget.TextView;
|
||||
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
import org.robolectric.Robolectric;
|
||||
import org.robolectric.RobolectricTestRunner;
|
||||
import org.robolectric.annotation.Config;
|
||||
import org.robolectric.util.ReflectionHelpers;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.AbstractExecutorService;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
@RunWith(RobolectricTestRunner.class)
|
||||
@Config(sdk = 34)
|
||||
public class ThreadStatusActivityTest {
|
||||
@Test
|
||||
public void reloadReadsThreadStatusAndRendersDocumentAndEvents() throws Exception {
|
||||
Intent intent = new Intent()
|
||||
.putExtra(ThreadStatusActivity.EXTRA_PROJECT_ID, "project-1")
|
||||
.putExtra(ThreadStatusActivity.EXTRA_PROJECT_NAME, "北区试产线回归");
|
||||
TestThreadStatusActivity activity = Robolectric
|
||||
.buildActivity(TestThreadStatusActivity.class, intent)
|
||||
.setup()
|
||||
.get();
|
||||
|
||||
RecordingBossApiClient apiClient = new RecordingBossApiClient(
|
||||
activity.getSharedPreferences("thread-status-test", Context.MODE_PRIVATE),
|
||||
"https://boss.hyzq.net"
|
||||
);
|
||||
ReflectionHelpers.setField(activity, "apiClient", apiClient);
|
||||
ReflectionHelpers.setField(activity, "executor", new DirectExecutorService());
|
||||
ReflectionHelpers.setField(activity, "reloadEnabled", true);
|
||||
|
||||
activity.reload();
|
||||
|
||||
LinearLayout content = activity.findViewById(R.id.screen_content);
|
||||
assertTrue(viewTreeContainsText(content, "当前目标"));
|
||||
assertTrue(viewTreeContainsText(content, "完成线程状态回归"));
|
||||
assertTrue(viewTreeContainsText(content, "当前阶段"));
|
||||
assertTrue(viewTreeContainsText(content, "增量同步"));
|
||||
assertTrue(viewTreeContainsText(content, "当前进度"));
|
||||
assertTrue(viewTreeContainsText(content, "已经记录最近 2 条进展"));
|
||||
assertTrue(viewTreeContainsText(content, "技术架构"));
|
||||
assertTrue(viewTreeContainsText(content, "Android 原生客户端 + Next.js API"));
|
||||
assertTrue(viewTreeContainsText(content, "当前阻塞"));
|
||||
assertTrue(viewTreeContainsText(content, "建议下一步"));
|
||||
assertTrue(viewTreeContainsText(content, "继续同步 Android 只读页"));
|
||||
assertTrue(viewTreeContainsText(content, "最近进展事件"));
|
||||
assertTrue(viewTreeContainsText(content, "事件 2"));
|
||||
assertTrue(viewTreeContainsText(content, "事件 1"));
|
||||
assertEquals("project-1", apiClient.lastRequestedProjectId);
|
||||
assertEquals("只读", String.valueOf(activity.findViewById(R.id.screen_header_action).getContentDescription()));
|
||||
}
|
||||
|
||||
private static final class TestThreadStatusActivity extends ThreadStatusActivity {
|
||||
private boolean reloadEnabled;
|
||||
|
||||
@Override
|
||||
protected void reload() {
|
||||
if (!reloadEnabled) {
|
||||
return;
|
||||
}
|
||||
super.reload();
|
||||
}
|
||||
}
|
||||
|
||||
private static final class RecordingBossApiClient extends BossApiClient {
|
||||
private String lastRequestedProjectId = "";
|
||||
|
||||
RecordingBossApiClient(android.content.SharedPreferences prefs, String baseUrl) {
|
||||
super(prefs, baseUrl);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ApiResponse getThreadStatus(String projectId) throws java.io.IOException, org.json.JSONException {
|
||||
lastRequestedProjectId = projectId;
|
||||
JSONObject payload = new JSONObject()
|
||||
.put("ok", true)
|
||||
.put("projectId", projectId)
|
||||
.put("threadStatusDocument", new JSONObject()
|
||||
.put("documentId", "doc-1")
|
||||
.put("projectId", projectId)
|
||||
.put("threadId", "thread-1")
|
||||
.put("threadDisplayName", "北区试产线回归")
|
||||
.put("folderName", "Boss")
|
||||
.put("deviceId", "mac-studio")
|
||||
.put("projectGoal", "完成线程状态回归")
|
||||
.put("currentPhase", "增量同步")
|
||||
.put("currentProgress", "已经记录最近 2 条进展")
|
||||
.put("technicalArchitecture", "Android 原生客户端 + Next.js API")
|
||||
.put("currentBlockers", "暂无阻塞")
|
||||
.put("recommendedNextStep", "继续同步 Android 只读页")
|
||||
.put("keyFiles", new JSONArray().put("ThreadStatusActivity.java"))
|
||||
.put("keyCommands", new JSONArray().put("./gradlew testDebugUnitTest"))
|
||||
.put("updatedAt", "2026-04-04T18:00:00+08:00")
|
||||
.put("sourceTaskId", "task-1")
|
||||
.put("sourceKind", "incremental_sync"))
|
||||
.put("recentProgressEvents", new JSONArray()
|
||||
.put(new JSONObject()
|
||||
.put("eventId", "event-2")
|
||||
.put("projectId", projectId)
|
||||
.put("threadId", "thread-1")
|
||||
.put("threadDisplayName", "北区试产线回归")
|
||||
.put("deviceId", "mac-studio")
|
||||
.put("eventType", "progress_updated")
|
||||
.put("summary", "事件 2")
|
||||
.put("phase", "增量同步")
|
||||
.put("createdAt", "2026-04-04T18:02:00+08:00")
|
||||
.put("sourceTaskId", "task-2"))
|
||||
.put(new JSONObject()
|
||||
.put("eventId", "event-1")
|
||||
.put("projectId", projectId)
|
||||
.put("threadId", "thread-1")
|
||||
.put("threadDisplayName", "北区试产线回归")
|
||||
.put("deviceId", "mac-studio")
|
||||
.put("eventType", "progress_updated")
|
||||
.put("summary", "事件 1")
|
||||
.put("phase", "增量同步")
|
||||
.put("createdAt", "2026-04-04T18:01:00+08:00")
|
||||
.put("sourceTaskId", "task-1")));
|
||||
return new ApiResponse(200, payload);
|
||||
}
|
||||
}
|
||||
|
||||
private static final class DirectExecutorService extends AbstractExecutorService {
|
||||
private boolean shutdown;
|
||||
|
||||
@Override
|
||||
public void shutdown() {
|
||||
shutdown = true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Runnable> shutdownNow() {
|
||||
shutdown = true;
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isShutdown() {
|
||||
return shutdown;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isTerminated() {
|
||||
return shutdown;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean awaitTermination(long timeout, TimeUnit unit) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void execute(Runnable command) {
|
||||
command.run();
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean viewTreeContainsText(View root, String expectedText) {
|
||||
if (root instanceof TextView) {
|
||||
CharSequence text = ((TextView) root).getText();
|
||||
if (expectedText.contentEquals(text)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (!(root instanceof ViewGroup)) {
|
||||
return false;
|
||||
}
|
||||
ViewGroup group = (ViewGroup) root;
|
||||
for (int index = 0; index < group.getChildCount(); index += 1) {
|
||||
if (viewTreeContainsText(group.getChildAt(index), expectedText)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
34
src/app/api/v1/projects/[projectId]/thread-status/route.ts
Normal file
34
src/app/api/v1/projects/[projectId]/thread-status/route.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { requireRequestSession } from "@/lib/boss-auth";
|
||||
import { readState } from "@/lib/boss-data";
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
context: { params: Promise<{ projectId: string }> },
|
||||
) {
|
||||
const session = await requireRequestSession(request);
|
||||
if (!session) {
|
||||
return NextResponse.json({ ok: false, message: "UNAUTHORIZED" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { projectId } = await context.params;
|
||||
const state = await readState();
|
||||
const project = state.projects.find((item) => item.id === projectId);
|
||||
if (!project) {
|
||||
return NextResponse.json({ ok: false, message: "PROJECT_NOT_FOUND" }, { status: 404 });
|
||||
}
|
||||
|
||||
const threadStatusDocument =
|
||||
state.threadStatusDocuments.find((item) => item.projectId === projectId) ?? null;
|
||||
const recentProgressEvents = state.threadProgressEvents
|
||||
.filter((item) => item.projectId === projectId)
|
||||
.sort((a, b) => b.createdAt.localeCompare(a.createdAt))
|
||||
.slice(0, 5);
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
projectId,
|
||||
threadStatusDocument,
|
||||
recentProgressEvents,
|
||||
});
|
||||
}
|
||||
@@ -762,7 +762,7 @@ export function ChatBubble({ message }: { message: Message }) {
|
||||
|
||||
export function ProjectHeaderActions({ projectId }: { projectId: string }) {
|
||||
return (
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<Link
|
||||
href={`/conversations/${projectId}/goals`}
|
||||
className="flex h-11 items-center justify-center rounded-2xl bg-[#EAF7F0] text-[14px] font-semibold text-[#215B39]"
|
||||
@@ -781,6 +781,14 @@ export function ProjectHeaderActions({ projectId }: { projectId: string }) {
|
||||
>
|
||||
转发
|
||||
</Link>
|
||||
<Link
|
||||
href={`/api/v1/projects/${projectId}/thread-status`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="flex h-11 items-center justify-center rounded-2xl bg-white text-[14px] font-semibold text-[#111111]"
|
||||
>
|
||||
线程状态
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
121
tests/thread-status-route.test.ts
Normal file
121
tests/thread-status-route.test.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
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 getThreadStatusRoute: (typeof import("../src/app/api/v1/projects/[projectId]/thread-status/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 = "";
|
||||
|
||||
async function setup() {
|
||||
if (runtimeRoot) return;
|
||||
|
||||
runtimeRoot = await mkdtemp(path.join(os.tmpdir(), "boss-thread-status-route-"));
|
||||
process.env.BOSS_RUNTIME_ROOT = runtimeRoot;
|
||||
process.env.BOSS_STATE_FILE = path.join(runtimeRoot, "boss-state.json");
|
||||
|
||||
const [routeModule, dataModule, authModule] = await Promise.all([
|
||||
import("../src/app/api/v1/projects/[projectId]/thread-status/route.ts"),
|
||||
import("../src/lib/boss-data.ts"),
|
||||
import("../src/lib/boss-auth.ts"),
|
||||
]);
|
||||
|
||||
getThreadStatusRoute = routeModule.GET;
|
||||
createAuthSession = dataModule.createAuthSession;
|
||||
readState = dataModule.readState;
|
||||
writeState = dataModule.writeState;
|
||||
AUTH_SESSION_COOKIE = authModule.AUTH_SESSION_COOKIE;
|
||||
}
|
||||
|
||||
test.after(async () => {
|
||||
if (runtimeRoot) {
|
||||
await rm(runtimeRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
async function createAuthedRequest(url: string) {
|
||||
const session = await createAuthSession({
|
||||
account: "17600003315",
|
||||
role: "highest_admin",
|
||||
displayName: "Boss 超级管理员",
|
||||
loginMethod: "password",
|
||||
});
|
||||
|
||||
return new NextRequest(url, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
cookie: `${AUTH_SESSION_COOKIE}=${session.sessionToken}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
test("GET thread-status returns current document and recent progress events", async () => {
|
||||
await setup();
|
||||
|
||||
const state = await readState();
|
||||
const project = state.projects.find((item) => item.id === "master-agent");
|
||||
assert.ok(project, "expected seeded master-agent project");
|
||||
|
||||
state.threadStatusDocuments = [
|
||||
{
|
||||
documentId: "doc-1",
|
||||
projectId: "master-agent",
|
||||
threadId: "thread-master-agent",
|
||||
threadDisplayName: "主 Agent",
|
||||
folderName: "Master",
|
||||
deviceId: "mac-studio",
|
||||
projectGoal: "完成线程状态同步",
|
||||
currentPhase: "增量同步",
|
||||
currentProgress: "已经记录最近进展事件",
|
||||
technicalArchitecture: "Next.js API + Android 原生客户端",
|
||||
currentBlockers: "",
|
||||
recommendedNextStep: "继续补 Android 只读页",
|
||||
keyFiles: ["src/app/api/v1/projects/[projectId]/thread-status/route.ts"],
|
||||
keyCommands: ["npx --yes tsx --test tests/thread-status-route.test.ts"],
|
||||
updatedAt: "2026-04-04T18:00:00+08:00",
|
||||
sourceTaskId: "task-1",
|
||||
sourceKind: "incremental_sync",
|
||||
},
|
||||
];
|
||||
state.threadProgressEvents = Array.from({ length: 7 }, (_, index) => ({
|
||||
eventId: `event-${index}`,
|
||||
projectId: "master-agent",
|
||||
threadId: "thread-master-agent",
|
||||
threadDisplayName: "主 Agent",
|
||||
deviceId: "mac-studio",
|
||||
eventType: "progress_updated",
|
||||
summary: `进展 ${index}`,
|
||||
phase: "增量同步",
|
||||
blockerDelta: "",
|
||||
nextStepDelta: "",
|
||||
createdAt: `2026-04-04T18:0${index}:00+08:00`,
|
||||
sourceTaskId: `task-${index}`,
|
||||
}));
|
||||
await writeState(state);
|
||||
|
||||
const response = await getThreadStatusRoute(
|
||||
await createAuthedRequest("http://127.0.0.1:3000/api/v1/projects/master-agent/thread-status"),
|
||||
{ params: Promise.resolve({ projectId: "master-agent" }) },
|
||||
);
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
const body = (await response.json()) as {
|
||||
ok: boolean;
|
||||
projectId: string;
|
||||
threadStatusDocument: { documentId: string; threadDisplayName: string; projectGoal: string } | null;
|
||||
recentProgressEvents: Array<{ eventId: string; summary: string }>;
|
||||
};
|
||||
assert.equal(body.ok, true);
|
||||
assert.equal(body.projectId, "master-agent");
|
||||
assert.equal(body.threadStatusDocument?.documentId, "doc-1");
|
||||
assert.equal(body.threadStatusDocument?.threadDisplayName, "主 Agent");
|
||||
assert.equal(body.threadStatusDocument?.projectGoal, "完成线程状态同步");
|
||||
assert.equal(body.recentProgressEvents.length, 5);
|
||||
assert.equal(body.recentProgressEvents[0]?.eventId, "event-6");
|
||||
assert.equal(body.recentProgressEvents[4]?.eventId, "event-2");
|
||||
});
|
||||
Reference in New Issue
Block a user