fix: preserve queued turn state during workflow transitions (#3542)

This commit is contained in:
Carlos Florêncio
2026-09-09 11:08:11 +01:00
committed by GitHub
parent 48f6c773dc
commit 25125638df
8 changed files with 522 additions and 3 deletions
@@ -535,6 +535,8 @@ func (s *Service) executeStepTransition(ctx context.Context, taskID, sessionID s
if !ok {
return
}
// The legacy engine-less path settles before dispatch. The engine-backed
// path preserves an admitted turn's RUNNING state in its transition hook.
s.setSessionWaitingForInput(ctx, taskID, effectiveSession.ID)
}
}
@@ -6145,7 +6147,11 @@ func (s *Service) applyEngineTransitionWithCommitMode(
if !ok {
return false
}
s.setSessionWaitingForInput(ctx, taskID, effectiveSession.ID)
// A queued prompt has already claimed RUNNING before its turn-start
// hook. Preserve that claim when profile preparation keeps the session.
if effectiveSession.ID != session.ID || session.State != models.TaskSessionStateRunning {
s.setSessionWaitingForInput(ctx, taskID, effectiveSession.ID)
}
return true
}
@@ -0,0 +1,93 @@
package orchestrator
import (
"context"
"encoding/json"
"testing"
"github.com/kandev/kandev/internal/orchestrator/executor"
"github.com/kandev/kandev/internal/orchestrator/messagequeue"
"github.com/kandev/kandev/internal/task/models"
v1 "github.com/kandev/kandev/pkg/api/v1"
"github.com/stretchr/testify/require"
)
// @covers AC-TASKS-RUNTIME-STATE-PUBLICATION-ORDER-001.5
func TestSendNowWorkflowTransitionPreservesRunningState(t *testing.T) {
for _, delivery := range []string{"send_now", "fifo"} {
t.Run(delivery, func(t *testing.T) {
ctx := context.Background()
repo := setupTestRepo(t)
steps, ids := buildWorkflowFromJSON(t, developmentWorkflowJSON)
seedSession(t, repo, "task", "session", ids["Done"])
seedExecutorRunning(t, repo, "session", "task", "exec")
setSessionState(t, ctx, repo, "session", models.TaskSessionStateWaitingForInput)
eventBus := &recordingEventBus{}
agent := &mockAgentManager{isAgentRunning: true, repoForExecutionLookup: repo}
svc := createEngineService(t, repo, steps, agent)
svc.eventBus = eventBus
svc.taskRepo = newTaskServiceStateRepository(repo, eventBus)
svc.turnService = &repoTurnService{repo: repo}
svc.messageCreator = &mockMessageCreator{}
// Inspect state at provider dispatch, before any completion can heal it.
agent.promptAgentFunc = func(ctx context.Context, _ string, prompt string, _ []v1.MessageAttachment, _ bool) (*executor.PromptResult, error) {
session, err := repo.GetTaskSession(ctx, "session")
require.NoError(t, err)
require.Equal(t, models.TaskSessionStateRunning, session.State)
task, err := repo.GetTask(ctx, "task")
require.NoError(t, err)
require.Equal(t, ids["In Progress"], task.WorkflowStepID)
require.Equal(t, v1.TaskStateInProgress, task.State)
turn, err := svc.turnService.GetActiveTurn(ctx, "session")
require.NoError(t, err)
require.NotNil(t, turn)
require.Equal(t, "continue", prompt)
for _, published := range eventBus.events {
data, err := json.Marshal(published.event.Data)
require.NoError(t, err)
var state struct {
NewState string `json:"new_state"`
}
require.NoError(t, json.Unmarshal(data, &state))
require.NotEqual(t, "WAITING_FOR_INPUT", state.NewState)
require.NotEqual(t, "REVIEW", state.NewState)
}
return &executor.PromptResult{StopReason: "dispatched"}, nil
}
queued, err := svc.messageQueue.QueueMessage(ctx, "session", "task", "continue", "", messagequeue.QueuedByUser, false, nil)
require.NoError(t, err)
if delivery == "send_now" {
claim, err := svc.messageQueue.ClaimSendNow(ctx, "session", []messagequeue.QueuedMessage{*queued})
require.NoError(t, err)
svc.markQueuedDispatchInFlight("session", claim.Dispatch.ID)
svc.executeSendNowClaim(claim)
} else {
var taken bool
queued, taken = svc.messageQueue.TakeQueued(ctx, "session")
require.True(t, taken)
require.NotNil(t, queued)
svc.executeQueuedMessage("session", queued)
}
require.Len(t, agent.capturedPrompts, 1)
require.Len(t, svc.messageCreator.(*mockMessageCreator).userMessages, 1)
})
}
}
func TestOnTurnStartBeforeAdmissionKeepsSessionPromptable(t *testing.T) {
ctx := context.Background()
repo := setupTestRepo(t)
steps, ids := buildWorkflowFromJSON(t, developmentWorkflowJSON)
seedSession(t, repo, "task", "session", ids["Done"])
setSessionState(t, ctx, repo, "session", models.TaskSessionStateWaitingForInput)
svc := createEngineService(t, repo, steps, &mockAgentManager{repoForExecutionLookup: repo})
result, err := svc.ProcessOnTurnStart(ctx, "task", "session")
require.NoError(t, err)
require.False(t, result.Queued)
session, err := repo.GetTaskSession(ctx, "session")
require.NoError(t, err)
require.Equal(t, models.TaskSessionStateWaitingForInput, session.State)
task, err := repo.GetTask(ctx, "task")
require.NoError(t, err)
require.Equal(t, ids["In Progress"], task.WorkflowStepID)
}
@@ -132,7 +132,7 @@ var workflowTestCases = []workflowTestCase{
ExpectTransitioned: true, ExpectQueued: false, ExpectResets: 1},
// Agent starts at New Context → on_turn_start → back to In Progress
{Trigger: engine.TriggerOnTurnStart, SetRunning: true, ExpectStep: "In Progress",
ExpectTransitioned: true, ExpectQueued: false, ExpectResets: 1},
ExpectTransitioned: true, ExpectQueued: false, ExpectResets: 1, ExpectState: models.TaskSessionStateRunning},
// Agent finishes at In Progress → New Context again (same reset + auto_start path)
{Trigger: engine.TriggerOnTurnComplete, SetRunning: true, ExpectStep: "New Context",
ExpectTransitioned: true, ExpectQueued: false, ExpectResets: 2},
@@ -144,7 +144,7 @@ var workflowTestCases = []workflowTestCase{
ExpectTransitioned: true, ExpectQueued: false, ExpectResets: 3},
// User sends message at Done → on_turn_start → In Progress
{Trigger: engine.TriggerOnTurnStart, SetRunning: true, ExpectStep: "In Progress",
ExpectTransitioned: true, ExpectQueued: false, ExpectResets: 3},
ExpectTransitioned: true, ExpectQueued: false, ExpectResets: 3, ExpectState: models.TaskSessionStateRunning},
},
},
{
@@ -0,0 +1,109 @@
import { expect, type Page } from "@playwright/test";
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 { SidebarFilterPopoverPage } from "../../pages/sidebar-filter-popover";
// @covers AC-TASKS-RUNTIME-STATE-PUBLICATION-ORDER-001.5
// @covers AC-TASKS-RUNTIME-STATE-PUBLICATION-ORDER-001.6
export async function expectSendNowWorkflowRunning(
page: Page,
api: ApiClient,
seed: SeedData,
mobile: boolean,
): Promise<void> {
const workflow = await api.createWorkflow(seed.workspaceId, "Queued turn state");
const review = await api.createWorkflowStep(workflow.id, "Review", 0, { is_start_step: true });
const working = await api.createWorkflowStep(workflow.id, "In Progress", 1);
await api.createWorkflowStep(workflow.id, "Done", 2);
const task = await api.createTaskWithAgent(
seed.workspaceId,
"Queued workflow state",
seed.agentProfileId,
{
description: "/e2e:simple-message",
workflow_id: workflow.id,
workflow_step_id: review.id,
repository_ids: [seed.repositoryId],
},
);
if (!task.session_id) throw new Error("Task did not return a session ID");
const sessionId = task.session_id;
await page.goto(`/t/${task.id}`);
const session = new SessionPage(page);
await session.waitForLoad();
await session.waitForChatIdle();
await waitForSessionState(api, {
taskId: task.id,
sessionId,
expectedState: "WAITING_FOR_INPUT",
message: "queued workflow session should settle before queue setup",
});
await api.updateWorkflowStep(review.id, {
events: { on_turn_start: [{ type: "move_to_step", config: { step_id: working.id } }] },
});
const identity = await api.getQueueSessionIdentity(task.id, sessionId);
await api.setQueueAutoRun(identity, false);
await api.queueMessage(
identity,
[
'e2e:message("replacement is active")',
// Flush the text buffer before holding the mock turn open.
'e2e:tool_use("Check", {})',
'e2e:tool_result("checked")',
"e2e:delay(15000)",
'e2e:message("replacement finished")',
].join("\n"),
);
const chat = session.activeChat();
const chip = chat.getByTestId("queue-chip");
if (mobile) await chip.tap();
else await chip.click();
const row = chat.getByTestId("queue-entry");
await expect(row).toHaveCount(1);
if (!mobile) await row.hover();
const sendNow = row.getByTestId("queue-entry-send-now");
if (mobile) await sendNow.tap();
else await sendNow.click();
const agentBodies = chat.locator("[data-agent-message-body][data-message-id]");
await expect(agentBodies.filter({ hasText: "replacement is active" })).toBeVisible();
await waitForSessionState(api, {
taskId: task.id,
sessionId,
expectedState: "RUNNING",
message: "Send Now should keep the queued workflow turn running",
});
const runningTask = await api.getTask(task.id);
expect(runningTask.state).toBe("IN_PROGRESS");
expect(runningTask.workflow_step_id).toBe(working.id);
await expect(session.cancelAgentButton()).toBeVisible();
if (mobile) await page.getByTestId("mobile-session-menu").tap();
const surface = mobile
? page.getByRole("dialog", { name: "Tasks", exact: true })
: session.sidebar;
const filters = new SidebarFilterPopoverPage(page);
if (mobile) await surface.getByTestId("sidebar-filter-gear").tap();
else await filters.open();
await filters.setGroup("State");
await filters.close();
await expect(
surface.locator('[data-testid="sidebar-group-header"][data-group-key="IN_PROGRESS"]'),
).toBeVisible();
await expect(
surface.locator(`[data-task-row-id="${task.id}"]`).getByTestId("task-state-running"),
).toBeVisible();
if (mobile) await page.keyboard.press("Escape");
await waitForSessionState(api, {
taskId: task.id,
sessionId,
expectedState: "WAITING_FOR_INPUT",
message: "queued workflow turn should settle after its replacement prompt",
});
await expect(agentBodies.filter({ hasText: "replacement finished" })).toBeVisible();
await expect.poll(async () => (await api.getTask(task.id)).state).toBe("REVIEW");
await expect(session.cancelAgentButton()).toBeHidden();
}
@@ -7,6 +7,7 @@ import { SessionPage } from "../../pages/session-page";
import { seedRunningGeneratingSession } from "../../helpers/generating-session";
import { expectFullQueueScrolls, seedFullQueueTask } from "./message-queue-scroll-helpers";
import { waitForQuickChatComposerReady } from "./quick-chat-helpers";
import { expectSendNowWorkflowRunning } from "./message-queue-workflow-helpers";
import {
registerSeparateQueueRows,
requestMessageQueueSettings,
@@ -14,6 +15,10 @@ import {
registerSeparateQueueRows(test);
test("Send Now keeps a workflow transition running", async ({ testPage, apiClient, seedData }) => {
await expectSendNowWorkflowRunning(testPage, apiClient, seedData, false);
});
// ---------------------------------------------------------------------------
// Quick Chat queue tests
// ---------------------------------------------------------------------------
@@ -8,9 +8,18 @@ import { expectFullQueueScrolls, seedFullQueueTask } from "./message-queue-scrol
import { registerSeparateQueueRows } from "../../helpers/message-queue-settings";
import { assertNoDocumentHorizontalOverflow } from "../../helpers/layout-assertions";
import { waitForActiveSessionForegroundActivity } from "../../helpers/session-store";
import { expectSendNowWorkflowRunning } from "./message-queue-workflow-helpers";
registerSeparateQueueRows(test);
test("mobile Send Now keeps a workflow transition running", async ({
testPage,
apiClient,
seedData,
}) => {
await expectSendNowWorkflowRunning(testPage, apiClient, seedData, true);
});
async function expectTouchTarget(locator: Locator): Promise<void> {
await expect(locator).toBeVisible();
const box = await locator.boundingBox();
+162
View File
@@ -0,0 +1,162 @@
---
created: 2026-09-09
status: complete
requirements:
- REQ-TASKS-RUNTIME-STATE-PUBLICATION-ORDER-001
system_design:
- ../../specs/tasks/system-design/runtime-state-publication-order.md
legacy_specs: []
---
# Implementation Plan: Queued turn-start state
## Overview
Keep an admitted queued turn running when its start moves the workflow step.
One work order repairs the backend lifecycle and verifies the visible result.
This is conformance with the existing runtime-state contract, especially
AC-TASKS-RUNTIME-STATE-PUBLICATION-ORDER-001.5. Requirements need no change.
## Confirmed cause and evidence
The reported task is `9f724e85-e643-4ee8-a60b-24efdb98f01b`; the affected
session is `8bce4cee-e40c-44f5-817b-c8e5ee48c925`.
Retained backend evidence was read from
`/root/.kandev/logs/backend-logs-2026-09-09-000007.log`, lines 66351–66417.
Times below are UTC on 2026-09-09; retained log timestamps use UTC+01:00.
| Time | Backend event |
| --- | --- |
| 08:23:53.227 | Send Now claims one queued message. |
| 08:23:53.240 | The session is published as RUNNING. |
| 08:23:53.244 | Turn `42f8517d-aee0-435e-a22d-b9b5ac5c65f2` starts. |
| 08:23:53.254 | The workflow evaluates an on_turn_start transition. |
| 08:23:53.264 | Task update publishes the destination step and RUNNING session. |
| 08:23:53.267 | The session changes from RUNNING to WAITING_FOR_INPUT. |
| 08:23:53.280 | Task state changes to REVIEW, retaining the destination step. |
| 08:23:53.281 | The executor sends the queued prompt to the agent. |
`promptSendNowClaim` calls `promptTask` with an `afterClaim` callback.
Prompt admission first sets the session running and creates its turn.
The callback then calls `processOnTurnStartViaEngine`.
`applyEngineTransitionWithCommitMode`, in `transitionLifecycleOnTurnStart`
mode, unconditionally calls `setSessionWaitingForInput` after profile
preparation. That helper also reconciles the task to Review. Prompt dispatch
continues afterward without restoring the admitted session state.
The browser receives and applies these server events. The earlier primary
session mismatch briefly delays card updates, but the primary session is
correct before the final reversal. No delayed cancellation event is needed
to explain the defect. Metadata-only state events (`newState=-`) are separate.
The existing `workflow_e2e_test.go` table includes running on_turn_start
transitions but omits `ExpectState` on those rows.
## Scope
### In scope
- Preserve current-turn ownership and running state across a successful
same-session on_turn_start transition.
- Verify Send Now and the ordinary queued-dispatch caller of the same hook.
- Preserve pre-admission transitions, profile preparation, WIP admission,
failure cleanup, and legitimate end-of-turn settlement.
- Prove desktop and mobile receive the same corrected authoritative state.
### Out of scope
- Client-side state overrides, new state values, and changes to grouping.
- Queue ordering, cancellation policy, schema changes, and Office ownership.
- Changes to the reported live task or its agent process.
## Technical approach
Repair the on_turn_start lifecycle branch in
`apps/backend/internal/orchestrator/event_handlers_workflow.go`.
Distinguish a transition for an already-admitted turn from preparation for a
future prompt. Preserve the admitted turn when the effective session remains
the same. Keep profile-switch and failure behavior explicit; do not remove
all waiting-state writes across lifecycle modes.
The implementation uses the session state and effective session ID already
available in the lifecycle branch. No new context parameter or turn registry
is necessary. The per-session cancellation guard and queue claim order remain
unchanged. The hook does not write RUNNING afterward, so it cannot revive a
cancelled turn.
The implementation must not depend on a later activity event healing the state.
The existing task publication order and client freshness design remain valid.
## Tests
Add `queue_send_now_workflow_state_test.go` with
`TestSendNowWorkflowTransitionPreservesRunningState`. Use the real queue,
workflow engine, prompt admission, and repository with a controllable executor.
Inspect state synchronously inside the fake provider dispatch, before it
returns. Assert session RUNNING, task IN_PROGRESS, the target workflow step,
an active turn, and one prompt. Capture lifecycle publications and reject any
waiting/review reversal before dispatch returns.
Add paired tests for the pre-admission hook and ordinary FIFO callback.
Retain coverage for profile changes, WIP deferral, failed dispatch, explicit
cancellation, and genuine turn completion. Strengthen the existing workflow
table's on_turn_start rows with state assertions.
These checks cover AC-TASKS-RUNTIME-STATE-PUBLICATION-ORDER-001.1, .2, .5,
and .8. Desktop/mobile evidence covers .6.
## E2E tests
Extend `apps/web/e2e/tests/chat/message-queue.spec.ts` with
`Send Now keeps a workflow transition running`. Start from a settled session
with a queued slow prompt and a Review-to-In-Progress on_turn_start action.
Click Send Now and assert the running composer and State-grouped sidebar
before completion, without reloading. Assert legitimate settlement afterward.
Extend `apps/web/e2e/tests/chat/mobile-message-queue-management.spec.ts` with
the same scenario using the existing touch queue control and task switcher.
Shared data semantics are the mobile requirement; layout and navigation stay
within the existing composition. Reuse the existing queue test fixtures.
## Work orders
- [x] [Task 01: Preserve admitted turn state](task-01-preserve-admitted-turn.md)
## Verification results
The Send Now and FIFO regressions first failed with WAITING_FOR_INPUT instead
of RUNNING at provider dispatch. Both pass after the lifecycle fix. The
pre-admission case and strengthened workflow table also pass.
The following targeted orchestrator groups passed with `-race -tags fts5`:
- `Test.*(SendNow|SendQueuedNow|Queued|Workflow|OnTurnStart|PromptTask|Cancel)`
- `Test.*(ApplyEngineTransition|PrepareWorkflowStepSession|SwitchSessionForProfile|WIP|Wip)`
- `Test(SwitchSessionForStep|ProcessOnEnter_ProfileSwitch)`
Desktop Chromium: two Send Now tests passed. Mobile Chrome: two Send Now tests
passed. These include the new workflow regression and existing priority-order
coverage on each platform. The new cases verify the running composer, State
grouping, authoritative session/task state, and legitimate completion without
a reload.
The initial browser failures exposed test-fixture issues: buffered mock text
needed a tool boundary, and the mobile filter trigger needed drawer scope.
The corrected fixtures pass. Focused ESLint and Prettier checks also pass.
Managed E2E runs used disposable containers and mock agents. The first run
built the artifacts. Later runs reused those artifacts with `--no-build`.
The runners removed their containers after each run. The reported live task,
session, and runtime were not changed.
Public docs need no change. This fix restores the behavior already described
in `docs/public/sessions-and-review.md` and the runtime-state specification.
## Risks
- The shared hook runs both before and after prompt admission; a blanket change
could make a prepared session busy without a dispatched prompt.
- Profile switching can replace the effective session. Never transfer the old
session's running state to an unstarted replacement.
- WIP deferral and genuine cancellation must still prevent dispatch or settle
the correct turn.
@@ -0,0 +1,135 @@
---
id: "01-preserve-admitted-turn"
title: "Preserve admitted turn state"
status: done
wave: 1
depends_on: []
plan: "plan.md"
requirements:
- REQ-TASKS-RUNTIME-STATE-PUBLICATION-ORDER-001
acceptance_criteria:
- AC-TASKS-RUNTIME-STATE-PUBLICATION-ORDER-001.1
- AC-TASKS-RUNTIME-STATE-PUBLICATION-ORDER-001.2
- AC-TASKS-RUNTIME-STATE-PUBLICATION-ORDER-001.5
- AC-TASKS-RUNTIME-STATE-PUBLICATION-ORDER-001.6
- AC-TASKS-RUNTIME-STATE-PUBLICATION-ORDER-001.8
system_design:
- ../../specs/tasks/system-design/runtime-state-publication-order.md
---
# Task 01: Preserve admitted turn state
## Summary
Repair the successful on_turn_start lifecycle for an already-admitted queued
turn. Keep the task and session projections consistent while that turn runs.
## In scope
- Write the failing `TestSendNowWorkflowTransitionPreservesRunningState`
regression before changing production code.
- Exercise the real Send Now claim, prompt admission, workflow transition,
repository writes, and lifecycle publication path. Inspect state inside
the fake executor before its prompt call returns.
- Add the corresponding ordinary FIFO and pre-admission transition cases.
- Correct the shared transition branch with existing turn/session ownership.
- Add desktop and mobile queue regressions described in the plan.
- Verify profile switching, WIP deferral, cancellation, failed prompts, and
real completion retain their existing behavior.
## Out of scope
Frontend production changes, queue policy changes, persistence migrations,
Office state ownership, and live-instance intervention.
## Acceptance
1. Same-session Send Now and FIFO transitions keep the admitted turn RUNNING
and task IN_PROGRESS, with one dispatched prompt and no false idle events.
2. Preparation without an admitted turn, changed profiles, WIP deferral, and
real settlement remain correct. The fix preserves claim serialization.
3. Desktop State grouping and the mobile task surface show active work without
a reload; genuine completion restores the expected idle state.
## Verification
Run the new backend regression first and record its expected state failure.
Then implement and run the focused package cases from `apps/backend`:
```bash
rtk go test -tags fts5 ./internal/orchestrator -run 'TestSendNowWorkflowTransitionPreservesRunningState' -count=1
rtk go test -race -tags fts5 ./internal/orchestrator -run 'Test.*(SendNow|SendQueuedNow|Queued|Workflow|OnTurnStart|PromptTask|Cancel)' -count=1
```
Before the first pnpm command in a fresh worktree, run from `apps`:
```bash
rtk pnpm install --frozen-lockfile
```
Run sequentially from `apps/web`; each managed run builds its artifacts:
```bash
rtk pnpm e2e:run --project chromium tests/chat/message-queue.spec.ts -- --grep 'Send Now'
rtk pnpm e2e:run --project mobile-chrome tests/chat/mobile-message-queue-management.spec.ts -- --grep 'Send Now'
```
Use the same test title fragment for the added mobile scenario. Confirm test
discovery and record command results. Do not run against the user's instance.
## Files likely touched
- `apps/backend/internal/orchestrator/event_handlers_workflow.go`
- `apps/backend/internal/orchestrator/queue_send_now.go` (only if context is needed)
- `apps/backend/internal/orchestrator/event_handlers_agent.go` (paired FIFO path)
- `apps/backend/internal/orchestrator/task_operations.go` (admission context only)
- `apps/backend/internal/orchestrator/queue_send_now_workflow_state_test.go`
- `apps/backend/internal/orchestrator/workflow_e2e_test.go`
- `apps/web/e2e/tests/chat/message-queue.spec.ts`
- `apps/web/e2e/tests/chat/mobile-message-queue-management.spec.ts`
## Dependencies
None.
## Risks
An unconditional removal of idle settlement can break replacement-session
preparation. An unconditional post-hook RUNNING write can revive a cancelled
or superseded turn. Preserve ownership instead of repairing state afterward.
## Parallelism
`sequential`
## Inputs
- [Confirmed trace and repair approach](plan.md).
- [Runtime-state requirements](../../specs/tasks/requirements/runtime-state-publication-order.md).
- [Runtime-state design](../../specs/tasks/system-design/runtime-state-publication-order.md).
- `queue_send_now_test.go`: existing real queue claim and executor fixture.
- `workflow_e2e_test.go`: existing workflow engine fixture and turn-start rows.
- Read scoped backend/web guidance and the TDD/E2E skills before implementation.
## Results
Implemented in the successful on_turn_start branch. A same-session transition
preserves an admitted RUNNING state. Pre-admission and replacement-session
preparation retain their waiting-state behavior.
The Send Now and FIFO tests failed before the fix and pass afterward. Their
fake provider inspects state synchronously at dispatch, so no channel or
timing-dependent pause is necessary. The tests also reject false idle events
and duplicate prompts or user messages.
The focused race-enabled groups passed, including prompt cancellation,
workflow transitions, profile changes, and WIP handling. Desktop and mobile
Send Now suites each passed both selected tests. Both new browser cases
verify active state without a reload and normal settlement after completion.
Focused ESLint and Prettier checks passed.
The shared browser fixture is
`apps/web/e2e/tests/chat/message-queue-workflow-helpers.ts`. It flushes mock
text at a tool boundary and scopes mobile navigation to the Tasks drawer.
No frontend production changes or live-instance mutations were necessary.
See [verification details](plan.md#verification-results).