+ );
+}
+
+/** Settings → Task Behavior → Message Queue controls, sharing one save contributor. */
export function MessageQueueSettings() {
const { t } = useTranslation();
const state = useMessageQueueSettingsDraft();
@@ -344,6 +441,11 @@ export function MessageQueueSettings() {
onChange={state.setMergeDraft}
disabled={!state.isAdmin}
/>
+
);
diff --git a/apps/web/e2e/helpers/message-queue-settings.ts b/apps/web/e2e/helpers/message-queue-settings.ts
new file mode 100644
index 0000000000..3e8ebe754b
--- /dev/null
+++ b/apps/web/e2e/helpers/message-queue-settings.ts
@@ -0,0 +1,59 @@
+import type { ApiClient } from "./api-client";
+import type {
+ MessageQueueSettingsPatch,
+ MessageQueueSettingsResponse,
+ MessageQueueSettingsValue,
+} from "../../lib/types/system";
+
+type KandevTest = typeof import("../fixtures/test-base").test;
+
+export const MESSAGE_QUEUE_SETTINGS_PATH = "/api/v1/system/message-queue/settings";
+
+export async function requestMessageQueueSettings(
+ apiClient: ApiClient,
+ method: "GET" | "PATCH",
+ patch?: MessageQueueSettingsPatch,
+): Promise {
+ const response = await apiClient.rawRequest(method, MESSAGE_QUEUE_SETTINGS_PATH, patch);
+ if (!response.ok) {
+ throw new Error(
+ `${method} ${MESSAGE_QUEUE_SETTINGS_PATH} failed (${response.status}): ${await response.text()}`,
+ );
+ }
+ return response.json() as Promise;
+}
+
+export async function restoreMessageQueueSettings(
+ apiClient: ApiClient,
+ baseline: MessageQueueSettingsValue,
+): Promise {
+ const current = await requestMessageQueueSettings(apiClient, "GET");
+ const patch: MessageQueueSettingsPatch = {
+ merge_enabled: baseline.merge_enabled,
+ auto_merge_enabled: baseline.auto_merge_enabled,
+ };
+ if (!current.effective.locked) {
+ patch.max_per_session = baseline.max_per_session;
+ }
+ await requestMessageQueueSettings(apiClient, "PATCH", patch);
+}
+
+/**
+ * Register per-test isolation for specs whose assertions require separate
+ * compatible queue rows. Captures and restores every install-wide queue field
+ * so the helper cannot leak a concurrent capacity or manual-merge baseline.
+ */
+export function registerSeparateQueueRows(test: KandevTest): void {
+ let baseline: MessageQueueSettingsValue | undefined;
+
+ test.beforeEach(async ({ apiClient }) => {
+ baseline = (await requestMessageQueueSettings(apiClient, "GET")).settings;
+ await requestMessageQueueSettings(apiClient, "PATCH", { auto_merge_enabled: false });
+ });
+
+ test.afterEach(async ({ apiClient }) => {
+ if (!baseline) return;
+ await restoreMessageQueueSettings(apiClient, baseline);
+ baseline = undefined;
+ });
+}
diff --git a/apps/web/e2e/tests/chat/agent-message-attribution.spec.ts b/apps/web/e2e/tests/chat/agent-message-attribution.spec.ts
index 25ba8ecf5d..217b952462 100644
--- a/apps/web/e2e/tests/chat/agent-message-attribution.spec.ts
+++ b/apps/web/e2e/tests/chat/agent-message-attribution.spec.ts
@@ -4,6 +4,9 @@ import type { SeedData } from "../../fixtures/test-base";
import type { ApiClient } from "../../helpers/api-client";
import { waitForSessionState } from "../../helpers/session";
import { SessionPage } from "../../pages/session-page";
+import { registerSeparateQueueRows } from "../../helpers/message-queue-settings";
+
+registerSeparateQueueRows(test);
/**
* E2E coverage for the cross-task message attribution feature.
diff --git a/apps/web/e2e/tests/chat/message-queue-reorder.spec.ts b/apps/web/e2e/tests/chat/message-queue-reorder.spec.ts
index 3f4832fde0..527388a3ba 100644
--- a/apps/web/e2e/tests/chat/message-queue-reorder.spec.ts
+++ b/apps/web/e2e/tests/chat/message-queue-reorder.spec.ts
@@ -4,6 +4,9 @@ import type { SeedData } from "../../fixtures/test-base";
import type { ApiClient } from "../../helpers/api-client";
import { typeWhileBusy } from "../../helpers/type-while-busy";
import { SessionPage } from "../../pages/session-page";
+import { registerSeparateQueueRows } from "../../helpers/message-queue-settings";
+
+registerSeparateQueueRows(test);
/** Create a task whose agent finishes its intro turn and becomes idle. */
async function seedIdleTask(
diff --git a/apps/web/e2e/tests/chat/message-queue.spec.ts b/apps/web/e2e/tests/chat/message-queue.spec.ts
index e7fd14ce3a..dd66f39fab 100644
--- a/apps/web/e2e/tests/chat/message-queue.spec.ts
+++ b/apps/web/e2e/tests/chat/message-queue.spec.ts
@@ -5,6 +5,12 @@ import type { ApiClient } from "../../helpers/api-client";
import { typeWhileBusy } from "../../helpers/type-while-busy";
import { SessionPage } from "../../pages/session-page";
import { expectFullQueueScrolls, seedFullQueueTask } from "./message-queue-scroll-helpers";
+import {
+ registerSeparateQueueRows,
+ requestMessageQueueSettings,
+} from "../../helpers/message-queue-settings";
+
+registerSeparateQueueRows(test);
// ---------------------------------------------------------------------------
// Quick Chat queue tests
@@ -229,6 +235,42 @@ test.describe("Task session queue", () => {
await expectFullQueueScrolls(session);
});
+ test("automatic merge compacts compatible user rows and disabled mode keeps them separate", async ({
+ testPage,
+ apiClient,
+ seedData,
+ }) => {
+ const task = await apiClient.createTask(seedData.workspaceId, "Automatic queue merge", {
+ workflow_id: seedData.workflowId,
+ workflow_step_id: seedData.startStepId,
+ repository_ids: [seedData.repositoryId],
+ });
+ const { session_id: sessionId } = await apiClient.seedTaskSession(task.id, {
+ state: "RUNNING",
+ agentProfileId: seedData.agentProfileId,
+ });
+ await requestMessageQueueSettings(apiClient, "PATCH", { auto_merge_enabled: true });
+
+ await apiClient.queueMessage(task.id, sessionId, "automatic first");
+ await apiClient.queueMessage(task.id, sessionId, "automatic second");
+ await testPage.goto(`/t/${task.id}`);
+ const session = new SessionPage(testPage);
+ await session.waitForLoad();
+ await openQueuePanel(session.activeChat());
+ const panel = session.activeChat().getByTestId("queued-ghost-list");
+ const entries = panel.getByTestId("queue-entry-text");
+ await expect(entries).toHaveCount(1);
+ await expect(entries.first()).toContainText("automatic first");
+ await expect(entries.first()).toContainText("automatic second");
+
+ await requestMessageQueueSettings(apiClient, "PATCH", { auto_merge_enabled: false });
+ await apiClient.queueMessage(task.id, sessionId, "separate third");
+ await apiClient.queueMessage(task.id, sessionId, "separate fourth");
+ await expect(entries).toHaveCount(3, { timeout: 10_000 });
+ await expect(entries.nth(1)).toHaveText("separate third");
+ await expect(entries.nth(2)).toHaveText("separate fourth");
+ });
+
test("queue message via submit button on task session page", async ({
testPage,
apiClient,
@@ -287,10 +329,12 @@ test.describe("Task session queue", () => {
);
await session.sendMessage("/slow 30s");
await expect(session.agentStatus()).toBeVisible({ timeout: 15_000 });
+ await expect(session.chat.getByText("Running slow response (30s total)...")).toBeVisible({
+ timeout: 15_000,
+ });
const taskID = new URL(testPage.url()).pathname.split("/").pop();
if (!taskID) throw new Error("task URL did not contain a task ID");
const workflowStepBefore = (await apiClient.getTask(taskID)).workflow_step_id;
- await testPage.waitForTimeout(500);
const editor = testPage.locator(".tiptap.ProseMirror").first();
await queueMessagesWhileBusy(testPage, editor, [
diff --git a/apps/web/e2e/tests/chat/mid-turn-steering.spec.ts b/apps/web/e2e/tests/chat/mid-turn-steering.spec.ts
index 74b1f797e7..177e3a0231 100644
--- a/apps/web/e2e/tests/chat/mid-turn-steering.spec.ts
+++ b/apps/web/e2e/tests/chat/mid-turn-steering.spec.ts
@@ -5,6 +5,9 @@ import type { ApiClient } from "../../helpers/api-client";
import { waitForActiveSessionForegroundActivity } from "../../helpers/session-store";
import { typeWhileBusy } from "../../helpers/type-while-busy";
import { SessionPage } from "../../pages/session-page";
+import { registerSeparateQueueRows } from "../../helpers/message-queue-settings";
+
+registerSeparateQueueRows(test);
// End-to-end coverage for the mid-turn steering composer contract
// (docs/specs/platform/mid-turn-steering.md). CONTRIBUTING.md requires Playwright
diff --git a/apps/web/e2e/tests/chat/mobile-message-queue-management.spec.ts b/apps/web/e2e/tests/chat/mobile-message-queue-management.spec.ts
index 4918b0ce5e..dff89e5f74 100644
--- a/apps/web/e2e/tests/chat/mobile-message-queue-management.spec.ts
+++ b/apps/web/e2e/tests/chat/mobile-message-queue-management.spec.ts
@@ -5,6 +5,9 @@ import type { ApiClient } from "../../helpers/api-client";
import { typeWhileBusy } from "../../helpers/type-while-busy";
import { SessionPage } from "../../pages/session-page";
import { expectFullQueueScrolls, seedFullQueueTask } from "./message-queue-scroll-helpers";
+import { registerSeparateQueueRows } from "../../helpers/message-queue-settings";
+
+registerSeparateQueueRows(test);
async function expectTouchTarget(locator: Locator): Promise {
await expect(locator).toBeVisible();
diff --git a/apps/web/e2e/tests/chat/mobile-message-queue-reorder.spec.ts b/apps/web/e2e/tests/chat/mobile-message-queue-reorder.spec.ts
index 8ae65f9633..d8eb2b184a 100644
--- a/apps/web/e2e/tests/chat/mobile-message-queue-reorder.spec.ts
+++ b/apps/web/e2e/tests/chat/mobile-message-queue-reorder.spec.ts
@@ -4,6 +4,9 @@ import type { SeedData } from "../../fixtures/test-base";
import type { ApiClient } from "../../helpers/api-client";
import { typeWhileBusy } from "../../helpers/type-while-busy";
import { SessionPage } from "../../pages/session-page";
+import { registerSeparateQueueRows } from "../../helpers/message-queue-settings";
+
+registerSeparateQueueRows(test);
async function expectTouchTarget(locator: Locator): Promise {
await expect(locator).toBeVisible();
diff --git a/apps/web/e2e/tests/system/message-queue-settings.spec.ts b/apps/web/e2e/tests/system/message-queue-settings.spec.ts
index d5416ebd72..4822aba4d3 100644
--- a/apps/web/e2e/tests/system/message-queue-settings.spec.ts
+++ b/apps/web/e2e/tests/system/message-queue-settings.spec.ts
@@ -1,50 +1,32 @@
import { test, expect } from "../../fixtures/test-base";
-import type { ApiClient } from "../../helpers/api-client";
import type {
- MessageQueueSettingsPatch,
MessageQueueSettingsResponse,
+ MessageQueueSettingsValue,
} from "../../../lib/types/system";
+import {
+ MESSAGE_QUEUE_SETTINGS_PATH,
+ requestMessageQueueSettings,
+ restoreMessageQueueSettings,
+} from "../../helpers/message-queue-settings";
-const SETTINGS_PATH = "/api/v1/system/message-queue/settings";
-
-async function requestSettings(
- apiClient: ApiClient,
- method: "GET" | "PATCH",
- patch?: MessageQueueSettingsPatch,
-): Promise {
- const response = await apiClient.rawRequest(method, SETTINGS_PATH, patch);
- if (!response.ok) {
- throw new Error(
- `${method} ${SETTINGS_PATH} failed (${response.status}): ${await response.text()}`,
- );
- }
- return response.json() as Promise;
-}
-
-test.describe.serial("Message Queue general settings", () => {
- let baseline: number | undefined;
- let mergeBaseline: boolean | undefined;
+test.describe.serial("Message Queue task behavior settings", () => {
+ let baseline: MessageQueueSettingsValue | undefined;
test.beforeEach(async ({ apiClient }) => {
- const current = (await requestSettings(apiClient, "GET")).settings;
- baseline = current.max_per_session;
- mergeBaseline = current.merge_enabled;
+ baseline = (await requestMessageQueueSettings(apiClient, "GET")).settings;
});
test.afterEach(async ({ apiClient }) => {
- if (baseline === undefined || mergeBaseline === undefined) return;
- await requestSettings(apiClient, "PATCH", {
- max_per_session: baseline,
- merge_enabled: mergeBaseline,
- });
+ if (!baseline) return;
+ await restoreMessageQueueSettings(apiClient, baseline);
baseline = undefined;
- mergeBaseline = undefined;
});
test("admin navigates to Task behavior, saves, and reloads the live setting", async ({
testPage,
}) => {
- const updated = baseline === 17 ? 18 : 17;
+ if (!baseline) throw new Error("message queue settings baseline was not captured");
+ const updated = baseline.max_per_session === 17 ? 18 : 17;
await testPage.goto("/settings/preferences/appearance");
const settingsNav = testPage.getByTestId("app-sidebar-settings-mode");
await settingsNav.getByRole("link", { name: /^Task Behavior/ }).click();
@@ -68,14 +50,14 @@ test.describe.serial("Message Queue general settings", () => {
);
const input = testPage.getByTestId("message-queue-max-per-session");
- await expect(input).toHaveValue(String(baseline));
+ await expect(input).toHaveValue(String(baseline.max_per_session));
await input.fill(String(updated));
await expect(testPage.getByTestId("settings-floating-save")).toBeVisible();
const saveResponse = testPage.waitForResponse(
(response) =>
response.request().method() === "PATCH" &&
- new URL(response.url()).pathname === SETTINGS_PATH,
+ new URL(response.url()).pathname === MESSAGE_QUEUE_SETTINGS_PATH,
);
await testPage.getByRole("button", { name: "Save changes" }).click();
expect((await saveResponse).status()).toBe(200);
@@ -90,10 +72,11 @@ test.describe.serial("Message Queue general settings", () => {
test("admin toggles queued message merging without touching the session limit", async ({
testPage,
}) => {
+ if (!baseline) throw new Error("message queue settings baseline was not captured");
await testPage.goto("/settings/general/message-queue");
const toggle = testPage.getByTestId("message-queue-merge-enabled");
await expect(toggle).toBeVisible();
- const wasEnabled = mergeBaseline === true;
+ const wasEnabled = baseline.merge_enabled;
await expect(toggle).toHaveAttribute("aria-checked", String(wasEnabled));
await expect(
testPage.getByText(/Only adjacent messages from the same sender can be merged/),
@@ -105,7 +88,7 @@ test.describe.serial("Message Queue general settings", () => {
const saveResponse = testPage.waitForResponse(
(response) =>
response.request().method() === "PATCH" &&
- new URL(response.url()).pathname === SETTINGS_PATH,
+ new URL(response.url()).pathname === MESSAGE_QUEUE_SETTINGS_PATH,
);
await toggle.click();
await expect(testPage.getByTestId("settings-floating-save")).toBeVisible();
@@ -122,18 +105,57 @@ test.describe.serial("Message Queue general settings", () => {
);
});
+ test("automatic merge defaults on and saves off then on without changing other fields", async ({
+ testPage,
+ }) => {
+ if (!baseline) throw new Error("message queue settings baseline was not captured");
+ expect(baseline.auto_merge_enabled).toBe(true);
+ await testPage.goto("/settings/preferences/task-behavior");
+ const toggle = testPage.getByTestId("message-queue-auto-merge-enabled");
+ await expect(toggle).toHaveAttribute("aria-checked", "true");
+
+ for (const expected of [false, true]) {
+ const saveResponse = testPage.waitForResponse(
+ (response) =>
+ response.request().method() === "PATCH" &&
+ new URL(response.url()).pathname === MESSAGE_QUEUE_SETTINGS_PATH,
+ );
+ await toggle.click();
+ await testPage.getByRole("button", { name: "Save changes" }).click();
+ const response = await saveResponse;
+ expect(response.status()).toBe(200);
+ expect(response.request().postDataJSON()).toEqual({ auto_merge_enabled: expected });
+ const saved = (await response.json()) as MessageQueueSettingsResponse;
+ expect(saved.settings).toMatchObject({
+ max_per_session: baseline.max_per_session,
+ merge_enabled: baseline.merge_enabled,
+ auto_merge_enabled: expected,
+ });
+ await testPage.reload();
+ await expect(testPage.getByTestId("message-queue-auto-merge-enabled")).toHaveAttribute(
+ "aria-checked",
+ String(expected),
+ );
+ }
+ });
+
test("environment override is visible and read-only", async ({ testPage }) => {
- await testPage.route(`**${SETTINGS_PATH}`, async (route) => {
+ await testPage.route(`**${MESSAGE_QUEUE_SETTINGS_PATH}`, async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
- settings: { max_per_session: baseline, merge_enabled: true },
+ settings: {
+ max_per_session: baseline?.max_per_session ?? 10,
+ merge_enabled: true,
+ auto_merge_enabled: true,
+ },
effective: {
max_per_session: 41,
source: "environment",
locked: true,
merge_enabled: true,
+ auto_merge_enabled: true,
},
} satisfies MessageQueueSettingsResponse),
});
@@ -145,8 +167,9 @@ test.describe.serial("Message Queue general settings", () => {
await expect(testPage.getByTestId("message-queue-source")).toHaveText("Environment");
await expect(testPage.getByText(/KANDEV_QUEUE_MAX_PER_SESSION/).first()).toBeVisible();
await expect(testPage.getByTestId("settings-floating-save")).toHaveCount(0);
- // The environment lock only governs max_per_session; merging stays editable.
+ // The environment lock only governs max_per_session; both merge settings stay editable.
await expect(testPage.getByTestId("message-queue-merge-enabled")).toBeEnabled();
+ await expect(testPage.getByTestId("message-queue-auto-merge-enabled")).toBeEnabled();
});
test("member can navigate to the setting but cannot edit it", async ({ testPage }) => {
@@ -173,6 +196,7 @@ test.describe.serial("Message Queue general settings", () => {
await expect(testPage.getByTestId("message-queue-max-per-session")).toBeDisabled();
await expect(testPage.getByTestId("message-queue-merge-enabled")).toBeDisabled();
+ await expect(testPage.getByTestId("message-queue-auto-merge-enabled")).toBeDisabled();
await expect(testPage.getByText("Only administrators can change this setting.")).toBeVisible();
await expect(testPage.getByTestId("settings-floating-save")).toHaveCount(0);
});
diff --git a/apps/web/e2e/tests/system/mobile-message-queue-settings.spec.ts b/apps/web/e2e/tests/system/mobile-message-queue-settings.spec.ts
index f43bb9440e..f1adecfc7b 100644
--- a/apps/web/e2e/tests/system/mobile-message-queue-settings.spec.ts
+++ b/apps/web/e2e/tests/system/mobile-message-queue-settings.spec.ts
@@ -1,6 +1,24 @@
import { test, expect } from "../../fixtures/test-base";
import { expectElementsNotToIntersect } from "../../helpers/layout-assertions";
import { MobileKanbanPage } from "../../pages/mobile-kanban-page";
+import type { MessageQueueSettingsValue } from "../../../lib/types/system";
+import {
+ MESSAGE_QUEUE_SETTINGS_PATH,
+ requestMessageQueueSettings,
+ restoreMessageQueueSettings,
+} from "../../helpers/message-queue-settings";
+
+let baseline: MessageQueueSettingsValue | undefined;
+
+test.beforeEach(async ({ apiClient }) => {
+ baseline = (await requestMessageQueueSettings(apiClient, "GET")).settings;
+});
+
+test.afterEach(async ({ apiClient }) => {
+ if (!baseline) return;
+ await restoreMessageQueueSettings(apiClient, baseline);
+ baseline = undefined;
+});
test("mobile navigation reaches the Message Queue section with touch-safe shared settings layout", async ({
testPage,
@@ -38,9 +56,35 @@ test("mobile navigation reaches the Message Queue section with touch-safe shared
);
expect(nestedScrollOwners).toBe(0);
- const current = await input.inputValue();
- await input.fill(current === "23" ? "24" : "23");
+ if (!baseline) throw new Error("message queue settings baseline was not captured");
+ const toggle = testPage.getByTestId("message-queue-auto-merge-enabled");
+ const touchTarget = testPage.getByTestId("message-queue-auto-merge-touch-target");
+ await expect(toggle).toHaveAttribute("aria-checked", String(baseline.auto_merge_enabled));
+ const touchBox = await touchTarget.boundingBox();
+ expect(touchBox).not.toBeNull();
+ expect(touchBox!.width).toBeGreaterThanOrEqual(44);
+ expect(touchBox!.height).toBeGreaterThanOrEqual(44);
+
+ await toggle.tap();
const saveBar = testPage.getByTestId("settings-floating-save");
await expect(saveBar).toBeVisible();
- await expectElementsNotToIntersect(input, saveBar);
+ await expectElementsNotToIntersect(touchTarget, saveBar);
+ await saveBar.getByRole("button", { name: "Reset" }).tap();
+ await expect(toggle).toHaveAttribute("aria-checked", String(baseline.auto_merge_enabled));
+ await expect(saveBar).not.toBeVisible();
+
+ const expected = !baseline.auto_merge_enabled;
+ const saveResponse = testPage.waitForResponse(
+ (response) =>
+ response.request().method() === "PATCH" &&
+ new URL(response.url()).pathname === MESSAGE_QUEUE_SETTINGS_PATH,
+ );
+ await toggle.tap();
+ await saveBar.getByRole("button", { name: "Save changes" }).tap();
+ expect((await saveResponse).request().postDataJSON()).toEqual({ auto_merge_enabled: expected });
+ await testPage.reload();
+ await expect(testPage.getByTestId("message-queue-auto-merge-enabled")).toHaveAttribute(
+ "aria-checked",
+ String(expected),
+ );
});
diff --git a/apps/web/e2e/tests/task/sidebar-queued-count.spec.ts b/apps/web/e2e/tests/task/sidebar-queued-count.spec.ts
index 637387532e..ebe39b9e47 100644
--- a/apps/web/e2e/tests/task/sidebar-queued-count.spec.ts
+++ b/apps/web/e2e/tests/task/sidebar-queued-count.spec.ts
@@ -10,6 +10,9 @@
*/
import { test, expect } from "../../fixtures/test-base";
import { SessionPage } from "../../pages/session-page";
+import { registerSeparateQueueRows } from "../../helpers/message-queue-settings";
+
+registerSeparateQueueRows(test);
const stepOpts = (seedData: { workflowId: string; startStepId: string }) => ({
workflow_id: seedData.workflowId,
diff --git a/apps/web/lib/api/domains/settings-api.test.ts b/apps/web/lib/api/domains/settings-api.test.ts
index 643a704b53..4d00a1bf6e 100644
--- a/apps/web/lib/api/domains/settings-api.test.ts
+++ b/apps/web/lib/api/domains/settings-api.test.ts
@@ -42,8 +42,14 @@ describe("message queue settings api", () => {
it("fetches the current setting without cache", async () => {
fetchSpy.mockResolvedValueOnce(
jsonResponse({
- settings: { max_per_session: 10 },
- effective: { max_per_session: 10, source: "default", locked: false },
+ settings: { max_per_session: 10, merge_enabled: true, auto_merge_enabled: true },
+ effective: {
+ max_per_session: 10,
+ source: "default",
+ locked: false,
+ merge_enabled: true,
+ auto_merge_enabled: true,
+ },
}),
);
@@ -58,8 +64,14 @@ describe("message queue settings api", () => {
it("PATCHes the requested per-session maximum", async () => {
fetchSpy.mockResolvedValueOnce(
jsonResponse({
- settings: { max_per_session: 25 },
- effective: { max_per_session: 25, source: "setting", locked: false },
+ settings: { max_per_session: 25, merge_enabled: true, auto_merge_enabled: true },
+ effective: {
+ max_per_session: 25,
+ source: "setting",
+ locked: false,
+ merge_enabled: true,
+ auto_merge_enabled: true,
+ },
}),
);
@@ -72,6 +84,25 @@ describe("message queue settings api", () => {
expect(lastCall().init?.method).toBe("PATCH");
expect(lastCall().init?.body).toBe(JSON.stringify({ max_per_session: 25 }));
});
+
+ it("PATCHes automatic merge without changing other queue settings", async () => {
+ fetchSpy.mockResolvedValueOnce(
+ jsonResponse({
+ settings: { max_per_session: 10, merge_enabled: true, auto_merge_enabled: false },
+ effective: {
+ max_per_session: 10,
+ source: "default",
+ locked: false,
+ merge_enabled: true,
+ auto_merge_enabled: false,
+ },
+ }),
+ );
+
+ await updateMessageQueueSettings({ auto_merge_enabled: false });
+
+ expect(lastCall().init?.body).toBe(JSON.stringify({ auto_merge_enabled: false }));
+ });
});
describe("sleep inhibition settings api", () => {
diff --git a/apps/web/lib/types/system.ts b/apps/web/lib/types/system.ts
index f554e0f134..3a96cb82b1 100644
--- a/apps/web/lib/types/system.ts
+++ b/apps/web/lib/types/system.ts
@@ -189,6 +189,7 @@ export interface SystemMetricsSettingsResponse {
export interface MessageQueueSettingsValue {
max_per_session: number;
merge_enabled: boolean;
+ auto_merge_enabled: boolean;
}
/** Partial PATCH payload: omitted fields are left unchanged server-side. */
diff --git a/apps/web/src/locales/en/system.json b/apps/web/src/locales/en/system.json
index d5621c8007..562021ba5d 100644
--- a/apps/web/src/locales/en/system.json
+++ b/apps/web/src/locales/en/system.json
@@ -164,6 +164,9 @@
"licensesTitle": "Third-party licenses",
"licensesToggleLabel": "Show license text for {{name}}",
"messageQueueAdminOnly": "Only administrators can change this setting.",
+ "messageQueueAutoMergeDescription": "Combines consecutive compatible queued messages from the same source into one turn before delivery.",
+ "messageQueueAutoMergeNotice": "If a message has a different source or incompatible task, model, mode, metadata, or shared limits, it stays as a separate queued message. This applies only to new admissions and does not change the manual merge control.",
+ "messageQueueAutoMergeToggleLabel": "Automatically merge consecutive messages",
"messageQueueConfigured": "Configured:",
"messageQueueDescription": "Control how many pending messages each session can hold before new work is rejected.",
"messageQueueEffective": "Effective:",
diff --git a/apps/web/src/locales/pseudo/system.json b/apps/web/src/locales/pseudo/system.json
index 2b99ff36b0..a37d4659bd 100644
--- a/apps/web/src/locales/pseudo/system.json
+++ b/apps/web/src/locales/pseudo/system.json
@@ -164,6 +164,9 @@
"licensesTitle": "Ţĥĩŕď-ƥàŕţŷ ĺĩćēńśēś",
"licensesToggleLabel": "Śĥōŵ ĺĩćēńśē ţēxţ ƒōŕ {{name}}",
"messageQueueAdminOnly": "Ōńĺŷ àďḿĩńĩśţŕàţōŕś ćàń ćĥàńĝē ţĥĩś śēţţĩńĝ.",
+ "messageQueueAutoMergeDescription": "Ćōḿƀĩńēś ćōńśēćũţĩvē ćōḿƥàţĩƀĺē qũēũēď ḿēśśàĝēś ƒŕōḿ ţĥē śàḿē śōũŕćē ĩńţō ōńē ţũŕń ƀēƒōŕē ďēĺĩvēŕŷ.",
+ "messageQueueAutoMergeNotice": "Ĩƒ à ḿēśśàĝē ĥàś à ďĩƒƒēŕēńţ śōũŕćē ōŕ ĩńćōḿƥàţĩƀĺē ţàśķ, ḿōďēĺ, ḿōďē, ḿēţàďàţà, ōŕ śĥàŕēď ĺĩḿĩţś, ĩţ śţàŷś àś à śēƥàŕàţē qũēũēď ḿēśśàĝē. Ţĥĩś àƥƥĺĩēś ōńĺŷ ţō ńēŵ àďḿĩśśĩōńś àńď ďōēś ńōţ ćĥàńĝē ţĥē ḿàńũàĺ ḿēŕĝē ćōńţŕōĺ.",
+ "messageQueueAutoMergeToggleLabel": "Àũţōḿàţĩćàĺĺŷ ḿēŕĝē ćōńśēćũţĩvē ḿēśśàĝēś",
"messageQueueConfigured": "Ćōńƒĩĝũŕēď:",
"messageQueueDescription": "Ćōńţŕōĺ ĥōŵ ḿàńŷ ƥēńďĩńĝ ḿēśśàĝēś ēàćĥ śēśśĩōń ćàń ĥōĺď ƀēƒōŕē ńēŵ ŵōŕķ ĩś ŕēĵēćţēď.",
"messageQueueEffective": "Ēƒƒēćţĩvē:",
diff --git a/docs/plans/message-queue-auto-merge/plan.md b/docs/plans/message-queue-auto-merge/plan.md
new file mode 100644
index 0000000000..3f4edf3992
--- /dev/null
+++ b/docs/plans/message-queue-auto-merge/plan.md
@@ -0,0 +1,264 @@
+---
+spec: docs/specs/ui/message-queue-auto-merge.md
+created: 2026-08-12
+status: completed
+---
+
+# Implementation Plan: Automatically Merge Consecutive Queued Messages
+
+## Overview
+
+Add a default-on, install-wide automatic-merge setting without changing the
+manual merge switch. Build the strict tail-compatibility mutation first, route
+ordinary admission and staged attachment admission through it safely, then add
+settings persistence and UI. Finish with desktop/mobile E2E coverage and public
+documentation.
+
+---
+
+## Backend
+
+### Automatic tail-merge contract
+
+- Add a repository operation in
+ `apps/backend/internal/orchestrator/messagequeue/repository.go` that targets
+ one newly inserted source ID and its immediate predecessor. It returns the
+ surviving target plus whether a fold occurred; incompatibility is a normal
+ `false` result, not `ErrNoMergeTarget`.
+- Implement it under the existing per-session lock in
+ `repository_memory.go` and as one transaction in `repository_sqlite.go`
+ (the database-backed implementation also serves the env-gated Postgres
+ suite). Select by source ID and session, select only the greatest lower
+ position, compute the complete candidate, update the target, and delete the
+ exact source. A missing/drained source never causes a search for another
+ source.
+- Keep manual `MergeIntoAbove` and `mergeAllowed` unchanged. Add automatic-only
+ helpers in `merge.go`:
+ - source compatibility: same task/model/plan mode; user identities equal and
+ non-reserved, or agent identities equal with one identical non-empty
+ `sender_task_id`; lifecycle/workflow/server/clarification rows excluded;
+ - non-additive metadata comparison after normalizing JSON-round-tripped map
+ values;
+ - stable entity-reference and context-file unions;
+ - combined attachment count/byte validation using the same limits and byte
+ accounting as send-now;
+ - content joining that adds `\n\n` only when both bodies are non-empty.
+- Preserve the target's immutable fields and target-then-source order. Any
+ mismatch or limit overflow leaves both rows unchanged and returns a skipped
+ result.
+
+### Admission integration
+
+- Extend `messagequeue.Service` in `service.go` with a separate atomic
+ `autoMergeEnabled` value, defaulting to true, plus getters/setters. Do not
+ reuse `mergeEnabled`; test all four switch combinations.
+- Within `QueueMessageWithMetadata`, snapshot capacity and automatic-merge
+ state under `WithSessionAdmission`, perform the existing capped insert, then
+ attempt the repository fold. Capacity remains an admission prerequisite.
+ Return the surviving target on success and the source on skip.
+- Treat a post-insert automatic-fold storage error as degraded success: the
+ transaction leaves the accepted source separate, the service logs the
+ failure, and callers do not receive an error that encourages a duplicate
+ retry.
+- Add a narrow deferred-finalization path for user WebSocket admissions that
+ contain staged file-backed attachments. Hold `WithSessionAdmission` across
+ insertion, claim, fold, and rollback so no later admission can merge into the
+ provisional source. `queue_handlers.go` must insert a distinct source first,
+ claim its attachments, and only then finalize the snapshotted
+ automatic-merge decision. On claim failure, the existing rollback removes
+ only that source ID. Inline attachments can use ordinary admission.
+- Preserve exact-ID consumers. `QueueAndInterruptForPeerMessage` and
+ `message_task_kandev` use the returned surviving ID, so an immediate parent
+ interrupt takes the complete merged entry. Workflow/coalesce, restoration,
+ explicit append, clarification, and reserved lifecycle paths remain excluded
+ by their APIs or compatibility checks.
+- Publish `message.queue.status_changed` only after the final merge-or-separate
+ state is known. Do not add `auto_merge_enabled` to `QueueStatus`; the queue
+ panel has no setting control.
+
+### Persistent settings and startup wiring
+
+- Extend `apps/backend/internal/system/queuesettings/types.go` with
+ `AutoMergeEnabled` on `Settings`, `SettingsPatch`, and `Effective`. Apply and
+ validate it like the independent manual merge boolean.
+- Extend `store.go`'s pointer-backed legacy decoder so an omitted
+ `auto_merge_enabled` becomes true while an explicit false survives. Saving
+ writes the normalized three-field object.
+- Extend `resolver.go` and `service.go` so GET/PATCH round-trip the field,
+ partial updates preserve both other values, max-capacity environment locks do
+ not block an auto-only patch, persistence precedes live apply, and the queue
+ target receives all three effective values. In particular, an auto-only or
+ manual-only PATCH under an environment capacity override must reapply the
+ resolved environment capacity, not the lower persisted capacity.
+- In `apps/backend/internal/backendapp/orchestrator.go`, resolve and apply the
+ saved automatic setting before the orchestrator accepts work. Update
+ `message_queue_settings_test.go`, `system_routes_test.go`, and
+ `internal/system/queuesettings/queuesettings_test.go` for default, upgrade,
+ explicit false, partial PATCH, environment lock, restart, and live-apply
+ cases.
+
+---
+
+## Frontend
+
+### Settings API and types
+
+- Add `auto_merge_enabled` to `MessageQueueSettingsValue` in
+ `apps/web/lib/types/system.ts`. The existing partial patch type then accepts
+ an auto-only update without changing the HTTP client contract.
+- Update `apps/web/lib/api/domains/settings-api.test.ts` fixtures and assertions
+ to prove the field is decoded and sent alone.
+
+### Message Queue settings card
+
+- Extend `useMessageQueueSettingsLoad` and
+ `useMessageQueueSettingsDraft` in
+ `apps/web/components/settings/system/message-queue-settings.tsx` with a third
+ draft/baseline. Include it in contributor revision, dirty/valid/save/discard
+ logic, and concurrency-safe response reconciliation.
+- Add a separate `AutoMergeToggleFields` presentation block below the existing
+ manual merge block. Use the label **Automatically merge consecutive
+ messages**, explanatory same-source/fallback copy, a stable test ID, and the
+ existing admin/member and shared floating-save behavior.
+- Keep the current Task Behavior page, card width, single settings scroll owner,
+ and one-column responsive layout. The switch and surrounding row must remain
+ touch-safe and clear the mobile save bar. No queue-panel component changes.
+- Add all user-facing copy to `apps/web/src/locales/en/system.json`, regenerate
+ the pseudo catalog, and run i18n checks and the changed-line ratchet.
+
+---
+
+## Tests
+
+- **What:** strict same-source compatibility, identity preservation, ordered
+ content/context/attachment merge, metadata mismatch fallback, combined-limit
+ fallback, and no half-write.
+ **File:** new
+ `apps/backend/internal/orchestrator/messagequeue/repository_auto_merge_test.go`
+ plus env-gated Postgres cases.
+ **How:** table-drive the same contract over memory, SQLite, and Postgres;
+ compare complete pre/post snapshots on skipped and failing paths.
+- **What:** default-on service admission, disabled behavior, three-message
+ chaining, cap-before-merge, concurrent admissions/drain, degraded repository
+ error, and independence from manual merge.
+ **File:** new `service_auto_merge_test.go` and focused updates to existing
+ queue tests that intentionally need multiple compatible rows.
+ **How:** RED service tests first, then race tests using the existing admission
+ lock and repository hooks.
+- **What:** file-backed attachment claim succeeds before fold and claim failure
+ removes only the new source.
+ **File:** `queue_handlers_test.go` or a new
+ `queue_handlers_auto_merge_test.go`.
+ **How:** handler-to-real-memory-service tests with blocking/failing attachment
+ claim fakes and byte-for-byte target assertions.
+- **What:** returned surviving IDs remain valid for normal peer queueing and
+ parent interrupt dispatch, while different senders, clarification, workflow,
+ lifecycle, plugin-source mismatch, append, coalesce, and restore do not
+ conflate.
+ **File:** focused cases in `internal/orchestrator`, `internal/mcp/handlers`,
+ and `internal/backendapp` tests.
+ **How:** integration tests through existing real service fixtures.
+- **What:** settings default/upgrade/persistence/partial PATCH/live apply and
+ environment-lock independence, including retention of the environment's live
+ capacity after an auto-only PATCH.
+ **File:** `internal/system/queuesettings/queuesettings_test.go`,
+ `internal/backendapp/message_queue_settings_test.go`, and
+ `internal/system/system_routes_test.go`.
+ **How:** store, service, HTTP, and startup wiring tests.
+- **What:** third draft loads, saves alone, discards, survives a concurrent
+ response, remains editable under only a max environment lock, and is disabled
+ for members.
+ **File:** `message-queue-settings.test.tsx` and `settings-api.test.ts`.
+ **How:** component tests with the existing save-provider harness and API
+ mocks.
+
+---
+
+## E2E Tests
+
+- **Scenario:** default-on setting persists and can be switched off/on without
+ changing capacity or manual merge.
+ **File:** `apps/web/e2e/tests/system/message-queue-settings.spec.ts`.
+ **What to verify:** GET default, auto-only PATCH body, shared save bar,
+ reload, environment-cap lock independence, member read-only state, and full
+ baseline restoration in `afterEach`.
+- **Scenario:** two compatible user messages become one when enabled and remain
+ two when disabled.
+ **File:** `apps/web/e2e/tests/chat/message-queue.spec.ts`.
+ **What to verify:** busy agent, final queue count/content/order, no transient
+ second row after the response, and setting restoration even on failure.
+- **Scenario:** mobile exposes the same touch-safe switch without overflow,
+ nested scroll ownership, or save-bar overlap.
+ **File:**
+ `apps/web/e2e/tests/system/mobile-message-queue-settings.spec.ts`.
+ **What to verify:** visible switch, minimum touch size, save/discard, and
+ persisted value.
+- Existing management, manual merge, send-now, and reorder E2E scenarios that
+ require multiple compatible rows explicitly disable auto merge for their
+ setup and restore the prior install value during teardown. Update desktop and
+ `mobile-*` specs together.
+
+---
+
+## Verification Results
+
+- Backend repository, admission, handler, MCP, settings, startup, and race suites
+ passed, including memory, SQLite, and environment-gated Postgres contracts.
+- Frontend passed 34 focused component/API tests, typecheck, focused ESLint,
+ pseudo-locale generation, `i18n:check`, and `i18n:ratchet`.
+- Desktop Chromium E2E passed 20 tests; mobile Chromium passed 4 tests; the
+ focused attribution, steering, and sidebar-count audit passed 3 tests.
+- Public documentation tests passed 60 tests, the validator accepted 41
+ published pages, and `git diff --check` passed.
+- No blockers remain.
+
+Review remediation verification also passed the focused automatic-merge
+repository/service tests and the message-queue race suite after adding the
+context-file cap and shared admission serialization for queue mutations.
+
+---
+
+## Implementation Waves And Parallel Candidates
+
+```text
+Wave 1:
+- [x] [task-01-auto-merge-repository](task-01-auto-merge-repository.md)
+
+Wave 2:
+- [x] [task-02-auto-merge-admission](task-02-auto-merge-admission.md)
+
+Wave 3:
+- [x] [task-03-auto-merge-settings](task-03-auto-merge-settings.md)
+
+Wave 4:
+- [x] [task-04-auto-merge-settings-ui](task-04-auto-merge-settings-ui.md)
+
+Wave 5:
+- [x] [task-05-auto-merge-e2e](task-05-auto-merge-e2e.md)
+
+Wave 6:
+- [x] [task-06-auto-merge-docs](task-06-auto-merge-docs.md)
+```
+
+The dependency chain is sequential in the primary conversation. No wave
+authorizes subagents; only a later explicit user request can do that.
+
+## Environment prerequisite
+
+If `apps/node_modules` is absent, run `pnpm install --frozen-lockfile` from
+`apps/` before frontend or E2E checks. Do not change the lockfile.
+
+## Risks and boundaries
+
+- Automatic admission must not call the manual setting gate or loosen manual
+ merge provenance rules.
+- File attachment claim rollback is destructive to the just-inserted source ID;
+ automatic fold must never run before that claim succeeds, and a later
+ admission must never use the provisional source as its target.
+- A post-insert fold error cannot be returned as an admission failure because a
+ caller retry would duplicate accepted content.
+- Existing tests frequently queue multiple compatible user rows. They must opt
+ out explicitly when row count is part of the scenario, while product defaults
+ stay on.
+- No schema migration, feature flag, environment variable, retroactive sweep,
+ queue-panel toggle, or capacity bypass is planned.
diff --git a/docs/plans/message-queue-auto-merge/task-01-auto-merge-repository.md b/docs/plans/message-queue-auto-merge/task-01-auto-merge-repository.md
new file mode 100644
index 0000000000..91b64bc251
--- /dev/null
+++ b/docs/plans/message-queue-auto-merge/task-01-auto-merge-repository.md
@@ -0,0 +1,88 @@
+---
+id: "01-auto-merge-repository"
+title: "Build automatic tail merge"
+status: completed
+wave: 1
+depends_on: []
+plan: "plan.md"
+spec: "../../specs/ui/message-queue-auto-merge.md"
+---
+
+# Task 01: Build automatic tail merge
+
+## Acceptance
+
+1. The repository contract and memory/database implementations atomically fold
+ an exact source ID into only its immediate predecessor, return the surviving
+ target and a merged/skipped result, and never mutate either row on skip or
+ error.
+2. Automatic compatibility enforces source, task, model, plan mode, metadata,
+ lifecycle, attachment, reference, and context rules from the spec while
+ preserving target identity and target-then-source order. Manual merge
+ behavior remains unchanged.
+3. Table-driven memory, SQLite, and env-gated Postgres tests cover happy paths,
+ every mismatch/limit fallback, missing/drained candidates, and atomic
+ rollback.
+
+## Verification
+
+```bash
+cd apps/backend && go test -count=1 ./internal/orchestrator/messagequeue
+cd apps/backend && go test -race -count=1 -run 'Test.*AutoMerge' ./internal/orchestrator/messagequeue
+```
+
+## Files likely touched
+
+- `apps/backend/internal/orchestrator/messagequeue/repository.go`
+- `apps/backend/internal/orchestrator/messagequeue/merge.go`
+- `apps/backend/internal/orchestrator/messagequeue/repository_memory.go`
+- `apps/backend/internal/orchestrator/messagequeue/repository_sqlite.go`
+- `apps/backend/internal/orchestrator/messagequeue/send_now.go`
+- `apps/backend/internal/orchestrator/messagequeue/repository_auto_merge_test.go`
+- `apps/backend/internal/orchestrator/messagequeue/repository_postgres_auto_merge_test.go`
+
+## Dependencies
+
+None.
+
+## Parallelism
+
+Sequential.
+
+## Inputs
+
+- Spec: `What`, `Data model`, `Admission lifecycle and concurrency`, `Failure
+ modes`, and compatibility/limit scenarios.
+- Plan: `Backend > Automatic tail-merge contract`.
+- Existing patterns: `MergeIntoAbove` transaction and memory lock,
+ `buildSendNowEnvelope` attachment/reference validation, persisted metadata
+ normalization, and env-gated Postgres merge tests.
+
+## Risks
+
+- JSON-round-tripped metadata types must compare semantically, not by Go slice
+ representation.
+- Incompatibility is a successful skip. Do not reuse manual merge errors for it.
+- Keep new Go functions within repository lint limits by extracting pure
+ compatibility and union helpers.
+
+## Output contract
+
+Report summary, files changed, exact commands/outcomes, blockers, risks, and
+update this task plus `plan.md` status in the same conversation.
+
+## Results
+
+- Added `AutoMergeIntoAbove` to the repository contract and implemented it for
+ memory plus database-backed repositories without changing manual merge.
+- Added automatic-only compatibility, semantic metadata comparison, stable
+ entity/context unions, attachment limit enforcement, and empty-body joining.
+- Added shared memory/SQLite/env-gated Postgres contract coverage, including
+ mismatch and limit skips, immediate-predecessor selection, missing/drained
+ sources, identity preservation, and transaction rollback.
+- Review remediation caps merged context-file metadata at 200 descriptors and
+ makes the identity assertion tolerant of Postgres timestamp precision.
+- Verification passed:
+ - `cd apps/backend && go test -count=1 ./internal/orchestrator/messagequeue`
+ - `cd apps/backend && go test -race -count=1 -run 'Test.*AutoMerge' ./internal/orchestrator/messagequeue`
+- Blockers: none.
diff --git a/docs/plans/message-queue-auto-merge/task-02-auto-merge-admission.md b/docs/plans/message-queue-auto-merge/task-02-auto-merge-admission.md
new file mode 100644
index 0000000000..8effdca7d4
--- /dev/null
+++ b/docs/plans/message-queue-auto-merge/task-02-auto-merge-admission.md
@@ -0,0 +1,99 @@
+---
+id: "02-auto-merge-admission"
+title: "Integrate automatic merge admission"
+status: completed
+wave: 2
+depends_on: ["01-auto-merge-repository"]
+plan: "plan.md"
+spec: "../../specs/ui/message-queue-auto-merge.md"
+---
+
+# Task 02: Integrate automatic merge admission
+
+## Acceptance
+
+1. `messagequeue.Service` has a default-on automatic setting independent from
+ manual merge; normal capped admissions return the surviving target when
+ folded, preserve a separate source on skip/error, and do not auto-process
+ append, coalesce, restore, retry, or reserved lifecycle APIs.
+2. File-backed WebSocket admissions hold per-session admission serialization,
+ insert a distinct rollback-safe source, claim attachments, then finalize the
+ snapshotted fold. No later admission can target the provisional source;
+ claim failure leaves the prior target byte-for-byte unchanged, and status
+ publishes only the final state.
+3. Integration tests prove concurrent ordering/drain behavior, cap-before-fold,
+ all four manual/automatic switch combinations, and exact-ID peer/parent
+ interrupt delivery with excluded workflow, clarification, plugin-source,
+ append, coalesce, and restore paths.
+
+## Verification
+
+```bash
+cd apps/backend && go test -count=1 ./internal/orchestrator/messagequeue ./internal/orchestrator/handlers ./internal/orchestrator ./internal/mcp/handlers ./internal/backendapp
+cd apps/backend && go test -race -count=1 ./internal/orchestrator/messagequeue ./internal/orchestrator/handlers
+```
+
+## Files likely touched
+
+- `apps/backend/internal/orchestrator/messagequeue/service.go`
+- `apps/backend/internal/orchestrator/messagequeue/types.go`
+- `apps/backend/internal/orchestrator/messagequeue/service_auto_merge_test.go`
+- `apps/backend/internal/orchestrator/handlers/queue_handlers.go`
+- `apps/backend/internal/orchestrator/handlers/queue_handlers_auto_merge_test.go`
+- `apps/backend/internal/orchestrator/task_operations.go`
+- `apps/backend/internal/orchestrator/task_operations_test.go`
+- `apps/backend/internal/orchestrator/event_handlers_clarification_test.go`
+- `apps/backend/internal/mcp/handlers/message_task_test.go`
+- `apps/backend/internal/backendapp/adapters_plugin_messenger_test.go`
+- Existing queue/service/handler tests whose setup intentionally needs separate
+ compatible rows.
+
+## Dependencies
+
+Task 01.
+
+## Parallelism
+
+Sequential.
+
+## Inputs
+
+- Spec: `What`, `API surface`, `Admission lifecycle and concurrency`, `Failure
+ modes`, and all admission scenarios.
+- Plan: `Backend > Admission integration` and corresponding test rows.
+- Existing patterns: `WithSessionAdmission`, queue attachment claim rollback,
+ `QueueAndInterruptForPeerMessage`, `QueueMessageWithCoalesceKey`,
+ `RestoreMessage`, and status publishers.
+
+## Risks
+
+- Never report post-insert fold failure as failed admission.
+- The deferred attachment API must carry the admission-time setting snapshot
+ and retain the admission lock through claim/finalize; a settings change or
+ later admission cannot retroactively alter the provisional source.
+- A returned merged ID may identify an older target. Every exact-ID caller must
+ use that returned value intentionally.
+
+## Output contract
+
+Report summary, files changed, exact commands/outcomes, blockers, risks, and
+update this task plus `plan.md` status in the same conversation.
+
+## Results
+
+- Added an independent default-on live automatic-merge setting and admission
+ folding that snapshots capacity/policy under the per-session lock.
+- Added deferred after-insert finalization so staged attachments are claimed
+ before folding; rollback and later admissions remain serialized by source ID.
+- Kept capacity as a precondition, degraded post-insert fold errors to separate
+ success, and preserved explicit append/coalesce/restore/retry contracts.
+- Added service, handler, concurrency, storage-error, four-switch-combination,
+ and exact surviving-ID parent-interrupt coverage. Legacy tests that require
+ multiple compatible rows now explicitly disable automatic merging.
+- Review remediation serializes queue drains, targeted takes, removals, edits,
+ reorders, manual merges, cancel-all, and send-now claims with admission
+ finalization; drain/removal race coverage now guards the provisional source.
+- Verification passed:
+ - `cd apps/backend && go test -count=1 ./internal/orchestrator/messagequeue ./internal/orchestrator/handlers ./internal/orchestrator ./internal/mcp/handlers ./internal/backendapp`
+ - `cd apps/backend && go test -race -count=1 ./internal/orchestrator/messagequeue ./internal/orchestrator/handlers`
+- Blockers: none.
diff --git a/docs/plans/message-queue-auto-merge/task-03-auto-merge-settings.md b/docs/plans/message-queue-auto-merge/task-03-auto-merge-settings.md
new file mode 100644
index 0000000000..7edce99d54
--- /dev/null
+++ b/docs/plans/message-queue-auto-merge/task-03-auto-merge-settings.md
@@ -0,0 +1,88 @@
+---
+id: "03-auto-merge-settings"
+title: "Persist automatic merge setting"
+status: completed
+wave: 3
+depends_on: ["02-auto-merge-admission"]
+plan: "plan.md"
+spec: "../../specs/ui/message-queue-auto-merge.md"
+---
+
+# Task 03: Persist automatic merge setting
+
+## Acceptance
+
+1. Message Queue settings GET/PATCH include `auto_merge_enabled` in configured
+ and effective values; default and legacy-missing values are true, explicit
+ false persists, and partial patches preserve capacity and manual merge.
+2. Persistence completes before live apply, an environment capacity lock does
+ not block an auto-only update or lose its effective live capacity, and
+ backend startup applies the resolved values before new admissions.
+3. Store, resolver, service, HTTP route, and backend wiring tests cover fresh,
+ legacy, restart, invalid-record fallback, partial update, explicit false,
+ live apply, and environment-lock cases.
+
+## Verification
+
+```bash
+cd apps/backend && go test -count=1 ./internal/system/queuesettings ./internal/system ./internal/backendapp
+cd apps/backend && go test -race -count=1 ./internal/system/queuesettings ./internal/orchestrator/messagequeue
+```
+
+## Files likely touched
+
+- `apps/backend/internal/system/queuesettings/types.go`
+- `apps/backend/internal/system/queuesettings/store.go`
+- `apps/backend/internal/system/queuesettings/resolver.go`
+- `apps/backend/internal/system/queuesettings/service.go`
+- `apps/backend/internal/system/queuesettings/queuesettings_test.go`
+- `apps/backend/internal/backendapp/orchestrator.go`
+- `apps/backend/internal/backendapp/message_queue_settings_test.go`
+- `apps/backend/internal/system/system_routes_test.go`
+
+## Dependencies
+
+Task 02.
+
+## Parallelism
+
+Sequential.
+
+## Inputs
+
+- Spec: `Data model`, `API surface`, `Permissions`, `Persistence guarantees`,
+ and settings scenarios.
+- Plan: `Backend > Persistent settings and startup wiring`.
+- Existing patterns: pointer-backed `merge_enabled` legacy decoding,
+ `SettingsPatch.Apply`, persisted-before-live-apply service flow, and
+ `resolveQueueMergeEnabled` startup wiring.
+
+## Risks
+
+- Plain bool decoding would silently turn legacy installs off. The stored shape
+ needs a pointer for the new field.
+- Keep `merge_enabled` and `auto_merge_enabled` names and live setters distinct.
+- The capacity environment source/lock metadata remains about capacity only.
+- Live apply must use resolved effective capacity after any merge-only patch;
+ copying the persisted capacity would override the active environment value.
+
+## Output contract
+
+Report summary, files changed, exact commands/outcomes, blockers, risks, and
+update this task plus `plan.md` status in the same conversation.
+
+## Results
+
+- Added `auto_merge_enabled` to configured/effective responses and partial
+ patches, with pointer-backed legacy decoding that defaults omitted values on.
+- Persisted normalized three-field JSON and applied all live values only after
+ a successful save.
+- Fixed merge-only live apply under a capacity environment override to reapply
+ the resolved environment capacity instead of the persisted value.
+- Wired startup to resolve once and apply capacity, manual merge, and automatic
+ merge before queue admissions; added fresh/legacy/false/restart resolver,
+ HTTP, persistence, and live-target tests.
+- Verification passed:
+ - `cd apps/backend && go test -count=1 ./internal/system/queuesettings ./internal/system ./internal/backendapp`
+ - `cd apps/backend && go test -race -count=1 ./internal/system/queuesettings ./internal/orchestrator/messagequeue`
+- Blockers: none.
diff --git a/docs/plans/message-queue-auto-merge/task-04-auto-merge-settings-ui.md b/docs/plans/message-queue-auto-merge/task-04-auto-merge-settings-ui.md
new file mode 100644
index 0000000000..fa35b7cb8f
--- /dev/null
+++ b/docs/plans/message-queue-auto-merge/task-04-auto-merge-settings-ui.md
@@ -0,0 +1,82 @@
+---
+id: "04-auto-merge-settings-ui"
+title: "Add automatic merge switch"
+status: completed
+wave: 4
+depends_on: ["03-auto-merge-settings"]
+plan: "plan.md"
+spec: "../../specs/ui/message-queue-auto-merge.md"
+---
+
+# Task 04: Add automatic merge switch
+
+## Acceptance
+
+1. Frontend settings types and the Message Queue card load a default-on
+ `auto_merge_enabled` draft and save/discard it independently through the
+ shared settings contributor without resetting capacity or manual merge.
+2. A localized **Automatically merge consecutive messages** switch explains
+ same-source fallback behavior, is admin-editable even under only a capacity
+ environment lock, and is read-only for members. No queue-panel control is
+ added.
+3. Component/API tests cover load, auto-only PATCH, dirty/discard/save,
+ concurrent response reconciliation, failure, environment lock, member
+ permission, and mobile-safe rendering; typecheck, focused lint, pseudo
+ generation, and i18n gates pass.
+
+## Verification
+
+```bash
+cd apps && pnpm install --frozen-lockfile && pnpm --filter @kandev/web test -- components/settings/system/message-queue-settings.test.tsx lib/api/domains/settings-api.test.ts && cd web && pnpm run typecheck && pnpm exec eslint --max-warnings 0 components/settings/system/message-queue-settings.tsx components/settings/system/message-queue-settings.test.tsx lib/api/domains/settings-api.ts lib/api/domains/settings-api.test.ts lib/types/system.ts && pnpm run i18n:pseudo && pnpm run i18n:check && pnpm run i18n:ratchet
+```
+
+## Files likely touched
+
+- `apps/web/lib/types/system.ts`
+- `apps/web/lib/api/domains/settings-api.test.ts`
+- `apps/web/components/settings/system/message-queue-settings.tsx`
+- `apps/web/components/settings/system/message-queue-settings.test.tsx`
+- `apps/web/src/locales/en/system.json`
+- `apps/web/src/locales/pseudo/system.json`
+
+## Dependencies
+
+Task 03.
+
+## Parallelism
+
+Sequential.
+
+## Inputs
+
+- Spec: `What`, `API surface`, `Permissions`, `Responsive and mobile behavior`,
+ and settings/mobile scenarios.
+- Plan: `Frontend` and frontend test rows.
+- Mobile contract: existing Task Behavior entry point; same Message Queue card;
+ one-column presentation; `settings-scroll-container` is the only scroll
+ owner; touch-safe switch; shared floating save bar; desktop/mobile capability
+ parity.
+- Existing pattern: `MergeToggleFields`, the two-draft save coordinator, and
+ `message-queue-settings.test.tsx` save-provider harness.
+
+## Risks
+
+- The current component is near file/function lint limits. Extract the new
+ presentation block and keep draft derivation readable.
+- New copy must use `t()` at render time and pass both i18n checks.
+- An async save response must not overwrite a newer local toggle change.
+
+## Output contract
+
+Report summary, files changed, exact commands/outcomes, blockers, risks, and
+update this task plus `plan.md` status in the same conversation.
+
+## Results
+
+- Added the third `auto_merge_enabled` draft, independent partial PATCH,
+ discard, and stale-response reconciliation to the shared Message Queue save
+ contributor.
+- Added the localized default-on switch and same-source fallback explanation
+ with a 44px mobile touch target and no new scroll owner.
+- Verified 34 focused component/API tests, web typecheck, zero-warning focused
+ ESLint, pseudo-locale generation, `i18n:check`, and `i18n:ratchet`.
diff --git a/docs/plans/message-queue-auto-merge/task-05-auto-merge-e2e.md b/docs/plans/message-queue-auto-merge/task-05-auto-merge-e2e.md
new file mode 100644
index 0000000000..df4cb4f65d
--- /dev/null
+++ b/docs/plans/message-queue-auto-merge/task-05-auto-merge-e2e.md
@@ -0,0 +1,101 @@
+---
+id: "05-auto-merge-e2e"
+title: "Prove automatic merge flows"
+status: completed
+wave: 5
+depends_on: ["04-auto-merge-settings-ui"]
+plan: "plan.md"
+spec: "../../specs/ui/message-queue-auto-merge.md"
+---
+
+# Task 05: Prove automatic merge flows
+
+## Acceptance
+
+1. Desktop E2E proves the fresh/default value, auto-only save/reload, enabled
+ same-user compaction, and disabled separate-row behavior against the real
+ backend.
+2. Mobile E2E proves the switch is reachable, touch-safe, persistence-equivalent,
+ free of horizontal overflow/nested scrolling, and clear of the save bar.
+3. Every affected queue E2E scenario that requires multiple compatible rows
+ explicitly disables automatic merge, captures all three install-wide queue
+ settings, and restores its baseline in teardown even after failure.
+
+## Verification
+
+```bash
+cd apps && pnpm install --frozen-lockfile
+cd apps/web && pnpm e2e:run --host tests/system/message-queue-settings.spec.ts tests/chat/message-queue.spec.ts tests/chat/message-queue-reorder.spec.ts
+cd apps/web && pnpm e2e:run --host --project mobile-chrome tests/system/mobile-message-queue-settings.spec.ts tests/chat/mobile-message-queue-management.spec.ts tests/chat/mobile-message-queue-reorder.spec.ts
+```
+
+## Files likely touched
+
+- `apps/web/e2e/tests/system/message-queue-settings.spec.ts`
+- `apps/web/e2e/tests/system/mobile-message-queue-settings.spec.ts`
+- `apps/web/e2e/tests/chat/message-queue.spec.ts`
+- `apps/web/e2e/tests/chat/message-queue-reorder.spec.ts`
+- `apps/web/e2e/tests/chat/mobile-message-queue-management.spec.ts`
+- `apps/web/e2e/tests/chat/mobile-message-queue-reorder.spec.ts`
+- A focused E2E settings helper under `apps/web/e2e/helpers/` if reuse avoids
+ duplicated baseline/restore logic.
+
+## Dependencies
+
+Task 04 and all backend tasks.
+
+## Parallelism
+
+Sequential.
+
+## Inputs
+
+- Spec: default, enabled/disabled, cap, independence, persistence, and mobile
+ scenarios.
+- Plan: `E2E Tests`.
+- Existing patterns: API baseline capture in
+ `message-queue-settings.spec.ts`, busy-agent queue setup in
+ `message-queue.spec.ts`, and mobile layout assertions in
+ `mobile-message-queue-settings.spec.ts`.
+- E2E fixture contract: install-wide mutable state must be captured and restored
+ by the owning spec; mobile changes accompany desktop changes.
+
+## Risks
+
+- Default-on behavior changes setup assumptions in manual merge, send-now, and
+ reorder tests. Audit all multi-row queue construction, not only new tests.
+- Do not rely on test ordering or the fixture reset to restore install-wide
+ settings.
+- Assert final authoritative state after the queue response/status event, not a
+ transient render.
+
+## Output contract
+
+Report summary, files changed, exact commands/outcomes, baseline restoration and
+cleanup evidence, blockers, risks, and update this task plus `plan.md` status in
+the same conversation.
+
+## Results
+
+- Added shared E2E setup that captures all three install-wide queue settings,
+ disables automatic merging for separate-row scenarios, and restores the full
+ baseline after every test.
+- Added real-backend desktop coverage for default-on persistence, auto-only
+ updates, enabled compaction, disabled fallback, capacity-lock independence,
+ and member permissions.
+- Added mobile coverage for touch geometry, save/discard persistence, overflow,
+ scroll ownership, and save-bar clearance. Audited attribution, steering,
+ sidebar-count, reorder, and queue-management scenarios that require separate
+ rows.
+- Stabilized the existing row Send Now scenario by waiting for observable mock
+ turn progress before requesting cancellation.
+- Verification passed:
+ - desktop Chromium: 20 tests;
+ - mobile Chromium: 4 tests;
+ - focused attribution, steering, and sidebar-count audit: 3 tests;
+ - focused E2E ESLint: zero warnings.
+- Blockers: none.
+
+Review remediation makes shared teardown read the effective environment lock
+before restoring and omit `max_per_session` when capacity is environment-
+controlled.
diff --git a/docs/plans/message-queue-auto-merge/task-06-auto-merge-docs.md b/docs/plans/message-queue-auto-merge/task-06-auto-merge-docs.md
new file mode 100644
index 0000000000..ffbbd764d8
--- /dev/null
+++ b/docs/plans/message-queue-auto-merge/task-06-auto-merge-docs.md
@@ -0,0 +1,90 @@
+---
+id: "06-auto-merge-docs"
+title: "Document automatic queue merging"
+status: completed
+wave: 6
+depends_on: ["05-auto-merge-e2e"]
+plan: "plan.md"
+spec: "../../specs/ui/message-queue-auto-merge.md"
+---
+
+# Task 06: Document automatic queue merging
+
+## Acceptance
+
+1. Public coordination, session, operations, configuration, and WebSocket docs
+ explain the default-on switch, strict same-source fallback, capacity-first
+ behavior, returned surviving ID, independent manual switch, and current Task
+ Behavior settings location.
+2. Related queue management/manual merge specs link to this feature and show the
+ normalized three-field setting without changing manual merge behavior.
+3. After all implementation evidence passes, the new spec/index and design
+ package statuses are synchronized, links resolve, public-doc validators pass,
+ and `git diff --check` is clean.
+
+## Verification
+
+```bash
+node --test scripts/validate-public-docs.test.mjs
+node scripts/validate-public-docs.mjs
+git diff --check
+```
+
+## Files likely touched
+
+- `docs/public/coordination.md`
+- `docs/public/sessions-and-review.md`
+- `docs/public/operations.md`
+- `docs/public/configuration.md`
+- `docs/public/websocket-api.md`
+- `docs/specs/ui/message-queue-management.md`
+- `docs/specs/ui/message-queue-merge.md`
+- `docs/specs/ui/message-queue-auto-merge.md`
+- `docs/specs/INDEX.md`
+- `docs/plans/message-queue-auto-merge/plan.md`
+- `docs/plans/message-queue-auto-merge/task-*.md`
+
+## Dependencies
+
+Task 05.
+
+## Parallelism
+
+Sequential.
+
+## Inputs
+
+- Full spec and completed task results.
+- Plan: `Risks and boundaries` plus public-documentation impact.
+- Existing public queue sections found by searching `Message Queue`,
+ `message.queue.add`, and `KANDEV_QUEUE_MAX_PER_SESSION`.
+- Docs-maintainer rule: document observable behavior and configuration, keep
+ public terminology current, and run both validator commands.
+
+## Risks
+
+- Do not imply that automatic merging bypasses a full queue or retroactively
+ compacts pending rows.
+- Distinguish message-queue auto merge from PR/MR auto-merge terminology.
+- Mark the feature shipped only after implementation and E2E evidence exist.
+
+## Output contract
+
+Report summary, files changed, exact commands/outcomes, generated/cleanup
+evidence, blockers, risks, and update this task plus `plan.md` status in the same
+conversation.
+
+## Results
+
+- Updated public coordination, sessions, operations, configuration, and
+ WebSocket documentation with the current settings path and observable
+ automatic-merge contract.
+- Linked the management and manual-merge specs, normalized the documented
+ three-field settings shape, and marked the implemented feature spec shipped.
+- Verification passed:
+ - `node --test scripts/validate-public-docs.test.mjs` (60 tests);
+ - `node scripts/validate-public-docs.mjs` (41 published pages);
+ - `git diff --check`.
+- Pseudo-locale regeneration completed, build output remained ignored, and the
+ lockfile stayed unchanged.
+- Blockers: none.
diff --git a/docs/public/configuration.md b/docs/public/configuration.md
index 293c7315a9..8444a0b84e 100644
--- a/docs/public/configuration.md
+++ b/docs/public/configuration.md
@@ -358,7 +358,7 @@ These variables are supported by specific runtime components but are not YAML fi
| `KANDEV_MCP_LOG_FILE` | unset | File path for per-agentctl MCP debug logs. Logs tool names, arguments, session IDs, results for tool errors, and timings; invalid paths warn and disable this sink. |
| `OTEL_EXPORTER_OTLP_ENDPOINT` | unset | Enables OTLP/HTTP tracing for backend and agentctl spans; unset uses a no-op tracer. See the transport warning below. |
-Changing concurrency values trades process pressure against throughput and requires a restart. The queue environment value is also read at startup. Without that environment override, an admin can use **Settings > General > Message Queue** to save an install-wide limit that applies live. `0` means unlimited. Lowering the live limit does not prune existing rows; new admissions remain blocked until the pending count drops below the limit, while retries of already accepted work remain eligible.
+Changing concurrency values trades process pressure against throughput and requires a restart. The queue environment value is also read at startup. Under **Settings > Task Behavior > Message Queue**, an admin can save an install-wide capacity and independently control manual and automatic merging. All three values apply live and persist across restarts. `KANDEV_QUEUE_MAX_PER_SESSION` overrides and locks only capacity; neither merge switch has an environment override. `0` means unlimited. Lowering the live limit does not prune existing rows; new admissions remain blocked until the pending count drops below the limit, while retries of already accepted work remain eligible. The default-on automatic switch affects only later admissions and never bypasses capacity or sweeps existing rows.
The current OTLP exporter strips an `http://` or `https://` prefix from the configured endpoint and always uses `WithInsecure()`. Treat this as implementation-bound cleartext transport: send it only to a trusted private collector over a protected network, not directly across an untrusted network. The service name is `kandev-agentctl`, and spans can include task/session/execution IDs plus raw agent-event JSON truncated to 8192 characters. That payload can contain prompts, files, and tool data. Use collector-side access controls and retention accordingly.
diff --git a/docs/public/coordination.md b/docs/public/coordination.md
index e4665fdf36..6df9998059 100644
--- a/docs/public/coordination.md
+++ b/docs/public/coordination.md
@@ -126,7 +126,9 @@ For predictable top-level creation, pass `repository_url`, `repository_id`, or `
| Created but not started | Starts the session with the message. |
| Failed or cancelled | Returns an error. |
-The default delivery mode is queued. Each session accepts 10 queued messages by default. An admin can change the install-wide limit under **Settings > General > Message Queue**; `0` means unlimited. The saved value applies immediately to new admissions without removing messages already waiting. `KANDEV_QUEUE_MAX_PER_SESSION` has higher precedence, locks the UI field, and requires a restart when changed; zero or a negative value means unlimited. Only one queued message drains per agent turn. When the cap is reached, the sender receives a structured `queue_full` error and should retry after space becomes available.
+The default delivery mode is queued. Each session accepts 10 queued messages by default. An admin can change the install-wide limit under **Settings > Task Behavior > Message Queue**; `0` means unlimited. The saved value applies immediately to new admissions without removing messages already waiting. `KANDEV_QUEUE_MAX_PER_SESSION` has higher precedence, locks only the capacity field, and requires a restart when changed; zero or a negative value means unlimited. Only one queued message drains per agent turn. When the cap is reached, the sender receives a structured `queue_full` error and should retry after space becomes available.
+
+**Automatically merge consecutive messages** is enabled by default on the same settings card. After capacity admission succeeds, a new message may fold into the immediately preceding pending entry when both have the same strict source and compatible task, model, mode, metadata, attachments, and references. Otherwise it remains a separate FIFO entry. The earlier entry survives, so a successful admission may return an older queue-entry ID. The switch affects only later admissions and never sweeps rows already waiting. It is independent from **Enable queued message merging**, which controls the manual **Merge with above** action.
In the task workbench, expand the queue chip to manage pending messages. Every visible pending row has **Remove**, whether it came from a user, another agent, workflow automation, or a server action. **Clear all** removes all visible pending rows in that session and releases their capacity. After removal, merge, or drain, displayed positions compact to `#1` through `#N` while FIFO order stays unchanged. Provenance still matters for editing and merging: only user-origin content can be edited. A row already reserved for delivery is hidden from the panel and cannot be cancelled there.
diff --git a/docs/public/operations.md b/docs/public/operations.md
index f803562259..78505ea5ab 100644
--- a/docs/public/operations.md
+++ b/docs/public/operations.md
@@ -89,11 +89,13 @@ kandev service logs -f
Add `--system` to both commands for a system service.
-## Message queue capacity
+## Message queue settings
-Open **Settings > General > Message Queue** to set the install-wide number of pending messages allowed in each session. The default is `10`; `0` means unlimited. Admin saves apply immediately to later admissions. Lowering the limit does not prune rows already waiting, so a queue at or above the new limit rejects new work until messages run or are removed. Delivery retries for work accepted before the change are not discarded by the lower cap.
+Open **Settings > Task Behavior > Message Queue** to manage install-wide queue behavior. The default capacity is `10`; `0` means unlimited. Admin saves apply immediately to later admissions. Lowering the limit does not prune rows already waiting, so a queue at or above the new limit rejects new work until messages run or are removed. Delivery retries for work accepted before the change are not discarded by the lower cap.
-`KANDEV_QUEUE_MAX_PER_SESSION` has higher precedence than the saved setting. A valid environment value makes the UI field read-only; zero or a negative value means unlimited. Invalid text is logged and ignored in favor of the saved setting or default. Environment changes require a backend restart, while UI changes do not.
+`KANDEV_QUEUE_MAX_PER_SESSION` has higher precedence than the saved capacity. A valid environment value makes only that field read-only; zero or a negative value means unlimited. Invalid text is logged and ignored in favor of the saved setting or default. Environment changes require a backend restart, while UI changes do not.
+
+**Automatically merge consecutive messages** is on by default. Capacity is checked before any fold, so a full queue still rejects a compatible message. After admission, a new row folds only into its immediate pending predecessor when both rows have the same strict source and compatible task, model, mode, metadata, attachments, and references. Any mismatch or combined limit leaves the new row separate. The earlier row survives and its ID is returned. Turning the switch on does not compact existing rows. This setting is independent from **Enable queued message merging**, which controls the manual queue action.
To recover capacity in one session, expand its queue chip in the task workbench. **Remove** deletes one visible pending row and **Clear all** deletes all visible pending rows, including user-, agent-, workflow-, and server-origin work. After removal, merge, or drain, displayed positions immediately compact to `#1` through `#N`; durable FIFO ordering is unchanged. A row already reserved for delivery is hidden and is not cancelled by either action.
diff --git a/docs/public/sessions-and-review.md b/docs/public/sessions-and-review.md
index 9a8ab1cb50..cb75228bb5 100644
--- a/docs/public/sessions-and-review.md
+++ b/docs/public/sessions-and-review.md
@@ -87,7 +87,9 @@ Delivery follows the target state:
- a created session starts with the message as its first prompt;
- a failed or cancelled session rejects the message.
-The default pending-message limit is 10 per session. An admin can change it live under **Settings > General > Message Queue**; `0` removes the cap. A valid `KANDEV_QUEUE_MAX_PER_SESSION` value takes precedence and makes the UI field read-only; changing the environment still requires a restart. Malformed environment values are logged and ignored, so the saved setting or default applies instead. Lowering the saved limit does not delete entries already waiting. Interrupt delivery is restricted to a direct parent task messaging its child. Other senders must queue.
+The default pending-message limit is 10 per session. An admin can change it live under **Settings > Task Behavior > Message Queue**; `0` removes the cap. A valid `KANDEV_QUEUE_MAX_PER_SESSION` value takes precedence and makes only the capacity field read-only; changing the environment still requires a restart. Malformed environment values are logged and ignored, so the saved setting or default applies instead. Lowering the saved limit does not delete entries already waiting.
+
+The same card enables **Automatically merge consecutive messages** by default. Once capacity admission succeeds, compatible consecutive messages from the same strict source fold into the earlier pending entry; incompatible messages remain separate. The earlier entry's ID survives, and only admissions after the setting is read are eligible. This behavior is independent from the manual **Enable queued message merging** switch. Interrupt delivery is restricted to a direct parent task messaging its child. Other senders must queue.
For urgent replacement work, the parent should use `message_task_kandev` with `delivery_mode: "interrupt"`; this cancels the current approach and immediately tries to dispatch the new prompt, with a safe queued fallback. Use `stop_task_kandev` only for halt-only intent. A successful stop marks every accepted live child session `CANCELLED` and schedules graceful teardown asynchronously. Kandev then attempts to move an eligible unarchived, non-Office task from `IN_PROGRESS` or `SCHEDULING` to `REVIEW`; other task states remain unchanged. A child with no live execution returns idempotent `not_running`, and its worktrees, environment, commits, task record, descendants, and queued messages are preserved. See [Coordination](coordination.md) for the complete authority and lifecycle contract.
@@ -296,7 +298,7 @@ Before moving a task to done:
- **New Agent has no profiles:** create a profile compatible with the task executor. A profile for another executor is intentionally hidden.
- **Summary or generated text fails:** configure the corresponding utility agent with an enabled ACP profile in **Settings > Utility Agents**. Repair any stale or disabled profile binding before retrying.
- **Resume fails:** start fresh when the executor no longer has resumable session state, then supply a summary or copy the relevant context.
-- **A peer message never arrives:** check the target session state and ID. Running sessions queue messages; failed or cancelled sessions reject them. For a full queue, expand its chip and run, remove, or clear pending work before retrying; an admin can also review the install-wide limit under **Settings > General > Message Queue**.
+- **A peer message never arrives:** check the target session state and ID. Running sessions queue messages; failed or cancelled sessions reject them. For a full queue, expand its chip and run, remove, or clear pending work before retrying; an admin can also review the install-wide limit under **Settings > Task Behavior > Message Queue**.
- **Changes is empty:** select the correct repository and comparison, then confirm the agent wrote inside the materialized task path.
- **Review marks became stale:** the underlying diff changed. Re-review the new hash before marking the file complete.
- **Walkthrough does not appear:** confirm an active task-MCP session exists and that the saved `changes-walkthrough` prompt was not removed or made invalid.
diff --git a/docs/public/websocket-api.md b/docs/public/websocket-api.md
index 63ffef4285..c0462dc216 100644
--- a/docs/public/websocket-api.md
+++ b/docs/public/websocket-api.md
@@ -253,6 +253,8 @@ A successful response returns the normalized query and an ordered `groups` array
`message.queue.add` accepts the same optional array. `message.queue.update` also accepts it and treats the array as a replacement: send `[]` after removing every generated reference link so stale metadata is cleared. Queue status and message responses return validated entries under `metadata.entity_references`.
+Ordinary `message.queue.add` admissions use the default-on automatic-merge setting. Capacity is enforced first. The newly admitted row folds only into its immediate pending predecessor when both rows have the same strict source and compatible task, model, plan mode, metadata, attachments, context files, and entity references; the merged context-file union is capped at 200 descriptors. Otherwise it remains separate. A successful fold returns the surviving earlier row's `entry_id`, while a fallback returns the new row's ID. The final state is published through `message.queue.status_changed`. Automatic merging applies only to later ordinary admissions, not existing rows, explicit append/coalesce/restore/retry/send-now operations, or the independent manual merge action.
+
`message.queue.merge` folds a queued entry into the entry directly above it. The payload requires `session_id` and `entry_id` (the source entry being merged away); `user_id` is optional and defaults to `user`. On success the response carries the surviving merged entry's `entry_id` (the target's id) and the server broadcasts an updated `message.queue.status_changed`. The request is rejected with `entry_not_found` when the entry was already drained or is not owned by the caller, with a validation error when no mergeable entry exists above it, and with `merge_reference_overflow` when the combined entity-reference lists would exceed the per-message cap (the merge is rejected atomically; neither row changes).
If the agent is busy, use the `message.queue.*` operations rather than retrying `message.add`. Permission prompts are represented in persisted/session message data; answer one with `permission.respond`. Its payload requires `session_id` and `pending_id`, plus `option_id` unless `cancelled:true`; optional `rejected:true` distinguishes an explicit denial from dismissing the prompt.
diff --git a/docs/specs/INDEX.md b/docs/specs/INDEX.md
index afc4ba56e0..425c22d313 100644
--- a/docs/specs/INDEX.md
+++ b/docs/specs/INDEX.md
@@ -186,10 +186,11 @@ Subscription quota tracking and per-agent cheap-model profile routing.
| [entity-reference-composer](ui/entity-reference-composer.md) | draft |
| [agent-launch-prompt-composer](ui/agent-launch-prompt-composer.md) | shipped |
| [mermaid-rendering](ui/mermaid-rendering.md) | shipped |
-[message-queue-management](ui/message-queue-management.md) | shipped |
-[message-queue-merge](ui/message-queue-merge.md) | shipped |
-[message-queue-reorder](ui/message-queue-reorder.md) | building |
-[message-queue-send-now](ui/message-queue-send-now.md) | shipped |
+| [message-queue-auto-merge](ui/message-queue-auto-merge.md) | shipped |
+| [message-queue-management](ui/message-queue-management.md) | shipped |
+| [message-queue-merge](ui/message-queue-merge.md) | shipped |
+| [message-queue-reorder](ui/message-queue-reorder.md) | building |
+| [message-queue-send-now](ui/message-queue-send-now.md) | shipped |
| [settings-manual-save](ui/settings-manual-save.md) | shipped |
| [settings-discovery](ui/settings-discovery.md) | shipped |
| [executor-settings-card-spacing](ui/executor-settings-card-spacing.md) | shipped |
diff --git a/docs/specs/ui/message-queue-auto-merge.md b/docs/specs/ui/message-queue-auto-merge.md
new file mode 100644
index 0000000000..49777995f6
--- /dev/null
+++ b/docs/specs/ui/message-queue-auto-merge.md
@@ -0,0 +1,262 @@
+---
+status: shipped
+created: 2026-08-12
+owner: kandev
+---
+
+# Automatically Merge Consecutive Queued Messages
+
+## Why
+
+Users and coordinating agents often send several short follow-ups while a
+session is busy. Delivering every follow-up as a separate turn adds latency and
+queue noise even when the messages came from the same source and can safely be
+handled together.
+
+## What
+
+- **Settings > Task Behavior > Message Queue** exposes an
+ **Automatically merge consecutive messages** switch. The install-wide switch
+ is on by default.
+- The switch applies immediately to messages admitted after the setting is
+ read. It does not compact entries that were already pending.
+- When enabled, a newly admitted message is automatically folded into the
+ pending tail entry only when the two entries are consecutive and compatible.
+- The earlier tail entry survives. It keeps its ID, FIFO position, queued time,
+ task, model, plan-mode value, and sender identity. Content and additive
+ context from the new entry follow the earlier entry in submission order.
+- User entries have the same source only when both have the same non-reserved
+ `queued_by` identity. Agent entries have the same source only when both are
+ agent-owned and carry the same non-empty `sender_task_id`.
+- Workflow, server, clarification, durable lifecycle, and reserved in-flight
+ entries are never automatic merge sources or targets. An agent entry without
+ `sender_task_id` is also excluded.
+- Entries must have the same `task_id`, `model`, and `plan_mode`. Metadata other
+ than `entity_references` and `context_files` must be equivalent. This keeps
+ plugin source, sender-session provenance, parent-question state, workflow
+ state, and future metadata from being silently discarded.
+- Merged content uses one blank line between two non-empty bodies. When either
+ body is empty, the other body is retained without an added leading or trailing
+ separator.
+- Attachments retain target-then-source order. Automatic merging occurs only
+ when the combined attachment count and payload size remain within the shared
+ per-message limits.
+- Entity references form a stable target-then-source union, deduplicated by
+ canonical reference, and must remain within the per-message reference limit.
+ Context files form a stable target-then-source union with exact duplicates
+ removed and remain capped at 200 descriptors. Exceeding that cap keeps the
+ newly admitted entry separate.
+- If any compatibility, provenance, metadata, attachment, or reference check
+ fails, the newly admitted message remains a separate FIFO entry. Automatic
+ merge incompatibility never rejects an otherwise valid message.
+- The existing manual **Merge with above** setting and action remain separate.
+ `merge_enabled` controls only the manual action; `auto_merge_enabled` controls
+ only admission-time automatic merging. Either switch may be enabled without
+ the other.
+- No automatic-merge control is added to the queue panel. The queue panel
+ reflects the resulting authoritative queue after admission.
+
+## Data model
+
+The existing install-wide `settings` row with key `message_queue` gains one
+boolean. Its normalized JSON value is:
+
+```json
+{
+ "max_per_session": 10,
+ "merge_enabled": true,
+ "auto_merge_enabled": true
+}
+```
+
+- `auto_merge_enabled` defaults to `true` for a fresh installation.
+- A persisted record that predates this field and omits it also resolves to
+ `true`.
+- An explicit `false` remains false across reloads and backend restarts.
+- The setting has no environment-variable override. The existing environment
+ lock for `max_per_session` does not lock either merge switch.
+
+No queued-message schema changes. A successful automatic merge updates the
+surviving target's content, attachments, and additive metadata, then removes
+the new source entry. A fallback leaves both rows intact.
+
+## API surface
+
+The existing HTTP settings routes retain their authorization and partial-PATCH
+contract:
+
+```text
+GET /api/v1/system/message-queue/settings
+PATCH /api/v1/system/message-queue/settings
+```
+
+GET and PATCH responses include the new field in both views:
+
+```json
+{
+ "settings": {
+ "max_per_session": 10,
+ "merge_enabled": true,
+ "auto_merge_enabled": true
+ },
+ "effective": {
+ "max_per_session": 10,
+ "merge_enabled": true,
+ "auto_merge_enabled": true,
+ "source": "default",
+ "locked": false
+ }
+}
+```
+
+PATCH may name only the new field:
+
+```json
+{ "auto_merge_enabled": false }
+```
+
+Omitted fields remain unchanged. A capacity environment lock blocks only a
+PATCH that names `max_per_session`; an auto-only PATCH leaves the environment's
+effective live capacity unchanged.
+
+No new WebSocket action is added. Existing admissions, including
+`message.queue.add` and inter-task queued delivery, return the surviving target
+entry when an automatic merge succeeds and return the new source entry when it
+stays separate. `message.queue.status_changed` publishes the final queue state.
+The explicit `message.queue.append`, coalescing, restoration, retry, edit,
+reorder, and manual merge contracts do not gain automatic behavior.
+
+## Admission lifecycle and concurrency
+
+1. An admission snapshots `auto_merge_enabled` and the current queue capacity.
+2. Normal validation and capacity admission run first. A queue that is already
+ full still returns `queue_full`; automatic merge does not bypass the cap.
+3. The new entry is durably inserted. File-backed attachments are claimed using
+ the existing task/session ownership checks before any fold is attempted.
+4. When the snapshotted setting is on, the backend atomically checks the new
+ entry and its immediate predecessor and either folds the new entry into that
+ predecessor or leaves both entries unchanged.
+5. The final authoritative queue is published.
+
+Concurrent admissions for one session preserve their admission order. The
+per-session admission serialization spans insertion, any staged attachment
+claim, and the final fold or rollback. A later admission therefore cannot merge
+into a provisional source whose attachment claim may still fail. The tail
+check, target rewrite, and source removal are one repository mutation, so a
+drain, remove, reorder, or manual merge can win before or after it but cannot
+observe a half-merged row. If a concurrent action removes either candidate
+before the automatic fold, no unrelated row is selected as a replacement
+target.
+
+## Permissions
+
+- Signed-in install members may read the configured and effective values under
+ the existing system-settings rules.
+- Only install admins may change `auto_merge_enabled`.
+- Automatic merging grants no new queue mutation permission. It runs only as
+ part of an already-authorized admission and uses persisted sender provenance;
+ callers cannot name a target or claim a reserved sender identity.
+
+## Failure modes
+
+- **Incompatible tail:** the source remains a separate FIFO entry. No error is
+ shown solely because automatic merge was skipped.
+- **Combined limit exceeded:** both messages remain separate; no attachment,
+ context file, or entity reference is truncated.
+- **Attachment claim fails:** the newly inserted source entry is rolled back by
+ its own ID before automatic merge runs. An older target is never removed or
+ rewritten by this rollback.
+- **Automatic fold storage failure after admission:** the transaction leaves
+ both rows separate, the failure is logged, and the already accepted message
+ is not reported as failed in a way that invites a duplicate retry.
+- **Concurrent drain or mutation wins:** the fold is skipped or loses with no
+ partial write; the final queue status reconciles clients.
+- **Invalid legacy settings JSON:** the existing invalid-setting fallback
+ applies. When the record is valid but omits `auto_merge_enabled`, the value is
+ `true`.
+
+## Persistence guarantees
+
+The saved switch survives backend restarts in the existing settings store. A
+restart applies it before new queue admissions. Already merged rows remain
+merged; disabling the switch does not reconstruct source rows. Already separate
+rows remain separate; enabling the switch does not sweep them.
+
+## Responsive and mobile behavior
+
+- Desktop and mobile use the same Message Queue settings card, values, save
+ contributor, permissions, and validation.
+- The new switch is a one-column settings row with a touch-safe control and
+ localized label and description.
+- The existing `settings-scroll-container` remains the only page scroll owner.
+ The switch stays clear of the shared floating save bar and safe-area inset.
+- The mobile queue panel does not gain a new control or scroll region.
+
+## Scenarios
+
+- **GIVEN** a fresh install, **WHEN** an admin opens Message Queue settings,
+ **THEN** automatic merge is shown as enabled.
+- **GIVEN** a legacy `message_queue` setting without `auto_merge_enabled`,
+ **WHEN** the backend starts, **THEN** automatic merge is enabled and the next
+ save writes the normalized field.
+- **GIVEN** automatic merge is enabled and two consecutive compatible user
+ messages are admitted, **WHEN** the second admission completes, **THEN** one
+ queued entry remains with both contents in order and the first entry's
+ identity.
+- **GIVEN** automatic merge is disabled, **WHEN** the same two user messages are
+ admitted, **THEN** two FIFO entries remain.
+- **GIVEN** two consecutive agent messages with the same non-empty
+ `sender_task_id`, **WHEN** the second is admitted, **THEN** they form one
+ agent-owned entry and the surviving ID can be targeted by immediate parent
+ interrupt delivery.
+- **GIVEN** two agent messages from different sender tasks or sender sessions,
+ **WHEN** the later message is admitted, **THEN** it remains separate.
+- **GIVEN** a user message follows an agent message, **WHEN** it is admitted,
+ **THEN** the two remain separate.
+- **GIVEN** a workflow, server, clarification, or reserved lifecycle entry is a
+ source or tail candidate, **WHEN** another message is admitted, **THEN** no
+ automatic fold uses that entry.
+- **GIVEN** two same-source entries differ in task, model, plan mode, plugin
+ source, parent-question metadata, or other non-additive metadata, **WHEN** the
+ later entry is admitted, **THEN** both remain separate with their metadata
+ unchanged.
+- **GIVEN** compatible entries carry attachments, context files, and entity
+ references within all limits, **WHEN** they merge, **THEN** every value is
+ present in target-then-source order with only exact/canonical duplicates
+ removed.
+- **GIVEN** a combined attachment, context-file, or entity-reference limit would
+ be exceeded, **WHEN** the later message is admitted, **THEN** both entries
+ remain separate and the admission succeeds if queue capacity permits.
+- **GIVEN** a compatible file-backed message whose attachment claim fails,
+ **WHEN** admission rolls back, **THEN** the prior tail is byte-for-byte
+ unchanged.
+- **GIVEN** the queue is already at its capacity, **WHEN** a compatible message
+ is submitted, **THEN** the existing `queue_full` response is returned and no
+ row changes.
+- **GIVEN** automatic merge is on and manual merge is off, **WHEN** compatible
+ messages are admitted, **THEN** they merge automatically while the manual
+ action remains unavailable; the inverse switch combination leaves admissions
+ separate while preserving the manual action.
+- **GIVEN** an environment value controls queue capacity, **WHEN** an admin
+ saves only `auto_merge_enabled`, **THEN** the automatic setting changes and
+ the environment-controlled effective and live capacity remain unchanged.
+- **GIVEN** pending rows predate an off-to-on settings change, **WHEN** the
+ setting is saved, **THEN** those rows remain unchanged and only later
+ admissions are eligible.
+- **GIVEN** a phone viewport, **WHEN** an admin changes the switch and saves,
+ **THEN** the value persists without horizontal overflow, nested scrolling, or
+ overlap with the shared save bar.
+
+## Out of scope
+
+- Retroactively compacting an existing queue.
+- Merging non-consecutive entries or searching past an incompatible tail.
+- A per-workspace, per-task, per-session, or queue-panel override.
+- Changing the manual **Merge with above** compatibility or error behavior.
+- Changing `message.queue.append`, workflow coalescing, delivery restoration,
+ retry, send-now, edit, or reorder semantics.
+- Bypassing queue capacity because a message might merge.
+
+## Implementation plan
+
+[Implementation plan](../../plans/message-queue-auto-merge/plan.md)
diff --git a/docs/specs/ui/message-queue-management.md b/docs/specs/ui/message-queue-management.md
index 1f0a83ae9b..6378fd89e7 100644
--- a/docs/specs/ui/message-queue-management.md
+++ b/docs/specs/ui/message-queue-management.md
@@ -26,6 +26,9 @@ remains full. Those same rows have no individual remove action.
- Removal does not change provenance rules for other operations. Only
user-owned messages may be edited. Merge behavior remains defined by
[Merge Enqueued Messages Individually](message-queue-merge.md), and
+ admission-time compaction is defined by
+ [Automatically Merge Consecutive Queued Messages](message-queue-auto-merge.md).
+ The two merge settings are independent. The
interrupt-and-dispatch behavior is defined by
[Send Queued Messages Now](message-queue-send-now.md).
- Successful removal publishes the existing
@@ -41,8 +44,9 @@ remains full. Those same rows have no individual remove action.
### Capacity setting
-- **Settings > General > Message Queue** exposes the maximum number of persisted
- messages allowed per session.
+- **Settings > Task Behavior > Message Queue** exposes the maximum number of
+ persisted messages allowed per session alongside independent manual and
+ automatic merge switches.
- The default is `10`. A positive integer sets a cap; `0` means unlimited.
- A saved setting applies immediately to later admissions. Existing entries
are never trimmed. If a queue already exceeds a newly lowered limit, new
@@ -90,9 +94,15 @@ GET response and PATCH response:
```json
{
- "settings": { "max_per_session": 10 },
+ "settings": {
+ "max_per_session": 10,
+ "merge_enabled": true,
+ "auto_merge_enabled": true
+ },
"effective": {
"max_per_session": 10,
+ "merge_enabled": true,
+ "auto_merge_enabled": true,
"source": "default",
"locked": false
}
@@ -106,9 +116,11 @@ persisted value, or the default when no override exists. PATCH accepts:
{ "max_per_session": 25 }
```
-PATCH rejects negative values with `400`, rejects a valid environment lock
-with `409`, and requires the admin role. GET requires an authenticated install
-user under the existing system-route middleware.
+PATCH is partial: omitted capacity, manual-merge, and automatic-merge fields
+remain unchanged. It rejects negative capacity with `400`, rejects a valid
+environment capacity lock with `409` only when the patch names
+`max_per_session`, and requires the admin role. GET requires an authenticated
+install user under the existing system-route middleware.
## Data Model
@@ -116,7 +128,11 @@ No queue-table migration is required. Capacity uses the existing install-wide
`settings` table under key `message_queue` with JSON value:
```json
-{ "max_per_session": 25 }
+{
+ "max_per_session": 25,
+ "merge_enabled": true,
+ "auto_merge_enabled": true
+}
```
`queued_by` stays unchanged as immutable provenance. The existing
@@ -210,8 +226,9 @@ starts accepting work.
**THEN** no capacity error is produced solely by queue length and the page
explains that the queue is unlimited.
- **GIVEN** a phone viewport, **WHEN** the user opens the queue panel and the
- General settings menu, **THEN** remove/clear are touch-sized and Message Queue
- is reachable without horizontal overflow or a second page scroll owner.
+ Task Behavior settings menu, **THEN** remove/clear are touch-sized and Message
+ Queue is reachable without horizontal overflow or a second page scroll
+ owner.
## Out of Scope
diff --git a/docs/specs/ui/message-queue-merge.md b/docs/specs/ui/message-queue-merge.md
index 670d93f035..d1cebc4aa2 100644
--- a/docs/specs/ui/message-queue-merge.md
+++ b/docs/specs/ui/message-queue-merge.md
@@ -7,8 +7,12 @@ owner: kandev
# Merge Enqueued Messages Individually
Queue-wide removal and capacity behavior is defined separately by
-[Manage Pending Message Queues](message-queue-management.md). This spec governs
-only the merge operation.
+[Manage Pending Message Queues](message-queue-management.md), and admission-time
+compaction is defined by
+[Automatically Merge Consecutive Queued Messages](message-queue-auto-merge.md).
+This spec governs only the manual merge operation. Its `merge_enabled` setting
+does not control automatic merging, and `auto_merge_enabled` does not change
+the behavior or compatibility rules below.
## Why