fix(ui): remove stale parked session note (#3900)
This commit is contained in:
@@ -314,6 +314,7 @@ func (a *mockAgent) LoadSession(ctx context.Context, req acp.LoadSessionRequest)
|
||||
return acp.LoadSessionResponse{}, ctx.Err()
|
||||
}
|
||||
}
|
||||
sessionMCPServers := registerACPMcpServers(req.McpServers)
|
||||
a.mu.Lock()
|
||||
if a.sessions == nil {
|
||||
a.sessions = make(map[acp.SessionId]bool)
|
||||
@@ -324,7 +325,11 @@ func (a *mockAgent) LoadSession(ctx context.Context, req acp.LoadSessionRequest)
|
||||
if a.commandsEmitted == nil {
|
||||
a.commandsEmitted = make(map[acp.SessionId]bool)
|
||||
}
|
||||
if a.sessionMCPServers == nil {
|
||||
a.sessionMCPServers = make(map[acp.SessionId]map[string]mcpServerDef)
|
||||
}
|
||||
a.sessions[req.SessionId] = true
|
||||
a.sessionMCPServers[req.SessionId] = cloneMCPServerDefs(sessionMCPServers)
|
||||
configOptions, ok := a.sessionConfig[req.SessionId]
|
||||
if !ok {
|
||||
a.sessionConfig[req.SessionId] = mockSessionConfigOptions()
|
||||
|
||||
@@ -73,6 +73,41 @@ func TestLoadSessionReturnsCapabilitiesForResumedSession(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadSessionRetainsMCPServersForResumedPrompt(t *testing.T) {
|
||||
sessionID := acp.SessionId("session-load-mcp-test")
|
||||
priorServers := mcpServers
|
||||
mcpServers = nil
|
||||
t.Cleanup(func() { mcpServers = priorServers })
|
||||
agent := &mockAgent{
|
||||
conn: &promptCancelUpdater{started: make(chan struct{})},
|
||||
sessions: make(map[acp.SessionId]bool),
|
||||
sessionConfig: make(map[acp.SessionId][]acp.SessionConfigOption),
|
||||
sessionMCPServers: make(map[acp.SessionId]map[string]mcpServerDef),
|
||||
commandsEmitted: make(map[acp.SessionId]bool),
|
||||
}
|
||||
request := acp.LoadSessionRequest{
|
||||
SessionId: sessionID,
|
||||
McpServers: []acp.McpServer{{
|
||||
Sse: &acp.McpServerSseInline{
|
||||
Name: "resumed-test",
|
||||
Type: "sse",
|
||||
Url: "http://127.0.0.1:1/sse",
|
||||
},
|
||||
}},
|
||||
}
|
||||
|
||||
if _, err := agent.LoadSession(context.Background(), request); err != nil {
|
||||
t.Fatalf("LoadSession() error = %v", err)
|
||||
}
|
||||
server, ok := agent.sessionMCPServers[sessionID]["resumed-test"]
|
||||
if !ok {
|
||||
t.Fatal("LoadSession() did not retain MCP server for the resumed session")
|
||||
}
|
||||
if server.URL != "http://127.0.0.1:1/sse" {
|
||||
t.Fatalf("resumed MCP server URL = %q, want %q", server.URL, "http://127.0.0.1:1/sse")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetSessionConfigOptionRejectsUnknownValue(t *testing.T) {
|
||||
sessionID := acp.SessionId("session-config-test")
|
||||
agent := &mockAgent{
|
||||
|
||||
@@ -2,11 +2,7 @@
|
||||
import { act, cleanup, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { TaskStatusSummaryLaunchQueue } from "@/lib/types/task-status-summary";
|
||||
import {
|
||||
hasWorkflowParkingMarker,
|
||||
LaunchQueueStatus,
|
||||
ParkedSessionNote,
|
||||
} from "./launch-queue-status";
|
||||
import { LaunchQueueStatus } from "./launch-queue-status";
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
@@ -175,52 +171,3 @@ describe("LaunchQueueStatus retry copy", () => {
|
||||
expect(screen.queryByTestId("launch-queue-session-capacity-link")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("workflow parking presentation", () => {
|
||||
it.each([
|
||||
[undefined, false],
|
||||
[{}, false],
|
||||
[
|
||||
{
|
||||
workflow_parking: {
|
||||
stamp: "",
|
||||
parked_at: "2026-09-16T20:00:00Z",
|
||||
source_session_id: "source",
|
||||
},
|
||||
},
|
||||
false,
|
||||
],
|
||||
[{ workflow_parking: { stamp: "stamp", parked_at: "", source_session_id: "source" } }, false],
|
||||
[
|
||||
{
|
||||
workflow_parking: {
|
||||
stamp: "stamp",
|
||||
parked_at: "2026-09-16T20:00:00Z",
|
||||
source_session_id: "",
|
||||
},
|
||||
},
|
||||
false,
|
||||
],
|
||||
[
|
||||
{
|
||||
workflow_parking: {
|
||||
stamp: "stamp",
|
||||
parked_at: "2026-09-16T20:00:00Z",
|
||||
source_session_id: "source",
|
||||
},
|
||||
},
|
||||
true,
|
||||
],
|
||||
])("validates %j as %s", (metadata, expected) => {
|
||||
expect(hasWorkflowParkingMarker(metadata as Record<string, unknown> | undefined)).toBe(
|
||||
expected,
|
||||
);
|
||||
});
|
||||
|
||||
it("renders the note only when the selected session is parked", () => {
|
||||
const { rerender } = render(<ParkedSessionNote visible={false} />);
|
||||
expect(screen.queryByTestId("task-parked-session-note")).toBeNull();
|
||||
rerender(<ParkedSessionNote visible />);
|
||||
expect(screen.getByTestId("task-parked-session-note")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -167,33 +167,3 @@ function LaunchQueueStatusContent({
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function hasWorkflowParkingMarker(
|
||||
metadata: Record<string, unknown> | null | undefined,
|
||||
): boolean {
|
||||
const marker = metadata?.workflow_parking;
|
||||
if (!marker || typeof marker !== "object" || Array.isArray(marker)) return false;
|
||||
const value = marker as Record<string, unknown>;
|
||||
return (
|
||||
typeof value.stamp === "string" &&
|
||||
value.stamp.trim() !== "" &&
|
||||
typeof value.parked_at === "string" &&
|
||||
value.parked_at.trim() !== "" &&
|
||||
typeof value.source_session_id === "string" &&
|
||||
value.source_session_id.trim() !== ""
|
||||
);
|
||||
}
|
||||
|
||||
export function ParkedSessionNote({ visible }: { visible: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
if (!visible) return null;
|
||||
return (
|
||||
<div
|
||||
data-testid="task-parked-session-note"
|
||||
role="note"
|
||||
className="min-w-0 shrink-0 border-b border-border/70 bg-muted/10 px-3 py-2 text-xs text-muted-foreground"
|
||||
>
|
||||
{t("task:parkedSessionNote")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -59,11 +59,7 @@ import { useTaskLaunchErrorContext } from "./task-launch-error-context";
|
||||
import { useTaskStatusSummary } from "@/hooks/domains/task/use-task-status-summary";
|
||||
import { TaskMarkdownFileLinkProvider } from "@/components/shared/task-markdown-file-link-provider";
|
||||
import { statusSummaryTaskError } from "@/lib/task-status-summary";
|
||||
import {
|
||||
hasWorkflowParkingMarker,
|
||||
LaunchQueueStatus,
|
||||
ParkedSessionNote,
|
||||
} from "./launch-queue-status";
|
||||
import { LaunchQueueStatus } from "./launch-queue-status";
|
||||
import { WipQueueStatus } from "./wip-queue-status";
|
||||
import { useLateClarificationMessage } from "@/hooks/use-late-clarification-message";
|
||||
|
||||
@@ -1253,7 +1249,6 @@ export const TaskChatPanel = memo(function TaskChatPanel({
|
||||
>
|
||||
{!hideLaunchQueueStatus && <LaunchQueueStatus queue={launchStatusSummary?.launch_queue} />}
|
||||
{!hideWipQueueStatus && <WipQueueStatus taskId={summaryTaskId} />}
|
||||
<ParkedSessionNote visible={hasWorkflowParkingMarker(session?.metadata)} />
|
||||
<PanelBody
|
||||
padding={false}
|
||||
scroll={false}
|
||||
|
||||
@@ -376,7 +376,8 @@ test.describe("shared panel toolbars", () => {
|
||||
|
||||
await session.clickTab("Files");
|
||||
await expect(session.files).toBeVisible();
|
||||
await session.fileTreeNode("walkthrough_base.txt").click();
|
||||
const baseFile = await session.fileTree.waitForFileTreeNode("walkthrough_base.txt");
|
||||
await baseFile.click();
|
||||
|
||||
const editor = testPage.locator(".monaco-editor:visible").first();
|
||||
await expect(editor).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
@@ -67,9 +67,10 @@ test.describe("mobile: queued session ownership", () => {
|
||||
await expect(sessionPicker).not.toBeVisible();
|
||||
|
||||
await expect(queueStatus).toBeVisible();
|
||||
await expect(testPage.locator('[data-testid="task-parked-session-note"]')).toHaveCount(0);
|
||||
await expect(
|
||||
testPage.locator('[data-testid="task-parked-session-note"]:visible'),
|
||||
).toBeVisible();
|
||||
sessionPage.activeChat().locator(".tiptap.ProseMirror:visible").first(),
|
||||
).toBeEditable();
|
||||
await expectSessionMessagesUnchanged(
|
||||
apiClient,
|
||||
scenario.sourceSessionId,
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
import { expect, test } from "../../fixtures/test-base";
|
||||
import { assertNoDocumentHorizontalOverflow } from "../../helpers/layout-assertions";
|
||||
import { SessionPage } from "../../pages/session-page";
|
||||
import {
|
||||
attachSessionLaunchCapture,
|
||||
countPeerDeliveries,
|
||||
createWorkflowPeerResumeScenario,
|
||||
waitForPeerSessionState,
|
||||
type WorkflowPeerResumeScenario,
|
||||
} from "./workflow-peer-resume-helpers";
|
||||
|
||||
test.describe("mobile: workflow peer resume", () => {
|
||||
test("mobile review peer message resumes implement without parking UI", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
test.setTimeout(240_000);
|
||||
let scenario: WorkflowPeerResumeScenario | undefined;
|
||||
|
||||
try {
|
||||
scenario = await createWorkflowPeerResumeScenario(apiClient, seedData, "Mobile peer resume");
|
||||
const beforeMessage = await apiClient.getTask(scenario.taskId);
|
||||
expect(beforeMessage.workflow_step_id).toBe(scenario.reviewStepId);
|
||||
expect(beforeMessage.primary_session_id).toBe(scenario.initialSessionId);
|
||||
|
||||
const sessionsBefore = await apiClient.listTaskSessions(scenario.taskId);
|
||||
const implementationBefore = sessionsBefore.sessions.find(
|
||||
(session) => session.id === scenario!.implementationSessionId,
|
||||
);
|
||||
expect(implementationBefore?.metadata?.workflow_parking).toBeDefined();
|
||||
|
||||
await waitForPeerSessionState(
|
||||
apiClient,
|
||||
scenario.taskId,
|
||||
scenario.implementationSessionId,
|
||||
"RUNNING",
|
||||
);
|
||||
|
||||
const sessionPage = new SessionPage(testPage);
|
||||
await testPage.goto(`/t/${scenario.taskId}`);
|
||||
await sessionPage.waitForLoad();
|
||||
const mobileLayout = testPage.locator('[data-testid="mobile-task-layout"]:visible');
|
||||
await mobileLayout.getByTestId("mobile-sessions-pill").tap();
|
||||
const sessionPicker = testPage.getByRole("dialog", { name: "Sessions" });
|
||||
const implementationRow = sessionPicker.getByTestId(
|
||||
`mobile-session-row-${scenario.implementationSessionId}`,
|
||||
);
|
||||
await expect(implementationRow).toBeVisible();
|
||||
const implementationRowBox = await implementationRow.boundingBox();
|
||||
expect(implementationRowBox).not.toBeNull();
|
||||
expect(implementationRowBox!.height).toBeGreaterThanOrEqual(44);
|
||||
await implementationRow.tap();
|
||||
await expect(sessionPicker).not.toBeVisible();
|
||||
await sessionPage.expectChatResponseVisible(scenario.outputMarker);
|
||||
await expect(testPage.locator('[data-testid="task-parked-session-note"]')).toHaveCount(0);
|
||||
await expect(
|
||||
sessionPage.activeChat().locator(".tiptap.ProseMirror:visible").first(),
|
||||
).toBeEditable();
|
||||
await assertNoDocumentHorizontalOverflow(testPage, "active peer-resumed mobile session");
|
||||
await expect
|
||||
.poll(() =>
|
||||
countPeerDeliveries(apiClient, scenario!.implementationSessionId, scenario!.outputMarker),
|
||||
)
|
||||
.toBe(1);
|
||||
|
||||
const activeTask = await apiClient.getTask(scenario.taskId);
|
||||
expect(activeTask.workflow_step_id).toBe(scenario.reviewStepId);
|
||||
expect(activeTask.primary_session_id).toBe(scenario.initialSessionId);
|
||||
const activeSessions = await apiClient.listTaskSessions(scenario.taskId);
|
||||
expect(
|
||||
activeSessions.sessions.find((session) => session.id === scenario!.implementationSessionId)
|
||||
?.metadata?.workflow_parking,
|
||||
).toBeDefined();
|
||||
|
||||
await testPage.reload();
|
||||
await sessionPage.waitForLoad();
|
||||
const reloadedMobileLayout = testPage.locator('[data-testid="mobile-task-layout"]:visible');
|
||||
await reloadedMobileLayout.getByTestId("mobile-sessions-pill").tap();
|
||||
const reloadedSessionPicker = testPage.getByRole("dialog", { name: "Sessions" });
|
||||
await reloadedSessionPicker
|
||||
.getByTestId(`mobile-session-row-${scenario.implementationSessionId}`)
|
||||
.tap();
|
||||
await expect(reloadedSessionPicker).not.toBeVisible();
|
||||
await expect(testPage.locator('[data-testid="task-parked-session-note"]')).toHaveCount(0);
|
||||
await sessionPage.expectChatResponseVisible(scenario.outputMarker);
|
||||
await assertNoDocumentHorizontalOverflow(
|
||||
testPage,
|
||||
"reloaded active peer-resumed mobile session",
|
||||
);
|
||||
|
||||
await waitForPeerSessionState(
|
||||
apiClient,
|
||||
scenario.taskId,
|
||||
scenario.implementationSessionId,
|
||||
"WAITING_FOR_INPUT",
|
||||
);
|
||||
await sessionPage.expectChatResponseVisible(scenario.completionMarker);
|
||||
await expect(testPage.locator('[data-testid="task-parked-session-note"]')).toHaveCount(0);
|
||||
const finalTask = await apiClient.getTask(scenario.taskId);
|
||||
expect(finalTask.workflow_step_id).toBe(scenario.reviewStepId);
|
||||
expect(finalTask.primary_session_id).toBe(scenario.initialSessionId);
|
||||
await expect
|
||||
.poll(() =>
|
||||
countPeerDeliveries(apiClient, scenario!.implementationSessionId, scenario!.outputMarker),
|
||||
)
|
||||
.toBe(1);
|
||||
} finally {
|
||||
if (scenario) {
|
||||
await Promise.all(
|
||||
[scenario.implementationSessionId, scenario.initialSessionId].map((sessionId) =>
|
||||
apiClient
|
||||
.stopSession({
|
||||
session_id: sessionId,
|
||||
reason: "mobile workflow peer resume e2e cleanup",
|
||||
force: true,
|
||||
})
|
||||
.catch(() => undefined),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("mobile opens parked implement and resumes without changing Review ownership", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
test.setTimeout(240_000);
|
||||
let scenario: WorkflowPeerResumeScenario | undefined;
|
||||
const capture = attachSessionLaunchCapture(testPage);
|
||||
|
||||
try {
|
||||
scenario = await createWorkflowPeerResumeScenario(apiClient, seedData, "Mobile open resume", {
|
||||
sendPeerMessage: false,
|
||||
});
|
||||
expect(
|
||||
await countPeerDeliveries(
|
||||
apiClient,
|
||||
scenario.implementationSessionId,
|
||||
scenario.implementationMarker,
|
||||
),
|
||||
).toBe(1);
|
||||
const beforeOpen = await apiClient.listTaskSessions(scenario.taskId);
|
||||
expect(
|
||||
beforeOpen.sessions.find((session) => session.id === scenario!.implementationSessionId)
|
||||
?.metadata?.workflow_parking,
|
||||
).toBeDefined();
|
||||
|
||||
const sessionPage = new SessionPage(testPage);
|
||||
await testPage.goto(`/t/${scenario.taskId}`);
|
||||
await sessionPage.waitForLoad();
|
||||
const mobileLayout = testPage.locator('[data-testid="mobile-task-layout"]:visible');
|
||||
await mobileLayout.getByTestId("mobile-sessions-pill").tap();
|
||||
const sessionPicker = testPage.getByRole("dialog", { name: "Sessions" });
|
||||
const implementationRow = sessionPicker.getByTestId(
|
||||
`mobile-session-row-${scenario.implementationSessionId}`,
|
||||
);
|
||||
await expect(implementationRow).toBeVisible();
|
||||
const implementationRowBox = await implementationRow.boundingBox();
|
||||
expect(implementationRowBox).not.toBeNull();
|
||||
expect(implementationRowBox!.height).toBeGreaterThanOrEqual(44);
|
||||
await implementationRow.tap();
|
||||
await expect(sessionPicker).not.toBeVisible();
|
||||
await expect
|
||||
.poll(() =>
|
||||
capture.requests.some(
|
||||
(request) =>
|
||||
request.taskId === scenario!.taskId &&
|
||||
request.sessionId === scenario!.implementationSessionId &&
|
||||
request.intent === "resume" &&
|
||||
request.activationSource === "session_open",
|
||||
),
|
||||
)
|
||||
.toBe(true);
|
||||
await expect(
|
||||
sessionPage.activeChat().getByText("Resumed agent", { exact: false }).first(),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
await expect(testPage.locator('[data-testid="task-parked-session-note"]')).toHaveCount(0);
|
||||
await expect(
|
||||
sessionPage.activeChat().locator(".tiptap.ProseMirror:visible").first(),
|
||||
).toBeEditable();
|
||||
await assertNoDocumentHorizontalOverflow(testPage, "mobile session-open recovery");
|
||||
await waitForPeerSessionState(
|
||||
apiClient,
|
||||
scenario.taskId,
|
||||
scenario.implementationSessionId,
|
||||
"WAITING_FOR_INPUT",
|
||||
);
|
||||
expect(
|
||||
await countPeerDeliveries(
|
||||
apiClient,
|
||||
scenario.implementationSessionId,
|
||||
scenario.implementationMarker,
|
||||
),
|
||||
).toBe(1);
|
||||
|
||||
const task = await apiClient.getTask(scenario.taskId);
|
||||
expect(task.workflow_step_id).toBe(scenario.reviewStepId);
|
||||
expect(task.primary_session_id).toBe(scenario.initialSessionId);
|
||||
} finally {
|
||||
if (scenario) {
|
||||
await Promise.all(
|
||||
[scenario.implementationSessionId, scenario.initialSessionId].map((sessionId) =>
|
||||
apiClient
|
||||
.stopSession({
|
||||
session_id: sessionId,
|
||||
reason: "mobile workflow session-open recovery e2e cleanup",
|
||||
force: true,
|
||||
})
|
||||
.catch(() => undefined),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -47,9 +47,10 @@ test.describe("Queued session ownership", () => {
|
||||
|
||||
await sessionPage.sessionTabBySessionId(scenario.sourceSessionId).click();
|
||||
await expect(queueStatus).toBeVisible();
|
||||
await expect(testPage.locator('[data-testid="task-parked-session-note"]')).toHaveCount(0);
|
||||
await expect(
|
||||
testPage.locator('[data-testid="task-parked-session-note"]:visible'),
|
||||
).toBeVisible();
|
||||
sessionPage.activeChat().locator(".tiptap.ProseMirror:visible").first(),
|
||||
).toBeEditable();
|
||||
await expectSessionMessagesUnchanged(
|
||||
apiClient,
|
||||
scenario.sourceSessionId,
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
import { expect } from "../../fixtures/test-base";
|
||||
import type { Page } from "@playwright/test";
|
||||
import type { SeedData } from "../../fixtures/test-base";
|
||||
import type { ApiClient } from "../../helpers/api-client";
|
||||
import { createWorkflowAgentProfiles } from "./workflow-agent-switch-helpers";
|
||||
|
||||
const PEER_OUTPUT_DELAY_MS = 12_000;
|
||||
|
||||
export type WorkflowPeerResumeScenario = {
|
||||
taskId: string;
|
||||
taskTitle: string;
|
||||
workflowId: string;
|
||||
reviewStepId: string;
|
||||
initialSessionId: string;
|
||||
implementationSessionId: string;
|
||||
implementationMarker: string;
|
||||
outputMarker: string;
|
||||
completionMarker: string;
|
||||
reviewMarker: string;
|
||||
};
|
||||
|
||||
export async function waitForPeerSessionState(
|
||||
apiClient: ApiClient,
|
||||
taskId: string,
|
||||
sessionId: string,
|
||||
expectedState: string,
|
||||
timeoutMs = 30_000,
|
||||
): Promise<void> {
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const { sessions } = await apiClient.listTaskSessions(taskId);
|
||||
return sessions.find((session) => session.id === sessionId)?.state ?? "";
|
||||
},
|
||||
{
|
||||
timeout: timeoutMs,
|
||||
message: `session ${sessionId} did not become ${expectedState}`,
|
||||
},
|
||||
)
|
||||
.toBe(expectedState);
|
||||
}
|
||||
|
||||
async function waitForAgentMarker(
|
||||
apiClient: ApiClient,
|
||||
sessionId: string,
|
||||
marker: string,
|
||||
): Promise<void> {
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const { messages } = await apiClient.listSessionMessages(sessionId);
|
||||
return messages.some(
|
||||
(message) => message.author_type === "agent" && message.content.includes(marker),
|
||||
);
|
||||
},
|
||||
{ timeout: 30_000, message: `session ${sessionId} did not emit ${marker}` },
|
||||
)
|
||||
.toBe(true);
|
||||
}
|
||||
|
||||
export async function createWorkflowPeerResumeScenario(
|
||||
apiClient: ApiClient,
|
||||
seedData: SeedData,
|
||||
name: string,
|
||||
options: { sendPeerMessage?: boolean } = {},
|
||||
): Promise<WorkflowPeerResumeScenario> {
|
||||
const { profileA, profileB } = await createWorkflowAgentProfiles(apiClient);
|
||||
const workflow = await apiClient.createWorkflow(seedData.workspaceId, `${name} workflow`);
|
||||
const initial = await apiClient.createWorkflowStep(workflow.id, "Plan", 0, {
|
||||
is_start_step: true,
|
||||
agent_profile_id: profileA.id,
|
||||
profile_session_start_policy: "new",
|
||||
profile_session_end_policy: "park",
|
||||
events: { on_enter: [{ type: "auto_start_agent" }] },
|
||||
});
|
||||
const implementation = await apiClient.createWorkflowStep(workflow.id, "Implement", 1, {
|
||||
agent_profile_id: profileB.id,
|
||||
profile_session_start_policy: "new",
|
||||
profile_session_end_policy: "park",
|
||||
events: { on_enter: [{ type: "auto_start_agent" }] },
|
||||
});
|
||||
const review = await apiClient.createWorkflowStep(workflow.id, "Review", 2, {
|
||||
session_target: { kind: "initial" },
|
||||
profile_session_end_policy: "park",
|
||||
events: { on_enter: [{ type: "auto_start_agent" }] },
|
||||
});
|
||||
await apiClient.createWorkflowStep(workflow.id, "PR", 3, {
|
||||
session_target: { kind: "step", step_id: implementation.id },
|
||||
profile_session_end_policy: "park",
|
||||
});
|
||||
|
||||
const initialMarker = `${name} initial ready`;
|
||||
const implementationMarker = `${name} implementation ready`;
|
||||
const reviewMarker = `${name} review ready`;
|
||||
const outputMarker = `${name} peer output`;
|
||||
const completionMarker = `${name} peer turn complete`;
|
||||
await apiClient.updateWorkflowStep(initial.id, { prompt: `e2e:message("${initialMarker}")` });
|
||||
await apiClient.updateWorkflowStep(implementation.id, {
|
||||
prompt: `e2e:message("${implementationMarker}")`,
|
||||
});
|
||||
await apiClient.updateWorkflowStep(review.id, { prompt: `e2e:message("${reviewMarker}")` });
|
||||
|
||||
const taskTitle = `${name} target`;
|
||||
const task = await apiClient.createTaskWithAgent(seedData.workspaceId, taskTitle, profileA.id, {
|
||||
workflow_id: workflow.id,
|
||||
workflow_step_id: initial.id,
|
||||
repository_ids: [seedData.repositoryId],
|
||||
});
|
||||
const initialSessionId = await waitForProfileSession(apiClient, task.id, profileA.id);
|
||||
await waitForAgentMarker(apiClient, initialSessionId, initialMarker);
|
||||
await waitForPeerSessionState(apiClient, task.id, initialSessionId, "WAITING_FOR_INPUT");
|
||||
|
||||
await apiClient.moveTask(task.id, workflow.id, implementation.id);
|
||||
const implementationSessionId = await waitForProfileSession(apiClient, task.id, profileB.id);
|
||||
await waitForAgentMarker(apiClient, implementationSessionId, implementationMarker);
|
||||
await waitForPeerSessionState(apiClient, task.id, implementationSessionId, "WAITING_FOR_INPUT");
|
||||
|
||||
if (options.sendPeerMessage !== false) {
|
||||
const peerPrompt = [
|
||||
`e2e:message("${outputMarker}")`,
|
||||
`e2e:delay(${PEER_OUTPUT_DELAY_MS})`,
|
||||
`e2e:message("${completionMarker}")`,
|
||||
].join("\n");
|
||||
const reviewPrompt = [
|
||||
`e2e:mcp:kandev:message_task_kandev(${JSON.stringify({
|
||||
task_id: task.id,
|
||||
session_id: implementationSessionId,
|
||||
prompt: peerPrompt,
|
||||
})})`,
|
||||
`e2e:message("${reviewMarker}")`,
|
||||
].join("\n");
|
||||
await apiClient.updateWorkflowStep(review.id, { prompt: reviewPrompt });
|
||||
}
|
||||
await apiClient.moveTask(task.id, workflow.id, review.id);
|
||||
await waitForAgentMarker(apiClient, initialSessionId, reviewMarker);
|
||||
await waitForPeerSessionState(apiClient, task.id, initialSessionId, "WAITING_FOR_INPUT");
|
||||
|
||||
return {
|
||||
taskId: task.id,
|
||||
taskTitle,
|
||||
workflowId: workflow.id,
|
||||
reviewStepId: review.id,
|
||||
initialSessionId,
|
||||
implementationSessionId,
|
||||
implementationMarker,
|
||||
outputMarker,
|
||||
completionMarker,
|
||||
reviewMarker,
|
||||
};
|
||||
}
|
||||
|
||||
export type SessionLaunchCapture = {
|
||||
taskId?: string;
|
||||
sessionId?: string;
|
||||
intent?: string;
|
||||
activationSource?: string;
|
||||
};
|
||||
|
||||
export function attachSessionLaunchCapture(page: Page): { requests: SessionLaunchCapture[] } {
|
||||
const requests: SessionLaunchCapture[] = [];
|
||||
page.on("websocket", (socket) => {
|
||||
socket.on("framesent", (event) => {
|
||||
if (typeof event.payload !== "string" || !event.payload.includes('"session.launch"')) return;
|
||||
try {
|
||||
const message = JSON.parse(event.payload) as {
|
||||
action?: string;
|
||||
payload?: {
|
||||
task_id?: string;
|
||||
session_id?: string;
|
||||
intent?: string;
|
||||
activation_source?: string;
|
||||
};
|
||||
};
|
||||
if (message.action !== "session.launch") return;
|
||||
requests.push({
|
||||
taskId: message.payload?.task_id,
|
||||
sessionId: message.payload?.session_id,
|
||||
intent: message.payload?.intent,
|
||||
activationSource: message.payload?.activation_source,
|
||||
});
|
||||
} catch {
|
||||
// Ignore non-JSON websocket frames.
|
||||
}
|
||||
});
|
||||
});
|
||||
return { requests };
|
||||
}
|
||||
|
||||
async function waitForProfileSession(
|
||||
apiClient: ApiClient,
|
||||
taskId: string,
|
||||
profileId: string,
|
||||
): Promise<string> {
|
||||
let sessionId = "";
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const { sessions } = await apiClient.listTaskSessions(taskId);
|
||||
const session = sessions.find((item) => item.agent_profile_id === profileId);
|
||||
sessionId = session?.id ?? "";
|
||||
return session?.state === "WAITING_FOR_INPUT";
|
||||
},
|
||||
{ timeout: 30_000, message: `profile ${profileId} did not become answerable` },
|
||||
)
|
||||
.toBe(true);
|
||||
return sessionId;
|
||||
}
|
||||
|
||||
export async function countPeerDeliveries(
|
||||
apiClient: ApiClient,
|
||||
sessionId: string,
|
||||
marker: string,
|
||||
): Promise<number> {
|
||||
const { messages } = await apiClient.listSessionMessages(sessionId);
|
||||
return messages.filter(
|
||||
(message) => message.author_type === "user" && message.content.includes(marker),
|
||||
).length;
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import { expect, test } from "../../fixtures/test-base";
|
||||
import { SessionPage } from "../../pages/session-page";
|
||||
import {
|
||||
attachSessionLaunchCapture,
|
||||
countPeerDeliveries,
|
||||
createWorkflowPeerResumeScenario,
|
||||
waitForPeerSessionState,
|
||||
type WorkflowPeerResumeScenario,
|
||||
} from "./workflow-peer-resume-helpers";
|
||||
|
||||
test.describe("Workflow peer resume", () => {
|
||||
test("review peer message resumes implement without parking UI", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
test.setTimeout(240_000);
|
||||
let scenario: WorkflowPeerResumeScenario | undefined;
|
||||
|
||||
try {
|
||||
scenario = await createWorkflowPeerResumeScenario(apiClient, seedData, "Desktop peer resume");
|
||||
const beforeMessage = await apiClient.getTask(scenario.taskId);
|
||||
expect(beforeMessage.workflow_step_id).toBe(scenario.reviewStepId);
|
||||
expect(beforeMessage.primary_session_id).toBe(scenario.initialSessionId);
|
||||
|
||||
const sessionsBefore = await apiClient.listTaskSessions(scenario.taskId);
|
||||
const implementationBefore = sessionsBefore.sessions.find(
|
||||
(session) => session.id === scenario!.implementationSessionId,
|
||||
);
|
||||
expect(implementationBefore?.metadata?.workflow_parking).toBeDefined();
|
||||
|
||||
await waitForPeerSessionState(
|
||||
apiClient,
|
||||
scenario.taskId,
|
||||
scenario.implementationSessionId,
|
||||
"RUNNING",
|
||||
);
|
||||
const sessionPage = new SessionPage(testPage);
|
||||
await testPage.goto(`/t/${scenario.taskId}`);
|
||||
await sessionPage.waitForLoad();
|
||||
await sessionPage.sessionTabBySessionId(scenario.implementationSessionId).click();
|
||||
await sessionPage.expectChatResponseVisible(scenario.outputMarker);
|
||||
await expect(testPage.locator('[data-testid="task-parked-session-note"]')).toHaveCount(0);
|
||||
await expect(
|
||||
sessionPage.activeChat().locator(".tiptap.ProseMirror:visible").first(),
|
||||
).toBeEditable();
|
||||
await expect
|
||||
.poll(() =>
|
||||
countPeerDeliveries(apiClient, scenario!.implementationSessionId, scenario!.outputMarker),
|
||||
)
|
||||
.toBe(1);
|
||||
|
||||
const activeTask = await apiClient.getTask(scenario.taskId);
|
||||
expect(activeTask.workflow_step_id).toBe(scenario.reviewStepId);
|
||||
expect(activeTask.primary_session_id).toBe(scenario.initialSessionId);
|
||||
const activeSessions = await apiClient.listTaskSessions(scenario.taskId);
|
||||
expect(
|
||||
activeSessions.sessions.find((session) => session.id === scenario!.implementationSessionId)
|
||||
?.metadata?.workflow_parking,
|
||||
).toBeDefined();
|
||||
|
||||
await testPage.reload();
|
||||
await sessionPage.waitForLoad();
|
||||
await sessionPage.sessionTabBySessionId(scenario.implementationSessionId).click();
|
||||
await sessionPage.expectChatResponseVisible(scenario.outputMarker);
|
||||
await expect(testPage.locator('[data-testid="task-parked-session-note"]')).toHaveCount(0);
|
||||
|
||||
await waitForPeerSessionState(
|
||||
apiClient,
|
||||
scenario.taskId,
|
||||
scenario.implementationSessionId,
|
||||
"WAITING_FOR_INPUT",
|
||||
);
|
||||
await sessionPage.expectChatResponseVisible(scenario.completionMarker);
|
||||
await expect(testPage.locator('[data-testid="task-parked-session-note"]')).toHaveCount(0);
|
||||
const finalTask = await apiClient.getTask(scenario.taskId);
|
||||
expect(finalTask.workflow_step_id).toBe(scenario.reviewStepId);
|
||||
expect(finalTask.primary_session_id).toBe(scenario.initialSessionId);
|
||||
await expect
|
||||
.poll(() =>
|
||||
countPeerDeliveries(apiClient, scenario!.implementationSessionId, scenario!.outputMarker),
|
||||
)
|
||||
.toBe(1);
|
||||
} finally {
|
||||
if (scenario) {
|
||||
await Promise.all(
|
||||
[scenario.implementationSessionId, scenario.initialSessionId].map((sessionId) =>
|
||||
apiClient
|
||||
.stopSession({
|
||||
session_id: sessionId,
|
||||
reason: "workflow peer resume e2e cleanup",
|
||||
force: true,
|
||||
})
|
||||
.catch(() => undefined),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("opening parked implement resumes without changing Review ownership", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
test.setTimeout(240_000);
|
||||
let scenario: WorkflowPeerResumeScenario | undefined;
|
||||
const capture = attachSessionLaunchCapture(testPage);
|
||||
|
||||
try {
|
||||
scenario = await createWorkflowPeerResumeScenario(
|
||||
apiClient,
|
||||
seedData,
|
||||
"Desktop open resume",
|
||||
{ sendPeerMessage: false },
|
||||
);
|
||||
expect(
|
||||
await countPeerDeliveries(
|
||||
apiClient,
|
||||
scenario.implementationSessionId,
|
||||
scenario.implementationMarker,
|
||||
),
|
||||
).toBe(1);
|
||||
const beforeOpen = await apiClient.listTaskSessions(scenario.taskId);
|
||||
expect(
|
||||
beforeOpen.sessions.find((session) => session.id === scenario!.implementationSessionId)
|
||||
?.metadata?.workflow_parking,
|
||||
).toBeDefined();
|
||||
|
||||
const sessionPage = new SessionPage(testPage);
|
||||
await testPage.goto(`/t/${scenario.taskId}`);
|
||||
await sessionPage.waitForLoad();
|
||||
await sessionPage.sessionTabBySessionId(scenario.implementationSessionId).click();
|
||||
await expect
|
||||
.poll(() =>
|
||||
capture.requests.some(
|
||||
(request) =>
|
||||
request.taskId === scenario!.taskId &&
|
||||
request.sessionId === scenario!.implementationSessionId &&
|
||||
request.intent === "resume" &&
|
||||
request.activationSource === "session_open",
|
||||
),
|
||||
)
|
||||
.toBe(true);
|
||||
await expect(
|
||||
sessionPage.activeChat().getByText("Resumed agent", { exact: false }).first(),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
await expect(testPage.locator('[data-testid="task-parked-session-note"]')).toHaveCount(0);
|
||||
await expect(
|
||||
sessionPage.activeChat().locator(".tiptap.ProseMirror:visible").first(),
|
||||
).toBeEditable();
|
||||
await waitForPeerSessionState(
|
||||
apiClient,
|
||||
scenario.taskId,
|
||||
scenario.implementationSessionId,
|
||||
"WAITING_FOR_INPUT",
|
||||
);
|
||||
expect(
|
||||
await countPeerDeliveries(
|
||||
apiClient,
|
||||
scenario.implementationSessionId,
|
||||
scenario.implementationMarker,
|
||||
),
|
||||
).toBe(1);
|
||||
|
||||
const task = await apiClient.getTask(scenario.taskId);
|
||||
expect(task.workflow_step_id).toBe(scenario.reviewStepId);
|
||||
expect(task.primary_session_id).toBe(scenario.initialSessionId);
|
||||
} finally {
|
||||
if (scenario) {
|
||||
await Promise.all(
|
||||
[scenario.implementationSessionId, scenario.initialSessionId].map((sessionId) =>
|
||||
apiClient
|
||||
.stopSession({
|
||||
session_id: sessionId,
|
||||
reason: "workflow session-open recovery e2e cleanup",
|
||||
force: true,
|
||||
})
|
||||
.catch(() => undefined),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1360,7 +1360,6 @@
|
||||
"wipQueueConfigure": "Configure workflow WIP limit",
|
||||
"wipQueueUnavailable": "Workflow WIP details are unavailable.",
|
||||
"wipQueueConfigurationUnavailable": "The workflow configuration link is unavailable.",
|
||||
"parkedSessionNote": "This session is parked for the workflow. Opening it does not resume it. Send a message to continue here.",
|
||||
"quorumAwaiting": "Awaiting {{received}} of {{required}} {{role}} decisions",
|
||||
"quorumStuck": "Stuck",
|
||||
"quorumStuckPendingReevaluation": "Blocked: no active session to re-evaluate this step",
|
||||
|
||||
@@ -1360,7 +1360,6 @@
|
||||
"wipQueueConfigure": "ワークフロー WIP 上限を設定",
|
||||
"wipQueueUnavailable": "ワークフロー WIP 詳細なし。",
|
||||
"wipQueueConfigurationUnavailable": "ワークフロー設定リンクなし。",
|
||||
"parkedSessionNote": "このセッションはワークフロー用に待機中。開いても再開しない。続けるにはメッセージ送信。",
|
||||
"quorumAwaiting": "{{role}} の決定待ち、{{received}} / {{required}} 件",
|
||||
"quorumStuck": "停止中",
|
||||
"quorumStuckPendingReevaluation": "ブロック中: このステップを再評価するアクティブ セッションなし",
|
||||
|
||||
@@ -1360,7 +1360,6 @@
|
||||
"wipQueueConfigure": "Ćōńƒĩĝũŕē ŵōŕķƒĺōŵ ŴĨƤ ĺĩḿĩţ",
|
||||
"wipQueueUnavailable": "Ŵōŕķƒĺōŵ ŴĨƤ ďēţàĩĺś àŕē ũńàvàĩĺàƀĺē.",
|
||||
"wipQueueConfigurationUnavailable": "Ţĥē ŵōŕķƒĺōŵ ćōńƒĩĝũŕàţĩōń ĺĩńķ ĩś ũńàvàĩĺàƀĺē.",
|
||||
"parkedSessionNote": "Ţĥĩś śēśśĩōń ĩś ƥàŕķēď ƒōŕ ţĥē ŵōŕķƒĺōŵ. Ōƥēńĩńĝ ĩţ ďōēś ńōţ ŕēśũḿē ĩţ. Śēńď à ḿēśśàĝē ţō ćōńţĩńũē ĥēŕē.",
|
||||
"quorumAwaiting": "Àŵàĩţĩńĝ {{received}} ōƒ {{required}} {{role}} ďēćĩśĩōńś",
|
||||
"quorumStuck": "Śţũćķ",
|
||||
"quorumStuckPendingReevaluation": "Ɓĺōćķēď: ńō àćţĩvē śēśśĩōń ţō ŕē-ēvàĺũàţē ţĥĩś śţēƥ",
|
||||
|
||||
@@ -1294,7 +1294,6 @@
|
||||
"wipQueueConfigure": "Configurar o limite WIP do fluxo de trabalho",
|
||||
"wipQueueUnavailable": "Os detalhes do WIP do fluxo de trabalho não estão disponíveis.",
|
||||
"wipQueueConfigurationUnavailable": "A ligação para a configuração do fluxo de trabalho não está disponível.",
|
||||
"parkedSessionNote": "Esta sessão está parada para o fluxo de trabalho. Abri-la não a retoma. Envie uma mensagem para continuar aqui.",
|
||||
"quorumAwaiting": "A aguardar {{received}} de {{required}} decisões de {{role}}",
|
||||
"quorumStuck": "Bloqueado",
|
||||
"quorumStuckPendingReevaluation": "Bloqueado: sem sessão ativa para reavaliar esta etapa",
|
||||
|
||||
@@ -1293,7 +1293,6 @@
|
||||
"wipQueueConfigure": "配置工作流 WIP 限制",
|
||||
"wipQueueUnavailable": "工作流 WIP 详情不可用。",
|
||||
"wipQueueConfigurationUnavailable": "工作流配置链接不可用。",
|
||||
"parkedSessionNote": "此会话已为工作流暂停。打开它不会恢复会话。发送消息即可在此继续。",
|
||||
"quorumAwaiting": "正在等待 {{role}} 的 {{received}}/{{required}} 项决定",
|
||||
"quorumStuck": "受阻",
|
||||
"quorumStuckPendingReevaluation": "受阻:没有活动会话可重新评估此步骤",
|
||||
|
||||
@@ -1293,7 +1293,6 @@
|
||||
"wipQueueConfigure": "設定工作流程 WIP 限制",
|
||||
"wipQueueUnavailable": "工作流程 WIP 詳情不可用。",
|
||||
"wipQueueConfigurationUnavailable": "工作流程設定連結不可用。",
|
||||
"parkedSessionNote": "此工作階段已為工作流程暫停。開啟它不會恢復工作階段。傳送訊息即可在此繼續。",
|
||||
"quorumAwaiting": "正在等待 {{role}} 的 {{received}}/{{required}} 項決定",
|
||||
"quorumStuck": "受阻",
|
||||
"quorumStuckPendingReevaluation": "受阻:沒有活動工作階段可重新評估此步驟",
|
||||
|
||||
@@ -1293,7 +1293,6 @@
|
||||
"wipQueueConfigure": "設定工作流程 WIP 限制",
|
||||
"wipQueueUnavailable": "工作流程 WIP 詳情不可用。",
|
||||
"wipQueueConfigurationUnavailable": "工作流程設定連結不可用。",
|
||||
"parkedSessionNote": "此工作階段已為工作流程暫停。開啟它不會恢復工作階段。傳送訊息即可在此繼續。",
|
||||
"quorumAwaiting": "正在等待 {{role}} 的 {{received}}/{{required}} 項決定",
|
||||
"quorumStuck": "受阻",
|
||||
"quorumStuckPendingReevaluation": "受阻:沒有活動工作階段可重新評估此步驟",
|
||||
|
||||
@@ -313,4 +313,6 @@ The [recovery eligibility repair](../session-open-recovery-eligibility/plan.md)
|
||||
owns the historical-stop and settled-deferral regressions found after restart.
|
||||
It replaces the parking suppression policy and removes the parking note, with
|
||||
separate and combined recovery cases on desktop and phone. Existing results and PostgreSQL prerequisites here
|
||||
remain unchanged. The follow-up work order is pending implementation.
|
||||
remain unchanged. The follow-up UI work order is complete. Its 2026-09-23 update also
|
||||
covers Review-to-Implement peer resumption with legacy parking metadata. The recovery
|
||||
package remains the single owner for removing parking presentation and its E2E coverage.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
created: 2026-09-18
|
||||
status: in_progress
|
||||
status: completed
|
||||
requirements:
|
||||
- REQ-TASKS-QUEUED-SESSION-OWNERSHIP-001
|
||||
- REQ-TASKS-QUEUED-SESSION-OWNERSHIP-002
|
||||
@@ -45,6 +45,43 @@ explicitly superseded by 001.9 and 003.10. Criteria 001.7/8 retain restart and
|
||||
settled-queue coverage. Queue identity, automatic admission, callback correlation,
|
||||
and the deadlock fixes remain compatibility constraints.
|
||||
|
||||
## Confirmed peer-resume regression (2026-09-23)
|
||||
|
||||
This package also owns the fix requested for task
|
||||
`31c82921-af54-465c-aa1d-adf033fe9b2a`. Task 02 remains the single implementation
|
||||
owner for banner removal. This update adds evidence and regression coverage.
|
||||
It does not reopen completed Task 01 or create a competing parking policy.
|
||||
|
||||
The investigation used read-only session queries, SQLite metadata, and retained
|
||||
backend logs. Times below use UTC on 2026-09-23.
|
||||
|
||||
| Time | Evidence |
|
||||
| --- | --- |
|
||||
| 21:08:25 | Review entry parked implementation session `2576d990-c94e-4291-85bb-64b4684cc3dc`. |
|
||||
| 21:13:46 | Initial session `2e76057d-653a-4120-9af6-37e0d1048acb` sent review findings through `message_task_kandev`. |
|
||||
| 21:13:53 | The implementation session resumed successfully. |
|
||||
| Investigation | The implementation session was RUNNING and emitted output. Its original `workflow_parking` marker remained stored. |
|
||||
|
||||
The Feature workflow uses a new Luna session for Implement. Review reuses the
|
||||
initial Sol session. Its prompt directs Sol to message Luna with findings.
|
||||
PR later targets the Implement session. A peer message does not itself move the
|
||||
workflow out of Review.
|
||||
|
||||
The prompt-owned resume path uses `ensureSessionRunning`, `runResumeAttempt`,
|
||||
and `coldResumeSession` in `task_operations.go`. It does not call the parking-clear
|
||||
helper used by `session_launch.go:launchResume`. The chat panel renders
|
||||
`ParkedSessionNote` from `hasWorkflowParkingMarker(session.metadata)` alone.
|
||||
The resulting banner contradicts the active session state.
|
||||
|
||||
This is an implementation gap against AC 003.10 and the accepted recovery ADR.
|
||||
The requirement document retains its existing draft status. The accepted ADR
|
||||
settles the intended behavior. No new requirement or architectural decision is needed.
|
||||
|
||||
Remove the presentation according to the existing design. Do not add a RUNNING-only
|
||||
exception or a metadata deletion prerequisite. Legacy metadata must remain inert.
|
||||
Keep execution-stamped stop intents for delayed callbacks. Do not change peer
|
||||
message delivery, workflow routing, or the agent completion-signal contract.
|
||||
|
||||
## Scope
|
||||
|
||||
### In scope
|
||||
@@ -144,6 +181,7 @@ AC suffixes use `AC-TASKS-QUEUED-SESSION-OWNERSHIP-`.
|
||||
| Archive, explicit cancellation, completion, authorization failure | Existing lifecycle restrictions remain | 001.9 |
|
||||
| Allowed status followed by conflicting queue or route change | Guarded admission rejects stale execution | 001.2, 001.6; 002.6 |
|
||||
| Restart after switch or queue settlement | Conversation context survives; opening requests normal recovery | 001.7, 001.8, 001.9 |
|
||||
| Review(initial) messages Implement session | One peer delivery, active output without parking UI, Review recipient unchanged | 001.3; 003.10 |
|
||||
| Desktop/phone with stored parking marker | No parked note; ordinary controls and real queue status remain | 003.10 |
|
||||
|
||||
## E2E tests
|
||||
@@ -169,10 +207,58 @@ parking note, no document horizontal overflow, and usable existing controls.
|
||||
Restore settings and runtime overrides in `finally`. No developer-instance restart
|
||||
or direct production database mutation is authorized.
|
||||
|
||||
### Peer-message handoff regression
|
||||
|
||||
Add `workflow-peer-resume.spec.ts` and `mobile-workflow-peer-resume.spec.ts` under
|
||||
`apps/web/e2e/tests/workflow/`. Share setup in `workflow-peer-resume-helpers.ts`.
|
||||
Use the shipped workflow-switch helpers and the phone Sessions picker.
|
||||
|
||||
Name the desktop test `review peer message resumes implement without parking UI`.
|
||||
Name the phone test `mobile review peer message resumes implement without parking UI`.
|
||||
Both cover AC 001.3 and 003.10. The desktop test must fail on the current banner
|
||||
before Task 02 removes it. Repeat the same behavior on the configured phone project.
|
||||
|
||||
Add desktop and phone session-open flows for selecting the stopped Implement
|
||||
conversation while capacity is available. Capture the `session.launch` request
|
||||
and require `activation_source: session_open`. Confirm the agent resumes, the
|
||||
composer is usable, and the original workflow prompt is not replayed. On phone,
|
||||
also assert the session row remains at least 44px and the page has no horizontal
|
||||
overflow. Both flows preserve Review and its initial primary session.
|
||||
|
||||
1. Create an isolated workflow with initial, Implement, Review, and PR recipients.
|
||||
2. Enter Implement, then Review, through actual workflow transitions.
|
||||
3. Keep the implementation page closed until the peer message starts its turn.
|
||||
4. From the initial mock agent, call `message_task_kandev` with the exact implementation session ID.
|
||||
5. Use `e2e:mcp:kandev:message_task_kandev(<JSON>)` from the existing mock script engine.
|
||||
6. Include a unique output marker and a bounded mock delay in the recipient prompt.
|
||||
7. Await provider output and RUNNING state before selecting the implementation conversation.
|
||||
8. Assert one peer delivery, the same session ID, no parked note, and usable ordinary controls.
|
||||
9. Reload during the observed turn and repeat the absence assertion after hydration.
|
||||
10. Let the turn settle without a completion signal, then assert Review and its initial recipient remain unchanged.
|
||||
|
||||
Use event or state waits in Playwright. The mock delay only holds a real turn open
|
||||
for observation. It is not a browser synchronization sleep. Do not inject RUNNING
|
||||
state or synthetic transcript messages to replace the peer-delivery path.
|
||||
|
||||
The defect fixture must expose legacy parking metadata before the UI assertion.
|
||||
If the workflow no longer writes it, use a scoped fixture on the isolated backend.
|
||||
Keep that fixture out of production APIs. A test without the stale marker does
|
||||
not reproduce the reported failure. Restore fixture state and stop owned sessions
|
||||
in `finally`. Genuine queue coverage remains in Task 02's existing two suites.
|
||||
|
||||
The resumed Review mock-agent initially lost its injected MCP server definitions
|
||||
on `LoadSession`, so `message_task_kandev` failed before reaching Implement.
|
||||
Task 02 now owns the small mock-agent fixture repair that carries those supplied
|
||||
definitions into the resumed session. This does not change the application MCP path.
|
||||
|
||||
For the active handoff, UI-01 becomes `[Sol] [Luna: running] [Plan]` above the
|
||||
conversation and composer. UI-02 uses `[Luna: running v]` in the existing phone
|
||||
picker. Neither view adds a parking row before, during, or after the turn.
|
||||
|
||||
## Work orders
|
||||
|
||||
- [x] [Task 01: Restore ordinary recovery for workflow-stopped sessions](task-01-repair-session-open-eligibility.md)
|
||||
- [ ] [Task 02: Remove parking presentation and verify user flows](task-02-remove-parking-presentation.md)
|
||||
- [x] [Task 02: Remove parking presentation and verify user flows](task-02-remove-parking-presentation.md)
|
||||
|
||||
Order: 01 then 02. No subagents are authorized.
|
||||
|
||||
@@ -190,18 +276,41 @@ remain compatibility inputs. Do not change their lock order or claim fences.
|
||||
## Documentation impact
|
||||
|
||||
Task 02 updates `docs/public/tasks-and-workflows.md` and
|
||||
`docs/public/agents-and-profiles.md`: opening restores the selected conversation
|
||||
under normal controls without changing workflow ownership. Remove instructions
|
||||
that require a message solely because the workflow stopped the conversation.
|
||||
No public behavior claim ships during this design-only turn.
|
||||
`docs/public/agents-and-profiles.md`: opening a workflow-stopped session can
|
||||
resume it under normal recovery eligibility and capacity. It does not change the
|
||||
selected workflow step or primary session. The docs no longer require a message
|
||||
solely because the workflow stopped the conversation.
|
||||
|
||||
## Verification results
|
||||
|
||||
Task 01 backend implementation is complete. Focused recovery, race, model/SQLite,
|
||||
build, lint, and the full orchestrator package pass. The complete backend test
|
||||
target still reports unrelated environment-sensitive failures in probe process
|
||||
detection and home configuration discovery. Task 02 remains pending for the
|
||||
frontend presentation removal and desktop/mobile E2E coverage.
|
||||
detection and home configuration discovery. Task 02 completed the frontend
|
||||
presentation removal and desktop/mobile E2E coverage. The 2026-09-23
|
||||
peer-resume and session-open scenarios now pass on desktop and mobile.
|
||||
|
||||
Task 02's desktop peer-resume test first failed as expected with one parked note
|
||||
during active output. After removing the presentation, the desktop suite passed
|
||||
3 tests and the mobile suite passed 3 tests. The direct session-open flows
|
||||
captured `activation_source: session_open` and preserved the original prompt and
|
||||
Review ownership. Targeted UI tests passed 41 tests;
|
||||
typecheck, i18n checks, the focused mock-agent test, and `git diff --check` passed.
|
||||
Public-doc validation passed 62 tests and 47 pages. Spec validation passed for
|
||||
301 decisions and 1134 specifications, including all 36 linter tests. The peer
|
||||
message was delivered once; Implement retained its legacy marker without parking
|
||||
UI, and Review ownership remained unchanged.
|
||||
|
||||
### Design validation, 2026-09-23
|
||||
|
||||
- `python3 scripts/list-docs.py validate`: passed (301 decisions, 1134 specifications).
|
||||
- `python3 scripts/lint-spec-files.py --all`: passed.
|
||||
- `python3 scripts/lint-spec-files.test.py`: passed (36 tests).
|
||||
- `git diff --check`: passed.
|
||||
- `git status --short -- docs/plans/session-open-recovery-eligibility docs/plans/queued-session-ownership`: three modified plan files, unstaged.
|
||||
|
||||
At package preparation time, implementation checks remained pending and only the
|
||||
fix package changed. The completed implementation results are recorded above.
|
||||
|
||||
## Risks
|
||||
|
||||
|
||||
+60
-7
@@ -1,7 +1,7 @@
|
||||
---
|
||||
id: "02-remove-parking-presentation"
|
||||
title: "Remove parking presentation and verify user flows"
|
||||
status: pending
|
||||
status: completed
|
||||
wave: 2
|
||||
depends_on: ["01-repair-session-open-eligibility"]
|
||||
plan: "plan.md"
|
||||
@@ -11,6 +11,7 @@ requirements:
|
||||
- REQ-TASKS-QUEUED-SESSION-OWNERSHIP-003
|
||||
acceptance_criteria:
|
||||
- AC-TASKS-QUEUED-SESSION-OWNERSHIP-001.2
|
||||
- AC-TASKS-QUEUED-SESSION-OWNERSHIP-001.3
|
||||
- AC-TASKS-QUEUED-SESSION-OWNERSHIP-001.5
|
||||
- AC-TASKS-QUEUED-SESSION-OWNERSHIP-001.6
|
||||
- AC-TASKS-QUEUED-SESSION-OWNERSHIP-001.7
|
||||
@@ -33,10 +34,13 @@ conversation recovery on desktop and phone without adding another parking afford
|
||||
## In scope
|
||||
|
||||
- Remove `ParkedSessionNote`, `hasWorkflowParkingMarker`, and their chat-panel use.
|
||||
- Remove `parkedSessionNote` from en, pseudo, pt-pt, zh-cn, zh-hk, and zh-tw catalogs.
|
||||
- Remove `parkedSessionNote` from en, pseudo, pt-pt, zh-cn, zh-hk, zh-tw, and ja catalogs.
|
||||
- Replace parked-note tests with absence and ordinary-conversation-control assertions.
|
||||
- Update desktop/mobile queued-session E2E scenarios according to the plan matrix.
|
||||
- Verify restart, capacity, prevention preference, conversation identity, and no prompt replay.
|
||||
- Capture `session_open` recovery on desktop and phone when capacity permits.
|
||||
- Cover the confirmed Review-to-Implement peer resume described in the plan.
|
||||
- Preserve legacy metadata compatibility without a live-data rewrite.
|
||||
- Update both public recovery explanations and affected companion-plan references.
|
||||
|
||||
## Out of scope
|
||||
@@ -49,7 +53,11 @@ queue status remains. No new layout, navigation hierarchy, or automatic prompt.
|
||||
1. No parking presentation appears on desktop or phone, including with persisted legacy markers.
|
||||
2. Opening the selected stopped session resumes it when normal controls permit,
|
||||
without clicking Resume. Queue ownership and workflow primary remain unchanged.
|
||||
3. Both E2E projects and targeted UI/i18n checks pass. Public docs describe the new behavior.
|
||||
3. The reviewer messages the implementation session exactly once through MCP. Active output, reload, and turn settlement show no parking UI.
|
||||
Review ownership stays unchanged.
|
||||
4. Selecting a stopped session launches normal `session_open` recovery when capacity permits,
|
||||
without replaying its workflow prompt or changing Review ownership.
|
||||
5. Both E2E projects and targeted UI/i18n checks pass. Public docs describe the new behavior.
|
||||
|
||||
## ASCII UI preview
|
||||
|
||||
@@ -65,17 +73,41 @@ Keep the existing desktop tabs and phone task drawer/session picker.
|
||||
The chat remains the single conversation scroll owner. No new touch targets or
|
||||
hover-only behavior are introduced. Cover AC 001.9 and 003.10.
|
||||
|
||||
## Regression implementation
|
||||
|
||||
Follow [the peer-message scenario](plan.md#peer-message-handoff-regression).
|
||||
The existing `cmd/mock-agent/script.go:executeMCPCommand` supports the real MCP call.
|
||||
The mock-agent fixture must retain the MCP server definitions supplied to `LoadSession`
|
||||
so the resumed Review session can issue that call. Do not change the application MCP path.
|
||||
|
||||
Add the named desktop and phone regression tests before banner removal.
|
||||
Record a RED result caused by the visible parked note during active output.
|
||||
Then remove the component, marker predicate, imports, obsolete tests, and locale key.
|
||||
Retain queue component tests. Use rendered chat/E2E absence checks instead of tests
|
||||
that only assert a removed export or file no longer exists.
|
||||
|
||||
Confirm the no-banner result with stored legacy markers. Cover STARTING, RUNNING,
|
||||
and WAITING_FOR_INPUT where the fixture exposes those states. The fix removes
|
||||
parking presentation in every state, rather than changing the state predicate.
|
||||
Do not delete stop-intent tombstones or add a parking-clear requirement to peer delivery.
|
||||
|
||||
The nearest phone exemplar is `components/task/mobile/session-task-switcher-sheet.tsx`.
|
||||
Use `mobile-sessions-pill` and `mobile-session-row-<id>` from the existing mobile E2E.
|
||||
Keep the dedicated phone layout, chat scroll owner, safe-area navigation, and composer.
|
||||
Assert no document horizontal overflow and a session-row hit target of at least 44px.
|
||||
|
||||
## Verification
|
||||
|
||||
Run from repository root; install dependencies once for a fresh worktree.
|
||||
|
||||
```bash
|
||||
(cd apps/backend && go test ./cmd/mock-agent -run 'TestLoadSession(RetainsMCPServersForResumedPrompt|ReturnsCapabilitiesForResumedSession)' -count=1)
|
||||
(cd apps && pnpm install --frozen-lockfile)
|
||||
(cd apps/web && pnpm exec vitest run components/task/launch-queue-status.test.tsx hooks/domains/session/use-session-resumption.test.ts)
|
||||
(cd apps/web && pnpm run typecheck)
|
||||
(cd apps/web && pnpm run i18n:check)
|
||||
(cd apps/web && pnpm e2e:run --project chromium tests/workflow/queued-session-ownership.spec.ts)
|
||||
(cd apps/web && pnpm e2e:run --project mobile-chrome tests/workflow/mobile-queued-session-ownership.spec.ts)
|
||||
(cd apps/web && pnpm e2e:run --project chromium tests/workflow/queued-session-ownership.spec.ts tests/workflow/workflow-peer-resume.spec.ts)
|
||||
(cd apps/web && pnpm e2e:run --project mobile-chrome tests/workflow/mobile-queued-session-ownership.spec.ts tests/workflow/mobile-workflow-peer-resume.spec.ts)
|
||||
python3 scripts/list-docs.py validate
|
||||
python3 scripts/lint-spec-files.test.py
|
||||
python3 scripts/lint-spec-files.py --all
|
||||
@@ -92,10 +124,14 @@ Assert provider readiness independently of workspace-only readiness.
|
||||
- `apps/web/components/task/launch-queue-status.tsx` and its test.
|
||||
- `apps/web/components/task/task-chat-panel.tsx`.
|
||||
- `apps/web/hooks/domains/session/use-session-resumption.test.ts`.
|
||||
- `apps/web/src/locales/{en,pseudo,pt-pt,zh-cn,zh-hk,zh-tw}/task.json`.
|
||||
- `apps/web/src/locales/{en,pseudo,pt-pt,zh-cn,zh-hk,zh-tw,ja}/task.json`.
|
||||
- `apps/web/e2e/tests/workflow/queued-session-ownership.spec.ts`.
|
||||
- `apps/web/e2e/tests/workflow/mobile-queued-session-ownership.spec.ts`.
|
||||
- `apps/web/e2e/tests/workflow/queued-session-ownership-helpers.ts`.
|
||||
- `apps/web/e2e/tests/workflow/workflow-peer-resume.spec.ts` (new).
|
||||
- `apps/web/e2e/tests/workflow/mobile-workflow-peer-resume.spec.ts` (new).
|
||||
- `apps/web/e2e/tests/workflow/workflow-peer-resume-helpers.ts` (new).
|
||||
- `apps/backend/cmd/mock-agent/main.go` and `session_config_test.go` for resumed MCP fixture support.
|
||||
- `docs/public/tasks-and-workflows.md` and `docs/public/agents-and-profiles.md`.
|
||||
- This package's status/results and companion references.
|
||||
|
||||
@@ -122,4 +158,21 @@ Restore fixture capacity and user settings even when assertions fail.
|
||||
|
||||
## Results
|
||||
|
||||
Pending. No frontend, E2E, or implementation check ran during planning.
|
||||
Complete. The desktop peer-resume test first failed at the expected assertion:
|
||||
the implementation session was RUNNING with active output and one parked note.
|
||||
After presentation removal:
|
||||
|
||||
- Mock-agent LoadSession MCP retention test: passed.
|
||||
- Targeted UI tests: 41 passed; typecheck and i18n check: passed.
|
||||
- Chromium queued-session, peer-resume, and session-open E2E: 3 passed.
|
||||
- Mobile queued-session, peer-resume, and session-open E2E: 3 passed.
|
||||
- Public-doc validation: 62 tests and 47 published pages passed.
|
||||
- Specification validation: 301 decisions, 1134 specifications, and 36 linter tests passed.
|
||||
- `git diff --check`: passed.
|
||||
|
||||
The peer delivery occurred once. The active Implement session kept its legacy
|
||||
parking marker while the banner stayed absent before and after reload and turn
|
||||
settlement. Review remained the workflow step and the initial session remained
|
||||
primary. The phone session row met the 44px target and showed no horizontal overflow.
|
||||
The direct session-open flows captured `activation_source: session_open` and
|
||||
confirmed the original workflow prompt was not replayed.
|
||||
|
||||
@@ -291,8 +291,10 @@ candidate policies are configured on dynamic profiles.
|
||||
|
||||
When a task launch waits for session capacity, Kandev keeps the selected
|
||||
destination and retries it automatically. Inspecting another session does not
|
||||
resume a parked predecessor. Use an explicit **Resume** action or send a
|
||||
message for manual recovery. These actions can override the automatic ceiling.
|
||||
change the workflow's selected step or primary session. You can open another
|
||||
session and use its normal controls. Kandev resumes it when the session can run;
|
||||
the automatic session ceiling still applies unless you explicitly start,
|
||||
resume, or message the session.
|
||||
|
||||
Provider errors that occur before a result can use the configured action, such
|
||||
as retrying the current candidate or trying the next candidate. A started turn
|
||||
|
||||
@@ -655,7 +655,11 @@ The session limit is off by default. Manual **Start**, **Resume**, or sending a
|
||||
|
||||
If session capacity blocks a start, Kandev keeps the selected destination and retries automatically. See [Agents and profiles](agents-and-profiles.md) for profile compatibility and recovery.
|
||||
|
||||
Parked sessions share the task workspace. Resuming one while a destination session is active can create concurrent writers. Wait until the active session finishes first.
|
||||
Sessions parked by workflow transitions share the task workspace. Opening one
|
||||
can resume it when recovery rules and session capacity allow. This does not
|
||||
change the selected workflow step or primary session. Multiple sessions can
|
||||
write to the same workspace at the same time, so review their activity before
|
||||
resuming a session while another session is active.
|
||||
|
||||
### Change session options by workflow step
|
||||
|
||||
|
||||
Reference in New Issue
Block a user