Add thread execution conflict guards to chat flows
This commit is contained in:
@@ -100,6 +100,11 @@ function buildSingleThreadProject(projectId: string) {
|
||||
};
|
||||
}
|
||||
|
||||
function buildProjectFolderKey(project: ReturnType<typeof buildSingleThreadProject>) {
|
||||
const folderRef = (project.threadMeta.codexFolderRef?.trim() || project.threadMeta.folderName.trim()).toLowerCase();
|
||||
return `${project.deviceIds[0]}:${folderRef}`;
|
||||
}
|
||||
|
||||
async function ensureSingleThreadProject() {
|
||||
const state = await readState();
|
||||
const existing = findSingleThreadProject(state);
|
||||
@@ -159,6 +164,131 @@ test("POST /api/v1/projects/[projectId]/messages enqueues a conversation task fo
|
||||
assert.ok(!task?.executionPrompt?.includes("deviceIds:"), "thread prompt should not include device id labels");
|
||||
});
|
||||
|
||||
test("POST /api/v1/projects/[projectId]/messages blocks single-thread sends when the target device prefers gui mode", async () => {
|
||||
await setup();
|
||||
const singleProject = await ensureSingleThreadProject();
|
||||
assert.ok(singleProject, "expected a seeded single-thread project");
|
||||
|
||||
const state = await readState();
|
||||
const targetDevice = state.devices.find((device) => device.id === singleProject.deviceIds[0]);
|
||||
assert.ok(targetDevice, "expected a seeded target device");
|
||||
targetDevice.preferredExecutionMode = "gui";
|
||||
await writeState(state);
|
||||
|
||||
const response = await postMessageRoute(
|
||||
await createAuthedRequest(
|
||||
`http://127.0.0.1:3000/api/v1/projects/${singleProject.id}/messages`,
|
||||
"POST",
|
||||
{ body: "继续推进当前线程" },
|
||||
),
|
||||
{ params: Promise.resolve({ projectId: singleProject.id }) },
|
||||
);
|
||||
assert.equal(response.status, 409);
|
||||
|
||||
const payload = (await response.json()) as {
|
||||
ok: boolean;
|
||||
code?: string;
|
||||
message?: string;
|
||||
executionConflict?: {
|
||||
projectId: string;
|
||||
deviceId: string;
|
||||
preferredExecutionMode: "gui" | "cli";
|
||||
allowPolicy: "forbid" | "allow_once" | "allow_always";
|
||||
conflictState: "none" | "warning" | "blocked";
|
||||
reason: string;
|
||||
actions: string[];
|
||||
};
|
||||
};
|
||||
|
||||
assert.equal(payload.ok, false);
|
||||
assert.equal(payload.code, "THREAD_EXECUTION_CONFLICT");
|
||||
assert.equal(payload.executionConflict?.projectId, singleProject.id);
|
||||
assert.equal(payload.executionConflict?.deviceId, singleProject.deviceIds[0]);
|
||||
assert.equal(payload.executionConflict?.preferredExecutionMode, "gui");
|
||||
assert.equal(payload.executionConflict?.allowPolicy, "forbid");
|
||||
assert.equal(payload.executionConflict?.conflictState, "blocked");
|
||||
assert.equal(payload.executionConflict?.reason, "preferred_gui_mode");
|
||||
assert.deepEqual(payload.executionConflict?.actions, ["forbid", "allow_once", "allow_always"]);
|
||||
|
||||
const nextState = await readState();
|
||||
const updatedProject = nextState.projects.find((project) => project.id === singleProject.id);
|
||||
const blockedMessage = updatedProject?.messages.find((message) => message.body.includes("继续推进当前线程"));
|
||||
assert.equal(blockedMessage, undefined, "blocked send should not append a local chat message");
|
||||
const queuedTask = nextState.masterAgentTasks.find(
|
||||
(item) =>
|
||||
item.taskType === "conversation_reply" &&
|
||||
item.projectId === singleProject.id &&
|
||||
item.requestText === "继续推进当前线程",
|
||||
);
|
||||
assert.equal(queuedTask, undefined, "blocked send should not enqueue a conversation task");
|
||||
});
|
||||
|
||||
test("POST /api/v1/projects/[projectId]/messages blocks single-thread sends when the current project folder is forbidden", async () => {
|
||||
await setup();
|
||||
const singleProject = await ensureSingleThreadProject();
|
||||
assert.ok(singleProject, "expected a seeded single-thread project");
|
||||
|
||||
const state = await readState();
|
||||
const targetDevice = state.devices.find((device) => device.id === singleProject.deviceIds[0]);
|
||||
assert.ok(targetDevice, "expected a seeded target device");
|
||||
targetDevice.preferredExecutionMode = "cli";
|
||||
state.projectExecutionPolicies = [
|
||||
{
|
||||
deviceId: singleProject.deviceIds[0],
|
||||
folderKey: buildProjectFolderKey(singleProject),
|
||||
projectId: singleProject.id,
|
||||
allowPolicy: "forbid",
|
||||
conflictState: "blocked",
|
||||
updatedAt: "2026-04-06T13:20:00.000Z",
|
||||
},
|
||||
];
|
||||
await writeState(state);
|
||||
|
||||
const response = await postMessageRoute(
|
||||
await createAuthedRequest(
|
||||
`http://127.0.0.1:3000/api/v1/projects/${singleProject.id}/messages`,
|
||||
"POST",
|
||||
{ body: "继续同步项目进度" },
|
||||
),
|
||||
{ params: Promise.resolve({ projectId: singleProject.id }) },
|
||||
);
|
||||
assert.equal(response.status, 409);
|
||||
|
||||
const payload = (await response.json()) as {
|
||||
ok: boolean;
|
||||
code?: string;
|
||||
executionConflict?: {
|
||||
projectId: string;
|
||||
folderKey?: string;
|
||||
preferredExecutionMode: "gui" | "cli";
|
||||
allowPolicy: "forbid" | "allow_once" | "allow_always";
|
||||
conflictState: "none" | "warning" | "blocked";
|
||||
reason: string;
|
||||
};
|
||||
};
|
||||
|
||||
assert.equal(payload.ok, false);
|
||||
assert.equal(payload.code, "THREAD_EXECUTION_CONFLICT");
|
||||
assert.equal(payload.executionConflict?.projectId, singleProject.id);
|
||||
assert.equal(payload.executionConflict?.folderKey, buildProjectFolderKey(singleProject));
|
||||
assert.equal(payload.executionConflict?.preferredExecutionMode, "cli");
|
||||
assert.equal(payload.executionConflict?.allowPolicy, "forbid");
|
||||
assert.equal(payload.executionConflict?.conflictState, "blocked");
|
||||
assert.equal(payload.executionConflict?.reason, "project_conflict_forbid");
|
||||
|
||||
const nextState = await readState();
|
||||
const updatedProject = nextState.projects.find((project) => project.id === singleProject.id);
|
||||
const blockedMessage = updatedProject?.messages.find((message) => message.body.includes("继续同步项目进度"));
|
||||
assert.equal(blockedMessage, undefined, "blocked send should not append a local chat message");
|
||||
const queuedTask = nextState.masterAgentTasks.find(
|
||||
(item) =>
|
||||
item.taskType === "conversation_reply" &&
|
||||
item.projectId === singleProject.id &&
|
||||
item.requestText === "继续同步项目进度",
|
||||
);
|
||||
assert.equal(queuedTask, undefined, "blocked send should not enqueue a conversation task");
|
||||
});
|
||||
|
||||
test("POST /api/v1/master-agent/tasks/[taskId]/complete writes the raw thread reply back to the single-thread project", async () => {
|
||||
await setup();
|
||||
const singleProject = await ensureSingleThreadProject();
|
||||
|
||||
51
tests/thread-execution-conflict-ui.test.ts
Normal file
51
tests/thread-execution-conflict-ui.test.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
describeThreadConversationExecutionConflict,
|
||||
labelForThreadConversationExecutionConflictDecision,
|
||||
} from "../src/lib/thread-execution-conflict-ui.ts";
|
||||
|
||||
test("describeThreadConversationExecutionConflict explains preferred gui mode with project-scoped guidance", () => {
|
||||
const description = describeThreadConversationExecutionConflict({
|
||||
projectId: "thread-ui",
|
||||
projectName: "Boss UI 主线程",
|
||||
deviceId: "mac-studio",
|
||||
deviceName: "Mac Studio",
|
||||
folderKey: "mac-studio:boss",
|
||||
preferredExecutionMode: "gui",
|
||||
allowPolicy: "forbid",
|
||||
conflictState: "blocked",
|
||||
reason: "preferred_gui_mode",
|
||||
actions: ["forbid", "allow_once", "allow_always"],
|
||||
});
|
||||
|
||||
assert.equal(description.title, "当前项目默认先走 GUI");
|
||||
assert.match(description.summary, /Mac Studio/);
|
||||
assert.match(description.summary, /只对这个项目/);
|
||||
assert.match(description.summary, /Boss UI 主线程/);
|
||||
});
|
||||
|
||||
test("describeThreadConversationExecutionConflict explains project-level forbid without implying a global lock", () => {
|
||||
const description = describeThreadConversationExecutionConflict({
|
||||
projectId: "thread-ui",
|
||||
projectName: "Boss UI 主线程",
|
||||
deviceId: "mac-studio",
|
||||
deviceName: "Mac Studio",
|
||||
folderKey: "mac-studio:boss",
|
||||
preferredExecutionMode: "cli",
|
||||
allowPolicy: "forbid",
|
||||
conflictState: "blocked",
|
||||
reason: "project_conflict_forbid",
|
||||
actions: ["forbid", "allow_once", "allow_always"],
|
||||
});
|
||||
|
||||
assert.equal(description.title, "当前项目已命中并发保护");
|
||||
assert.match(description.summary, /最近检测到 GUI \/ CLI 同时活动/);
|
||||
assert.match(description.summary, /只影响这个项目/);
|
||||
});
|
||||
|
||||
test("labelForThreadConversationExecutionConflictDecision keeps the three project-scoped actions concise", () => {
|
||||
assert.equal(labelForThreadConversationExecutionConflictDecision("forbid"), "禁止");
|
||||
assert.equal(labelForThreadConversationExecutionConflictDecision("allow_once"), "允许本次");
|
||||
assert.equal(labelForThreadConversationExecutionConflictDecision("allow_always"), "永久放行");
|
||||
});
|
||||
Reference in New Issue
Block a user