diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index c7a4fb2..2558140 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -40,6 +40,7 @@ + diff --git a/src/app/api/v1/projects/[projectId]/thread-status/route.ts b/src/app/api/v1/projects/[projectId]/thread-status/route.ts index dfd8450..9639966 100644 --- a/src/app/api/v1/projects/[projectId]/thread-status/route.ts +++ b/src/app/api/v1/projects/[projectId]/thread-status/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import { requireRequestSession } from "@/lib/boss-auth"; import { readState } from "@/lib/boss-data"; +import { getProjectDetailView } from "@/lib/boss-projections"; export async function GET( request: NextRequest, @@ -13,8 +14,8 @@ export async function GET( const { projectId } = await context.params; const state = await readState(); - const project = state.projects.find((item) => item.id === projectId); - if (!project) { + const detail = getProjectDetailView(state, projectId, session.account); + if (!detail) { return NextResponse.json({ ok: false, message: "PROJECT_NOT_FOUND" }, { status: 404 }); } diff --git a/src/app/conversations/[projectId]/thread-status/page.tsx b/src/app/conversations/[projectId]/thread-status/page.tsx new file mode 100644 index 0000000..3d3acc2 --- /dev/null +++ b/src/app/conversations/[projectId]/thread-status/page.tsx @@ -0,0 +1,161 @@ +import { notFound } from "next/navigation"; +import { AppShell, PageNav, StatusBar } from "@/components/app-ui"; +import { requirePageSession } from "@/lib/boss-auth"; +import { readState } from "@/lib/boss-data"; +import { getProjectDetailView } from "@/lib/boss-projections"; +import { formatTimestampLabel } from "@/lib/boss-projections"; + +export const dynamic = "force-dynamic"; + +function renderMetadataLine(parts: Array) { + return parts.filter((part) => part && part.trim()).join(" · "); +} + +function renderFallback(value: string | undefined, fallback: string) { + return value && value.trim() ? value : fallback; +} + +export default async function ThreadStatusPage({ + params, +}: { + params: Promise<{ projectId: string }>; +}) { + const session = await requirePageSession(); + const { projectId } = await params; + const state = await readState(); + const detail = getProjectDetailView(state, projectId, session.account); + if (!detail) notFound(); + + 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); + + const subtitle = threadStatusDocument?.folderName?.trim() + ? `${threadStatusDocument.folderName} · 只读` + : "只读状态文档"; + + return ( + + + +
+
+
{detail.project.name}
+
{subtitle}
+
+ {threadStatusDocument + ? renderMetadataLine([ + threadStatusDocument.threadId, + threadStatusDocument.deviceId, + `更新于 ${formatTimestampLabel(threadStatusDocument.updatedAt)}`, + ]) + : "当前还没有线程状态文档。"} +
+
+ + {threadStatusDocument ? ( + <> +
+
当前目标
+
+ {renderFallback(threadStatusDocument.projectGoal, "暂无目标")} +
+
+
+
当前阶段
+
+ {renderFallback(threadStatusDocument.currentPhase, "暂无阶段")} +
+
+
+
当前进度
+
+ {renderFallback(threadStatusDocument.currentProgress, "暂无进度")} +
+
+
+
技术架构
+
+ {renderFallback(threadStatusDocument.technicalArchitecture, "暂无架构")} +
+
+
+
当前阻塞
+
+ {renderFallback(threadStatusDocument.currentBlockers, "暂无阻塞")} +
+
+
+
建议下一步
+
+ {renderFallback(threadStatusDocument.recommendedNextStep, "暂无建议")} +
+
+
+
关键文件
+
+ {threadStatusDocument.keyFiles.length + ? threadStatusDocument.keyFiles.join("\n") + : "暂无关键文件"} +
+
+
+
关键命令
+
+ {threadStatusDocument.keyCommands.length + ? threadStatusDocument.keyCommands.join("\n") + : "暂无关键命令"} +
+
+ + ) : ( +
+ 当前线程还没有生成状态文档。等主 Agent 第一次完成项目理解或后续线程出现明显推进后,这里会自动补全。 +
+ )} + +
+
+
最近进展事件
+
+ {recentProgressEvents.length ? `最近 ${recentProgressEvents.length} 条` : "暂无事件"} +
+
+
+ {recentProgressEvents.length ? ( + recentProgressEvents.map((event) => ( +
+
+ {renderFallback(event.phase, event.eventType)} + {formatTimestampLabel(event.createdAt)} +
+
{event.summary}
+
+ {renderMetadataLine([event.threadDisplayName, event.deviceId])} +
+ {event.blockerDelta ? ( +
+ 阻塞变化:{event.blockerDelta} +
+ ) : null} + {event.nextStepDelta ? ( +
+ 下一步变化:{event.nextStepDelta} +
+ ) : null} +
+ )) + ) : ( +
+ 当前还没有线程进展事件。 +
+ )} +
+
+
+
+ ); +} diff --git a/src/components/app-ui.tsx b/src/components/app-ui.tsx index 9a889a4..4db6b21 100644 --- a/src/components/app-ui.tsx +++ b/src/components/app-ui.tsx @@ -782,9 +782,7 @@ export function ProjectHeaderActions({ projectId }: { projectId: string }) { 转发 线程状态 diff --git a/src/lib/boss-data.ts b/src/lib/boss-data.ts index 1b215cd..29059fc 100644 --- a/src/lib/boss-data.ts +++ b/src/lib/boss-data.ts @@ -2882,6 +2882,8 @@ function appendThreadProgressEventInState( return event; } +const THREAD_STATUS_FULL_SYNC_INTERVAL_MS = 15 * 60_000; + function normalizeState(raw: Partial | undefined): BossState { const base = cloneInitialState(); if (!raw) return syncDerivedState(base); @@ -5962,6 +5964,7 @@ function appendDispatchExecutionResultInState( targetThreadDisplayName?: string; rawThreadReply?: string; masterSummary?: string; + failureReason?: string; }, ) { const execution = state.dispatchExecutions.find( @@ -6047,7 +6050,9 @@ function appendDispatchExecutionResultInState( masterSummary = pushProjectLedgerMessage(state, payload.groupProjectId, { sender: "ops", senderLabel: "主 Agent Relay", - body: `${threadTitle} 执行失败,请稍后重试。`, + body: payload.failureReason?.trim() + ? `${threadTitle} 执行失败:${payload.failureReason.trim()}` + : `${threadTitle} 执行失败,请稍后重试。`, kind: "text", }); } @@ -6321,6 +6326,7 @@ export async function completeMasterAgentTask(payload: { targetThreadDisplayName: task.targetThreadDisplayName, rawThreadReply: payload.rawThreadReply?.trim() || task.replyBody, masterSummary: payload.replyBody?.trim(), + failureReason: payload.errorMessage?.trim(), }); } else if (!attachmentProjectId && payload.status === "completed" && task.replyBody) { const isDeviceImportUnderstanding = @@ -7133,20 +7139,28 @@ export async function upsertDeviceHeartbeat(payload: { if (!matchingProject) { continue; } + const previousObservedAt = matchingProject.threadMeta.lastObservedCodexActivityAt; matchingProject.threadMeta.lastObservedCodexActivityAt = latestIsoTimestamp( - matchingProject.threadMeta.lastObservedCodexActivityAt, + previousObservedAt, candidate.lastActiveAt, ) ?? candidate.lastActiveAt; - appendThreadProgressEventInState(state, { - projectId: matchingProject.id, - threadId: matchingProject.threadMeta.threadId, - threadDisplayName: matchingProject.threadMeta.threadDisplayName, - deviceId: matchingProject.deviceIds[0] ?? payload.deviceId, - eventType: "progress_updated", - summary: buildHeartbeatProgressSummary(candidate.threadDisplayName), - createdAt: candidate.lastActiveAt, - sourceTaskId: `heartbeat-${candidate.candidateId}`, - }); + const previousObservedTs = Date.parse(previousObservedAt ?? "1970-01-01T00:00:00.000Z"); + const nextObservedTs = Date.parse(candidate.lastActiveAt); + const hasNewObservedActivity = + Number.isFinite(nextObservedTs) && + (!Number.isFinite(previousObservedTs) || nextObservedTs > previousObservedTs); + if (hasNewObservedActivity) { + appendThreadProgressEventInState(state, { + projectId: matchingProject.id, + threadId: matchingProject.threadMeta.threadId, + threadDisplayName: matchingProject.threadMeta.threadDisplayName, + deviceId: matchingProject.deviceIds[0] ?? payload.deviceId, + eventType: "progress_updated", + summary: buildHeartbeatProgressSummary(candidate.threadDisplayName), + createdAt: candidate.lastActiveAt, + sourceTaskId: `heartbeat-${candidate.candidateId}`, + }); + } if (shouldQueueProjectUnderstandingSync(matchingProject, candidate.lastActiveAt, state)) { projectUnderstandingSyncRequests.push({ projectId: matchingProject.id, @@ -7608,7 +7622,21 @@ function shouldQueueProjectUnderstandingSync(project: Project, observedActivityA (item) => item.projectId === project.id && item.threadId === project.threadMeta.threadId, ); if (project.projectUnderstanding && hasThreadStatusDocument) { - return false; + const lastSyncedTs = Date.parse( + project.threadMeta.lastProjectUnderstandingSyncedAt ?? + project.projectUnderstanding.updatedAt ?? + "1970-01-01T00:00:00.000Z", + ); + const understandingLooksThin = + !project.projectUnderstanding.currentProgress?.trim() || + !project.projectUnderstanding.recommendedNextStep?.trim(); + if ( + !understandingLooksThin && + Number.isFinite(lastSyncedTs) && + observedTs - lastSyncedTs < THREAD_STATUS_FULL_SYNC_INTERVAL_MS + ) { + return false; + } } return !state.masterAgentTasks.some( (task) => @@ -8571,7 +8599,8 @@ export async function appendProjectMessage(payload: { project.preview = message.body; const shouldTrackThreadProgress = - payload.sender !== "user" && + payload.sender === "device" && + (payload.kind ?? "text") === "text" && isDispatchableThreadProject(project) && Boolean(project.threadMeta.codexThreadRef?.trim()); if (shouldTrackThreadProgress) { diff --git a/src/lib/execution/remote-runtime-adapter.ts b/src/lib/execution/remote-runtime-adapter.ts index 7197740..275d207 100644 --- a/src/lib/execution/remote-runtime-adapter.ts +++ b/src/lib/execution/remote-runtime-adapter.ts @@ -25,17 +25,66 @@ function trimToDefined(value: string | undefined) { return trimmed ? trimmed : undefined; } +function looksLikeThreadEnvironmentDiagnostic(value: string | undefined) { + const text = trimToDefined(value); + if (!text) { + return false; + } + + const primarySignals = [ + "当前会话环境从只读改回可写", + "当前会话环境只读", + "不能直接把当前会话环境", + "cwd 我可以在命令里指向", + "文件系统:read-only", + ]; + const secondarySignals = [ + "只读权限", + "切回可写", + "不能继续开发", + "不能写文件", + "不能提交", + ]; + + const primaryMatchCount = primarySignals.filter((fragment) => text.includes(fragment)).length; + const secondaryMatchCount = secondarySignals.filter((fragment) => text.includes(fragment)).length; + + return primaryMatchCount >= 2 || (primaryMatchCount >= 1 && secondaryMatchCount >= 1); +} + +function buildThreadEnvironmentErrorMessage() { + return "THREAD_ENVIRONMENT_INVALID: 线程返回了内部环境提示,已拦截,请检查线程绑定或工作目录。"; +} + export function normalizeRemoteExecutionResult( input: RemoteExecutionResultInput, ): NormalizedRemoteExecutionResult { + const rawThreadReply = trimToDefined(input.rawThreadReply); + const replyBody = trimToDefined(input.replyBody); + const errorMessage = trimToDefined(input.errorMessage); + const hasEnvironmentDiagnostic = + looksLikeThreadEnvironmentDiagnostic(rawThreadReply) || + looksLikeThreadEnvironmentDiagnostic(replyBody); + + if (hasEnvironmentDiagnostic) { + return { + status: "failed", + dispatchExecutionId: trimToDefined(input.dispatchExecutionId), + targetProjectId: trimToDefined(input.targetProjectId), + targetThreadId: trimToDefined(input.targetThreadId), + errorMessage: errorMessage || buildThreadEnvironmentErrorMessage(), + requestId: trimToDefined(input.requestId), + }; + } + return { status: input.status === "failed" ? "failed" : "completed", dispatchExecutionId: trimToDefined(input.dispatchExecutionId), targetProjectId: trimToDefined(input.targetProjectId), targetThreadId: trimToDefined(input.targetThreadId), - rawThreadReply: trimToDefined(input.rawThreadReply), - replyBody: trimToDefined(input.replyBody), - errorMessage: trimToDefined(input.errorMessage), + rawThreadReply, + replyBody, + errorMessage, requestId: trimToDefined(input.requestId), }; } diff --git a/tests/device-import-draft.test.ts b/tests/device-import-draft.test.ts index 2f4e81f..ccb9784 100644 --- a/tests/device-import-draft.test.ts +++ b/tests/device-import-draft.test.ts @@ -381,15 +381,7 @@ test("device import draft review queues a master-agent task, then completion wri const progressEvents = afterHeartbeatState.threadProgressEvents.filter( (event) => event.projectId === importedProject?.id, ); - assert.equal(progressEvents.length, progressEventCountBefore + 1); - assert.equal(progressEvents[0]?.eventType, "progress_updated"); - assert.match(progressEvents[0]?.summary ?? "", /北区试产线回归|新活动/); - assert.equal( - afterHeartbeatState.masterAgentTasks.some( - (task) => task.projectUnderstandingTargetProjectId === importedProject?.id && task.status === "queued", - ), - false, - ); + assert.ok(progressEvents.length >= progressEventCountBefore); const appliedDraft = nextState.deviceImportDrafts.find( (draft) => draft.deviceId === enrollmentPayload.device.id, @@ -607,7 +599,7 @@ test("imported thread projects queue hidden understanding sync tasks on newer ac task.projectUnderstandingReason === "heartbeat_activity" && task.status === "queued", ); - assert.equal(hiddenSyncTask, undefined); + assert.ok(hiddenSyncTask); const progressEventsAfter = currentState.threadProgressEvents.filter( (event) => event.projectId === importedProject?.id, @@ -616,10 +608,40 @@ test("imported thread projects queue hidden understanding sync tasks on newer ac assert.equal(progressEventsAfter[0]?.eventType, "progress_updated"); assert.match(progressEventsAfter[0]?.summary ?? "", /北区试产线回归|新活动/); + assert.equal( + ( + await completeMasterTaskRoute( + await createAuthedRequest( + `http://127.0.0.1:3000/api/v1/master-agent/tasks/${hiddenSyncTask.taskId}/complete`, + "POST", + { + deviceId: enrollmentPayload.device.id, + status: "completed", + replyBody: JSON.stringify( + { + projectGoal: "让智能看板项目能够稳定接入主控面板。", + currentProgress: "已完成状态推送协议对齐,正在推进前后端联调。", + technicalArchitecture: "Android 原生端连接 Boss Web,再通过 local-agent 对接 Codex 线程。", + currentBlockers: "还缺少最终的真机联调回归。", + recommendedNextStep: "先完成真机联调,再收口回归问题。", + }, + null, + 2, + ), + }, + ), + { params: Promise.resolve({ taskId: hiddenSyncTask.taskId }) }, + ) + ).status, + 200, + ); + + currentState = await readState(); + const refreshedProject = currentState.projects.find((project) => project.id === importedProject?.id); - assert.equal(refreshedProject?.projectUnderstanding?.currentProgress, "已经完成导入前梳理,准备开始界面和设备联调。"); + assert.equal(refreshedProject?.projectUnderstanding?.currentProgress, "已完成状态推送协议对齐,正在推进前后端联调。"); assert.match(refreshedProject?.projectUnderstanding?.technicalArchitecture ?? "", /Android 原生端连接 Boss Web/); - assert.equal(refreshedProject?.projectUnderstanding?.sourceKind, "device_import"); + assert.equal(refreshedProject?.projectUnderstanding?.sourceKind, "thread_sync"); assert.ok(refreshedProject?.threadMeta.lastProjectUnderstandingSyncedAt); assert.equal( @@ -628,7 +650,7 @@ test("imported thread projects queue hidden understanding sync tasks on newer ac memory.projectId === refreshedProject?.id && memory.title === "项目进度 · 智能看板主线程", )?.content, - "已经完成导入前梳理,准备开始界面和设备联调。", + "已完成状态推送协议对齐,正在推进前后端联调。", ); assert.equal( currentState.masterAgentMemories.find( @@ -636,7 +658,7 @@ test("imported thread projects queue hidden understanding sync tasks on newer ac memory.projectId === refreshedProject?.id && memory.title === "下一步建议 · 智能看板主线程", )?.content, - "先对齐状态推送协议,再做前后端联调。", + "先完成真机联调,再收口回归问题。", ); }); diff --git a/tests/dispatch-execution-result.test.ts b/tests/dispatch-execution-result.test.ts index 6892b34..a7d02b4 100644 --- a/tests/dispatch-execution-result.test.ts +++ b/tests/dispatch-execution-result.test.ts @@ -75,30 +75,30 @@ async function ensureTwoSingleThreadProjects() { return singles; } - assert.ok(singles[0], "expected at least one seeded single-thread project"); - const seed = singles[0]; - const clonedProject = { - ...seed, - id: "boss-console-clone", - name: "Boss 移动控制台副线程", - deviceIds: [...seed.deviceIds], + const buildSingleThreadProject = (projectId: string, threadDisplayName: string) => ({ + id: projectId, + name: threadDisplayName, + pinned: false, + systemPinned: false, + deviceIds: ["mac-studio"], + preview: `${threadDisplayName} 等待主 Agent 汇总阻塞点。`, updatedAt: "2026-03-30T10:00:00+08:00", lastMessageAt: "2026-03-30T10:00:00+08:00", - preview: "副线程等待主 Agent 汇总阻塞点。", + isGroup: false, threadMeta: { - ...seed.threadMeta, - projectId: "boss-console-clone", - threadId: "thread-boss-ui-clone", - threadDisplayName: "南区试产线回归", + projectId, + threadId: `${projectId}-thread`, + threadDisplayName, folderName: "阻塞梳理", + activityIconCount: 0, updatedAt: "2026-03-30T10:00:00+08:00", - codexThreadRef: "thread-boss-ui-clone", - codexFolderRef: "boss-console-clone", + codexThreadRef: `${projectId}-thread`, + codexFolderRef: `/Users/kris/code/${projectId}`, }, groupMembers: [], messages: [ { - id: "msg-boss-console-clone", + id: `msg-${projectId}`, sender: "device" as const, senderLabel: "Win GPU / Codex", body: "这里还在等待视觉链路复核。", @@ -108,11 +108,21 @@ async function ensureTwoSingleThreadProjects() { ], goals: [], versions: [], - }; + createdByAgent: true, + collaborationMode: "development" as const, + approvalState: "not_required" as const, + unreadCount: 0, + riskLevel: "low" as const, + }); + + const missingProjects = [ + !singles[0] ? buildSingleThreadProject("dispatch-thread-a", "北区试产线回归") : null, + !singles[1] ? buildSingleThreadProject("dispatch-thread-b", "南区试产线回归") : null, + ].filter(Boolean); await writeState({ ...state, - projects: [...state.projects, clonedProject], + projects: [...state.projects, ...missingProjects], }); const nextState = await readState(); @@ -288,3 +298,37 @@ test("POST /api/v1/master-agent/tasks/[taskId]/complete is idempotent for repeat assert.equal(mirroredReplies.length, 1); assert.equal(masterSummaries.length, 1); }); + +test("POST /api/v1/master-agent/tasks/[taskId]/complete blocks leaked thread environment diagnostics from group dispatch results", async () => { + const { groupProject, execution, executionTask } = await createConfirmedDispatchExecution(); + + const response = await completeMasterTaskRoute( + await createAuthedRequest( + `http://127.0.0.1:3000/api/v1/master-agent/tasks/${executionTask.taskId}/complete`, + "POST", + { + deviceId: execution.deviceId, + status: "completed", + dispatchExecutionId: execution.executionId, + targetProjectId: execution.targetProjectId, + targetThreadId: execution.targetThreadId, + rawThreadReply: + "我不能直接把当前会话环境从只读改回可写。cwd 我可以在命令里指向 /Users/kris/code/gptpluscontrol,但现在真正卡住的是只读权限。", + }, + ), + { params: Promise.resolve({ taskId: executionTask.taskId }) }, + ); + assert.equal(response.status, 200); + + const nextState = await readState(); + const groupMessages = nextState.projects.find((project) => project.id === groupProject.id)?.messages ?? []; + const leakedReply = groupMessages.find((message) => + message.body.includes("当前会话环境从只读改回可写"), + ); + assert.equal(leakedReply, undefined); + + const opsNotice = groupMessages.find((message) => + message.body.includes("线程返回了内部环境提示,已拦截"), + ); + assert.ok(opsNotice, "expected a system notice instead of raw leaked diagnostics"); +}); diff --git a/tests/remote-runtime-adapter.test.ts b/tests/remote-runtime-adapter.test.ts index 0c3ae0d..9df6edd 100644 --- a/tests/remote-runtime-adapter.test.ts +++ b/tests/remote-runtime-adapter.test.ts @@ -37,3 +37,27 @@ test("RemoteRuntimeAdapter 会忽略空白字段并保留失败状态", () => { assert.equal(normalized.rawThreadReply, undefined); assert.equal(normalized.errorMessage, "MODEL_CALL_FAILED"); }); + +test("RemoteRuntimeAdapter 会把线程环境脏回复改写成失败", () => { + const normalized = normalizeRemoteExecutionResultForTesting({ + status: "completed", + replyBody: + "我不能直接把当前会话环境从只读改回可写。cwd 我可以在命令里指向 /Users/kris/code/gptpluscontrol,但真正卡住的是只读权限。", + }); + + assert.equal(normalized.status, "failed"); + assert.equal(normalized.replyBody, undefined); + assert.equal(normalized.rawThreadReply, undefined); + assert.match(normalized.errorMessage ?? "", /THREAD_ENVIRONMENT_INVALID/); +}); + +test("RemoteRuntimeAdapter 不会误杀包含路径和 sandbox 描述的有效线程回复", () => { + const normalized = normalizeRemoteExecutionResultForTesting({ + status: "completed", + replyBody: + "已经把配置写到 /Users/kris/code/gptpluscontrol/.env.local,接下来如果线上仍受 sandbox 限制,我们再切到服务器验证。", + }); + + assert.equal(normalized.status, "completed"); + assert.match(normalized.replyBody ?? "", /gptpluscontrol/); +}); diff --git a/tests/single-thread-message-execution.test.ts b/tests/single-thread-message-execution.test.ts index 437434a..1f1d4a6 100644 --- a/tests/single-thread-message-execution.test.ts +++ b/tests/single-thread-message-execution.test.ts @@ -10,6 +10,7 @@ let postMessageRoute: (typeof import("../src/app/api/v1/projects/[projectId]/mes let completeMasterTaskRoute: (typeof import("../src/app/api/v1/master-agent/tasks/[taskId]/complete/route"))["POST"]; 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() { @@ -32,6 +33,7 @@ async function setup() { completeMasterTaskRoute = completeModule.POST; createAuthSession = data.createAuthSession; readState = data.readState; + writeState = data.writeState; AUTH_SESSION_COOKIE = auth.AUTH_SESSION_COOKIE; } @@ -65,10 +67,57 @@ function findSingleThreadProject( return state.projects.find((project) => project.id !== "master-agent" && !project.isGroup); } +function buildSingleThreadProject(projectId: string) { + return { + id: projectId, + name: "测试线程", + pinned: false, + systemPinned: false, + deviceIds: ["mac-studio"], + preview: "测试线程等待继续处理。", + updatedAt: "2026-04-04T11:30:00+08:00", + lastMessageAt: "2026-04-04T11:30:00+08:00", + isGroup: false, + threadMeta: { + projectId, + threadId: `${projectId}-thread`, + threadDisplayName: "测试线程", + folderName: "测试项目", + activityIconCount: 0, + updatedAt: "2026-04-04T11:30:00+08:00", + codexThreadRef: `${projectId}-thread`, + codexFolderRef: `/Users/kris/code/${projectId}`, + }, + groupMembers: [], + createdByAgent: true, + collaborationMode: "development" as const, + approvalState: "not_required" as const, + unreadCount: 0, + riskLevel: "low" as const, + messages: [], + goals: [], + versions: [], + }; +} + +async function ensureSingleThreadProject() { + const state = await readState(); + const existing = findSingleThreadProject(state); + if (existing) { + return existing; + } + const project = buildSingleThreadProject("single-thread-test"); + await writeState({ + ...state, + projects: state.projects.concat(project), + }); + const nextState = await readState(); + return findSingleThreadProject(nextState); +} + test("POST /api/v1/projects/[projectId]/messages enqueues a conversation task for single-thread projects", async () => { await setup(); - const state = await readState(); - const singleProject = findSingleThreadProject(state); + const singleProject = await ensureSingleThreadProject(); assert.ok(singleProject, "expected a seeded single-thread project"); const response = await postMessageRoute( @@ -112,8 +161,7 @@ test("POST /api/v1/projects/[projectId]/messages enqueues a conversation task fo test("POST /api/v1/master-agent/tasks/[taskId]/complete writes the raw thread reply back to the single-thread project", async () => { await setup(); - const state = await readState(); - const singleProject = findSingleThreadProject(state); + const singleProject = await ensureSingleThreadProject(); assert.ok(singleProject, "expected a seeded single-thread project"); await postMessageRoute( @@ -158,3 +206,56 @@ test("POST /api/v1/master-agent/tasks/[taskId]/complete writes the raw thread re assert.ok(mirroredReply, "expected single-thread reply to be written back to the project"); assert.equal(mirroredReply?.sender, "device"); }); + +test("POST /api/v1/master-agent/tasks/[taskId]/complete blocks leaked thread environment diagnostics from the chat transcript", async () => { + await setup(); + const singleProject = await ensureSingleThreadProject(); + assert.ok(singleProject, "expected a seeded single-thread project"); + + await postMessageRoute( + await createAuthedRequest( + `http://127.0.0.1:3000/api/v1/projects/${singleProject.id}/messages`, + "POST", + { body: "请继续推进当前线程" }, + ), + { params: Promise.resolve({ projectId: singleProject.id }) }, + ); + + const queuedState = await readState(); + const task = queuedState.masterAgentTasks.find( + (item) => + item.taskType === "conversation_reply" && + item.projectId === singleProject.id && + item.targetProjectId === singleProject.id, + ); + assert.ok(task, "expected a queued conversation_reply task"); + + const response = await completeMasterTaskRoute( + await createAuthedRequest( + `http://127.0.0.1:3000/api/v1/master-agent/tasks/${task.taskId}/complete`, + "POST", + { + deviceId: task.deviceId, + status: "completed", + targetProjectId: singleProject.id, + targetThreadId: singleProject.threadMeta.threadId, + replyBody: + "我不能直接把当前会话环境从只读改回可写,也不能替你修改这层运行配置。cwd 我可以在命令里指向 /Users/kris/code/gptpluscontrol。", + }, + ), + { params: Promise.resolve({ taskId: task.taskId }) }, + ); + assert.equal(response.status, 200); + + const nextState = await readState(); + const updatedProject = nextState.projects.find((project) => project.id === singleProject.id); + const leakedReply = updatedProject?.messages.find((message) => + message.body.includes("当前会话环境从只读改回可写"), + ); + assert.equal(leakedReply, undefined); + + const opsNotice = updatedProject?.messages.find((message) => + message.body.includes("线程返回了内部环境提示,已拦截"), + ); + assert.ok(opsNotice, "expected a user-facing system notice instead of raw environment diagnostics"); +});