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();
|
||||
|
||||
Reference in New Issue
Block a user