From 27871c34825cdbaa0d0bddba2b5558e80e376dae Mon Sep 17 00:00:00 2001
From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com>
Date: Fri, 6 Nov 2026 03:45:09 +0800
Subject: [PATCH 1/8] fix(workspace): keep background replies in Chat and
clarify completion
Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com>
---
.../dashboard/smoke/todo-ownership-smoke.mjs | 13 +-
.../personal-workspace/channel-header.tsx | 13 +-
.../personal-workspace/goal-draft-card.css | 3 -
.../src/features/personal-workspace/i18n.tsx | 2 +
.../personal-workspace-contract.test.mjs | 12 +-
.../personal-workspace-model.ts | 2 +
.../personal-workspace-page.tsx | 184 ++----------------
.../personal-workspace/personal-workspace.css | 38 +---
.../todo-completion-feedback.ts | 12 +-
.../attention-user-action.mjs | 8 +
.../chat-recovery.mjs | 46 +++--
.../conversation-activity.mjs | 52 ++++-
.../conversation-startup.mjs | 19 +-
.../personal-workspace-browser/goal-draft.mjs | 2 +-
14 files changed, 152 insertions(+), 254 deletions(-)
diff --git a/apps/presentation/dashboard/smoke/todo-ownership-smoke.mjs b/apps/presentation/dashboard/smoke/todo-ownership-smoke.mjs
index c05641e689..dd5d482c1c 100644
--- a/apps/presentation/dashboard/smoke/todo-ownership-smoke.mjs
+++ b/apps/presentation/dashboard/smoke/todo-ownership-smoke.mjs
@@ -9,7 +9,7 @@ import { workspaceAgentTodoFromItem } from "../src/features/personal-workspace/p
import { ContextDrawer } from "../src/features/personal-workspace/context-drawer.tsx";
import { WorkspaceI18nProvider, useWorkspaceI18n } from "../src/features/personal-workspace/i18n.tsx";
import { ChatApiError } from "../src/data/chat.ts";
-import { todoCompletionPreviewFeedback } from "../src/features/personal-workspace/todo-completion-feedback.ts";
+import { completedActionFeedback, todoCompletionPreviewFeedback } from "../src/features/personal-workspace/todo-completion-feedback.ts";
import { RunRow } from "../src/features/personal-workspace/cards/run-row.tsx";
// A Goal's default executor is not evidence that a Todo has been claimed.
@@ -21,6 +21,17 @@ try {
const render = (component, props) => renderToStaticMarkup(createElement(
WorkspaceI18nProvider, null, createElement(component, props),
));
+ function CompletedFeedback({proposal}) {
+ return completedActionFeedback(proposal, useWorkspaceI18n().t);
+ }
+ const fullTask = "Review all evidence before external execution. ".repeat(50);
+ assert.equal(render(CompletedFeedback, {proposal: {actionKind: "todo.update", todoOperation: "complete", title: fullTask}}),
+ locale === "en" ? "Marked complete" : "已标记完成");
+ for (const proposal of [{actionKind: "todo.update", todoOperation: "cancel"},
+ {actionKind: "todo.create", todoOperation: "complete"}, {actionKind: "todo.update"}]) {
+ assert.ok(render(CompletedFeedback, {proposal: {...proposal, title: "complete"}}).includes("complete"),
+ "Only the typed completion operation changes result wording; titles never classify actions");
+ }
const completionRequest = {actionKind: "todo.update", normalizedParameters: {operation: "complete"}};
function CompletionFeedback({error, request}) {
return todoCompletionPreviewFeedback(error, request, useWorkspaceI18n().t);
diff --git a/apps/presentation/dashboard/src/features/personal-workspace/channel-header.tsx b/apps/presentation/dashboard/src/features/personal-workspace/channel-header.tsx
index a8060ebd2e..8780a616de 100644
--- a/apps/presentation/dashboard/src/features/personal-workspace/channel-header.tsx
+++ b/apps/presentation/dashboard/src/features/personal-workspace/channel-header.tsx
@@ -1,4 +1,4 @@
-import { List, Bot, Eye, FolderOpen, Menu, RefreshCw, SlidersHorizontal } from "lucide-react";
+import { List, Bot, Eye, FolderOpen, LoaderCircle, Menu, RefreshCw, SlidersHorizontal } from "lucide-react";
import { GoalLifecycleControl, GoalMastheadIdentity } from "./goal-masthead";
import { useWorkspaceI18n } from "./i18n";
@@ -12,6 +12,7 @@ export function ChannelHeader({
agents,
agentWorkActive = false,
conversationScope,
+ conversationRunning = false,
goalLifecycleBusy,
managerChannelBinding,
managerChatOpen,
@@ -45,6 +46,7 @@ export function ChannelHeader({
}: {
agents: WorkspaceAgentOption[];
agentWorkActive?: boolean;
+ conversationRunning?: boolean;
/** Steward conversation scope; absent when the host grants no workspace. */
conversationScope?: { options: { disabled?: boolean; label: string; value: string }[]; value: string; onChange: (value: string) => void } | null;
goalLifecycleBusy?: boolean;
@@ -84,6 +86,11 @@ export function ChannelHeader({
}) {
const { locale, t } = useWorkspaceI18n();
const statusOnly = selectedAgentId === "status-only";
+ const chatLabel = <>{t("header.chat")}{conversationRunning ? (
+
+
+
+ ) : null}>;
const ordinaryConversation = Boolean(selectedGoal || workspaceGrantLabel);
// The chip reports the selected executor, whose credential pays for it, and
// the resolved model, so an executor and a model that disagree are visible
@@ -259,7 +266,7 @@ export function ChannelHeader({
-
+
{researchControl}
@@ -268,7 +275,7 @@ export function ChannelHeader({
) : !scopeUnavailable ? (
) : null}
diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-draft-card.css b/apps/presentation/dashboard/src/features/personal-workspace/goal-draft-card.css
index 0153915064..e6d5f9fc15 100644
--- a/apps/presentation/dashboard/src/features/personal-workspace/goal-draft-card.css
+++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-draft-card.css
@@ -7,6 +7,3 @@
.personal-goal-draft button { min-height: 44px; padding: 8px 12px; border: 1px solid var(--pw-line-strong); border-radius: 6px; background: transparent; color: inherit; text-align: left; white-space: normal; overflow-wrap: anywhere; }
.personal-goal-draft button:focus-visible { outline: 2px solid var(--pw-blue); outline-offset: 2px; }
.personal-goal-draft footer { display: flex; flex-wrap: wrap; gap: 12px; align-items: center; justify-content: space-between; margin-top: 16px; }
-.personal-manager-conversation-tray:has(.personal-goal-draft) { max-height: min(620px, 65dvh); }
-.personal-manager-conversation-tray:has(.personal-goal-draft) .personal-manager-conversation-messages { min-height: 0; max-height: none; }
-.personal-manager-conversation-bubble .personal-goal-draft > strong { color: var(--pw-text); font-size: var(--text-body); line-height: var(--leading-body); font-weight: 600; }
diff --git a/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx b/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx
index 1f19d0ae9d..6a6eadf865 100644
--- a/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx
+++ b/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx
@@ -258,6 +258,7 @@ const en = {
"feedback.applying": "Running: {title}",
"feedback.cancelFailed": "Cancel failed: {error}",
"feedback.completed": "Completed: {title}",
+ "feedback.todoMarkedComplete": "Marked complete",
"feedback.executionFailed": "Execution failed: {error}",
"feedback.gateRequired": "Your confirmation is required: {summary}",
"feedback.notCompleted": "Not completed: {status}",
@@ -1920,6 +1921,7 @@ const zhCN: Record
= {
"feedback.applying": "正在执行:{title}",
"feedback.cancelFailed": "取消失败:{error}",
"feedback.completed": "已完成:{title}",
+ "feedback.todoMarkedComplete": "已标记完成",
"feedback.executionFailed": "执行失败:{error}",
"feedback.gateRequired": "需要你确认:{summary}",
"feedback.notCompleted": "操作未完成:{status}",
diff --git a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-contract.test.mjs b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-contract.test.mjs
index d0e55e5634..75740c6376 100644
--- a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-contract.test.mjs
+++ b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-contract.test.mjs
@@ -323,16 +323,8 @@ assert.match(attentionActions, /role="group" aria-label=\{t\("drawer\.decisionGr
assert.match(drawer, /const hasProjectedRunActivity = selection\.kind === "run"[\s\S]*selection\.item\.completedSteps > 0/, "Session empty-state copy distinguishes projected progress from a truly idle run");
assert.match(drawer, /t\("drawer\.runRecordProjected"/, "A projected run does not claim that the Agent never started");
assert.match(drawer, /t\("drawer\.runRecordEmpty"\)/, "A truly empty Session still explains why there is no timeline yet");
-assert.match(page, /function ManagerConversationTray/, "Manager home has a dedicated conversation tray instead of burying replies in the board timeline");
assert.match(page, /managerMessages/, "Manager conversation is derived from the active manager message context");
-assert.match(page, /managerConversationReceiptVisible/, "Manager conversation is a send-triggered receipt instead of permanent history chrome");
-assert.match(page, /setManagerConversationReceiptVisible\(true\)/, "A new manager send reveals the temporary conversation receipt");
-assert.match(page, /setManagerConversationReceiptVisible\(false\)/, "Opening or leaving the receipt clears it from the overview");
-assert.match(page, /personal-manager-conversation-tray/, "Manager conversation stays anchored beside the composer");
-assert.match(page, /aria-live="polite"/, "Manager conversation announces streamed replies without moving focus");
-assert.match(page, /t\("conversation\.full"\)/, "The compact manager conversation can expand in place");
-assert.match(page, /onOpenConversation/, "The compact manager conversation has a dedicated full-chat navigation action");
-assert.match(page, /managerChatOpen/, "Manager full conversation uses a dedicated Chat view instead of stretching the home tray");
+assert.match(page, /managerChatOpen/, "Manager conversation uses a dedicated Chat view");
assert.match(page, /managerChatItems/, "Manager Chat only renders conversation and confirmation items");
assert.match(page, /sessionProposalIds\.includes\(item\.proposal\.previewId\)/, "Manager Chat only shows proposals created in the current UI session");
assert.match(page, /\["preview_ready", "gated", "deferred", "applying"\]\.includes\(proposal\.status\)[\s\S]*compileActionReviewPlan\(proposal\)\.retryOriginal === true/, "Restored proposal history uses wire states and preserves retryable original operations");
@@ -347,8 +339,6 @@ assert.doesNotMatch(sidebar, /personal-manager-channels/, "The sidebar does not
assert.doesNotMatch(sidebar, /onSelectChannel/, "The sidebar only navigates to the manager or a concrete Goal");
assert.doesNotMatch(page, /selectedChannel|selectChannel\(|__manager_channel__/, "Manager state lanes do not create hidden conversation channels");
assert.match(page, /personal-digest-stats[\s\S]*t\("digest\.completed"\)/, "The away digest is a non-interactive summary");
-assert.match(page, /!selectedGoal && !managerChatOpen && managerConversationReceiptVisible[\s\S]* message\.pending\)[\s\S]*setManagerConversationReceiptVisible\(true\)/, "A recovered active Manager turn restores its compact conversation tray");
assert.match(page, /goal\.needsYou \?\? goal\.nextSentence/, "Needs-you cards keep the source Goal action visible");
assert.match(page, /onClick=\{\(\) => onSelectGoal\(goal\.goalId\)\}/, "Manager cards open their source Goal instead of a hidden lane channel");
assert.doesNotMatch(page, /managerConversationActive/, "Sending from the manager never replaces the overview with a separate conversation page");
diff --git a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-model.ts b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-model.ts
index 994f3f712b..15af4c9f5c 100644
--- a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-model.ts
+++ b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-model.ts
@@ -340,6 +340,8 @@ export type WorkspaceSchedule = {
export type WorkspaceActionPreview = {
/** Exact Todo target from the stored action, used only for display joins. */
todoId?: string;
+ /** Stored Todo operation for result wording; never grants execution authority. */
+ todoOperation?: string;
reviewPlan?: ActionReviewPlan;
actionKind:
| "goal.create"
diff --git a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx
index b390d1071f..5ebda586e9 100644
--- a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx
+++ b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx
@@ -1,22 +1,20 @@
import {DocumentAttachment, isTextDocument} from "./document-attachment";
-import {AlertCircle, Bot, CalendarClock, CircleHelp, FileText, ListPlus, MessageCircleQuestion, Paperclip, Plus, RefreshCw, Send, Square, X} from "lucide-react";
+import {AlertCircle, Bot, CalendarClock, CircleHelp, FileText, MessageCircleQuestion, Paperclip, Plus, RefreshCw, Send, Square, X} from "lucide-react";
import {ContextHelp} from "./context-help";
import {readSettingsRoute, writeSettingsRoute} from "./workspace-settings-route";
import {hasConfiguredGoalChecks} from "../../data/goal-acceptance-observation";
import {useDeliveryReview} from "./delivery-review-state";
import {useGoalWorkspaceDigest} from "./goal-masthead";
import { goalCreateRequest, goalCreationAgentId, goalDraftReady } from "./goal-create-request";
-import { todoCompletionPreviewFeedback, todoCompletionProofRequired } from "./todo-completion-feedback";
+import { completedActionFeedback, todoCompletionPreviewFeedback, todoCompletionProofRequired } from "./todo-completion-feedback";
import { readSteeringRequest, retainSteeringRequest, retireSteeringRequest, legacySteeringRequests, restoreLegacySteeringRequest } from "./steering-recovery";
import { conversationInputKey, useConversationInputState } from "./use-conversation-input-state";
import { composerKeyAction } from "./composer-keyboard";
import type { ConversationHistoryStatus } from "../../data/use-conversation-history";
import { useConversationQueue } from "../../data/use-conversation-queue";
import { ConversationQueue } from "./conversation-queue";
-import { GoalDraftCard } from "./goal-draft-card";
import type { GoalDraft } from "../../../../../../loopx/control_plane/collaboration/goal_draft.js";
-import { CollaborationCards } from "./collaboration-card";
-import {ConversationRequests, ConversationReplyContext, OriginalProjectRequests} from "./conversation-requests";
+import {ConversationRequests, OriginalProjectRequests} from "./conversation-requests";
import {
compileActionReviewPlan,
isStaleActionFailure,
@@ -55,7 +53,6 @@ import { GoalTeamResults } from "./goal-team-results";
import { GoalManagedResults } from "./goal-managed-results";
import { GoalResearchResults, type GoalResearchApi } from "./goal-research-results";
import { sendLoopXMessage, type LoopXModeSnapshot } from "../../data/chat";
-import { MessageActivity } from "./message-activity";
import { ChannelTimeline } from "./channel-timeline";
import { ContextDrawer } from "./context-drawer";
import { monitorScheduleReadback } from "./monitor-readback";
@@ -69,8 +66,6 @@ import { GoalVitals, GoalVitalsSkeleton, type GoalVitalRegion } from "./goal-mas
import { GoalWorkspacePanels } from "./goal-workspace-panels";
import {GoalWorkBrowseProvider, readGoalWorkBrowse, type GoalWorkBrowseState} from "./goal-work-browse";
import { localizedGoalState, localizedSessionStatus, useWorkspaceI18n, type WorkspaceTranslate } from "./i18n";
-import { MarkdownText } from "./markdown";
-import { ReturnDeliveryStatus } from "./return-delivery-status";
import type {
PersonalWorkspaceCallbacks,
WorkspaceAgentOption,
@@ -400,102 +395,6 @@ function GoalOutputsView({
);
}
-function ManagerConversationTray({
- agentLabel,
- messages,
- onClose,
- onDraftTask,
- onReviewGoalDraft,
- onSuggestReply,
- onOpenConversation,
- onInterruptTurn,
- onAdjustTurn,
- onCancelPreparation,
- title,
-}: {
- agentLabel?: string;
- messages: Array['message']>;
- onClose?: () => void;
- onDraftTask?: (text: string) => void;
- onReviewGoalDraft?: (draft: GoalDraft, edit?: boolean, draftId?: string) => Promise;
- onSuggestReply?: (text: string) => void;
- onOpenConversation: () => void;
- onInterruptTurn?: (turnId: string) => Promise;
- onAdjustTurn?: (turnId: string) => void;
- onCancelPreparation?: () => void;
- title?: string;
-}) {
- const { t, locale } = useWorkspaceI18n();
- const latestUserIndex = messages.reduce((latest, message, index) => message.role === "user" ? index : latest, 0);
- const latestExchange = messages.slice(Math.max(0, latestUserIndex));
- const visibleMessages = latestExchange.slice(-3);
- const activeIndex = messages.reduce((latest, message, index) => message.role === "assistant" && message.pending ? index : latest, -1);
- // A queued follow-up must not hide the current Turn's activity and controls.
- if (activeIndex >= 0 && activeIndex < latestUserIndex) visibleMessages.unshift(messages[activeIndex]);
- const latestAssistantMessage = visibleMessages.filter((item) => item.role === "assistant" && !item.pending).at(-1);
-
- useEffect(() => {
- if (!onClose) return;
- const handleKeyDown = (event: KeyboardEvent) => {
- if (event.key === "Escape") onClose();
- };
- window.addEventListener("keydown", handleKeyDown);
- return () => window.removeEventListener("keydown", handleKeyDown);
- }, [onClose]);
-
- return (
-
- );
-}
-
function SessionRecordHeader({ onClose, onOpenDetails, run }: {
onClose: () => void;
onOpenDetails: () => void;
@@ -807,6 +706,8 @@ function workspaceProposal(proposal: TypedActionProposal, t: WorkspaceTranslate)
return {
actionKind: proposal.action_kind,
todoId: typeof proposal.normalized_parameters.todo_id === "string" ? proposal.normalized_parameters.todo_id : undefined,
+ todoOperation: proposal.action_kind === "todo.update" && typeof proposal.normalized_parameters.operation === "string"
+ ? proposal.normalized_parameters.operation : undefined,
reviewPlan,
fields: proposal.action_kind === "operation.execute"
? operationProposalFields(proposal, reviewPlan, t)
@@ -1074,8 +975,6 @@ export function PersonalWorkspacePage({
setLocalView(view);
callbacks.onSelectView?.(view);
}
- const [managerConversationReceiptVisible, setManagerConversationReceiptVisible] = useState(false);
- const [goalConversationReceiptVisible, setGoalConversationReceiptVisible] = useState(false);
const [actionDraft, setActionDraft] = useState(null);
const [loopxMode, setLoopxMode] = useState(null);
const [loopxDelivery, setLoopxDelivery] = useState<"queue" | "inbox" | "steer">("queue");
@@ -1365,22 +1264,6 @@ export function PersonalWorkspacePage({
() => items.flatMap((item) => item.kind === "message" ? [item.message] : []),
[items],
);
- const goalMessages = useMemo(
- () => selectedGoal ? items.flatMap((item) => item.kind === "message" ? [item.message] : []) : [],
- [items, selectedGoal],
- );
- useEffect(() => {
- if (selectedGoal || managerChatOpen) return;
- if (managerMessages.some((message) => message.pending)) {
- setManagerConversationReceiptVisible(true);
- }
- }, [managerChatOpen, managerMessages, selectedGoal]);
- useEffect(() => {
- if (!selectedGoal || selectedGoalTab === "chat") return;
- if (goalMessages.some((message) => message.pending)) {
- setGoalConversationReceiptVisible(true);
- }
- }, [goalMessages, selectedGoal, selectedGoalTab]);
// One composer; ordinary sends queue, while Command+Enter explicitly steers.
const composingRef = useRef(false);
useEffect(() => { composingRef.current = false; }, [composerDraftKey]);
@@ -1464,8 +1347,7 @@ export function PersonalWorkspacePage({
onRetryHistory: conversationHistoryState?.retry,
onReadMessage: () => { followConversationRef.current = false; },
};
- const conversationMessages = selectedGoal ? goalMessages : managerMessages;
- const latestMessage = conversationMessages.at(-1);
+ const latestMessage = managerMessages.at(-1);
const latestMessageTextLength = latestMessage?.text.length ?? 0;
function scrollToLatestMessage() {
followConversationRef.current = true;
@@ -1509,7 +1391,7 @@ export function PersonalWorkspacePage({
scroller.querySelectorAll(".personal-channel-timeline").forEach(element => observer.observe(element));
}
return () => {observer.disconnect(); scroller?.removeEventListener("toggle", readQuote, true); window.cancelAnimationFrame(frame);};
- }, [conversationOpen, conversationKey, selectedAgentId, conversationMessages.length,
+ }, [conversationOpen, conversationKey, selectedAgentId, managerMessages.length,
latestMessageTextLength, latestMessage?.pending, latestMessage?.activity?.length, latestMessage?.steps]);
useEffect(() => {
const route = `${selectionScope}:${routedTodoId ?? ""}`;
@@ -1879,6 +1761,10 @@ export function PersonalWorkspacePage({
workspaceCandidates,
};
}
+ if (request.actionKind === "todo.update" && callbacks.onPreviewAction) {
+ local = { ...local, todoOperation: typeof request.normalizedParameters.operation === "string"
+ ? request.normalizedParameters.operation : undefined };
+ }
rememberSessionProposal(local.previewId, selectedGoalId);
setProposals((current) => ({ ...current, [local.previewId]: local }));
if (options.select !== false && isCurrentConversation() && selectionRevision.current === originatingSelection) {
@@ -2109,7 +1995,7 @@ export function PersonalWorkspacePage({
const applied = { ...proposal, status: "applied" as const };
setProposals((current) => ({ ...current, [proposal.previewId]: applied }));
if (showDrawer) selectReceipt(applied);
- setActionFeedback(t("feedback.completed", { title: proposal.title }));
+ setActionFeedback(completedActionFeedback(applied, t));
if (proposal.actionKind === "goal.lifecycle") {
if (proposal.lifecycleOperation === "delete" || (proposal.lifecycleOperation === "stop" && !options.keepGoalSelected)) {
if (stillSelected()) selectGoal(null);
@@ -2152,7 +2038,7 @@ export function PersonalWorkspacePage({
);
return applied;
}
- setActionFeedback(t("feedback.completed", { title: applied.title }));
+ setActionFeedback(completedActionFeedback(applied, t));
// Keep the success receipt visible for reviewed actions. Direct actions
// surface the same result through the persistent feedback receipt.
if (applied.actionKind === "todo.create" || applied.actionKind === "goal.create") {
@@ -2225,7 +2111,7 @@ export function PersonalWorkspacePage({
if (options.presentation !== "inline") selectReceipt(reconciled);
if (observed.reviewPlan?.creationProgress?.goalId) void callbacks.onRefresh?.();
setActionFeedback(stored.status === "stale" ? t("feedback.stale")
- : observed.reviewPlan?.interaction === "completed" ? t("feedback.completed", { title: observed.title })
+ : observed.reviewPlan?.interaction === "completed" ? completedActionFeedback(observed, t)
: t("feedback.executionFailed", { error: error instanceof Error ? error.message : String(error) }));
return reconciled;
}
@@ -2247,7 +2133,6 @@ export function PersonalWorkspacePage({
function openGoalConversation() {
setSelectedGoalTab("chat");
setActiveSessionRun(null);
- setGoalConversationReceiptVisible(false);
window.requestAnimationFrame(() => {
const replies = channelScrollRef.current?.querySelectorAll(".personal-message.is-assistant");
replies?.item(replies.length - 1)?.scrollIntoView({ block: "start" });
@@ -2397,8 +2282,6 @@ export function PersonalWorkspacePage({
function selectGoal(goalId: string | null, view: WorkspaceGoalTab = "overview") {
setLocalGoalId(goalId);
- setManagerConversationReceiptVisible(false);
- setGoalConversationReceiptVisible(false);
setActiveSessionRun(null);
setSelection(null);
setLocalView(view);
@@ -2571,8 +2454,6 @@ export function PersonalWorkspacePage({
setImageAttachmentError(null);
setSending(true);
try {
- if (!selectedGoalId) setManagerConversationReceiptVisible(true);
- else if (selectedGoalTab !== "chat") setGoalConversationReceiptVisible(true);
const previews = await callbacks.onSendMessage?.(message, selectedAgentId, conversationKey, pendingImages.length ? pendingImages : undefined);
if (previews?.candidates?.length) {
const drafted = await Promise.allSettled(previews.candidates.map((request) => createPreview(request, { select: false })));
@@ -2794,7 +2675,7 @@ export function PersonalWorkspacePage({
}
}, [attentionKeySignature, attentionNotificationsActive, attentionNotificationSupportState, t]);
- const composerSuggestions = (!originalConversation || !managerChatOpen) && !selectedWorkspaceRef && (!conversationOpen || !conversationMessages.length) ?
+ const composerSuggestions = (!originalConversation || !managerChatOpen) && !selectedWorkspaceRef && (!conversationOpen || !managerMessages.length) ?
{selectedGoal && goalSuggestions.length ? (
{goalSuggestions.map((suggestion) => (
@@ -2922,6 +2803,7 @@ export function PersonalWorkspacePage({
value: selectedWorkspaceRef ?? stewardScopeValue,
} : null}
workspaceGrantLabel={selectedWorkspaceRef ? t(workspaceUnavailable ? "workspace.grantRevoked" : selectedWorkspaceProject?.grant === "workspace_write" ? "workspace.grantWrite" : "workspace.grantRead") : null}
+ conversationRunning={Boolean((runningMessage && !runningMessage.queued) || loopxDeliveryOpen)}
managerChatOpen={managerChatOpen}
managerChannelBinding={managerChannelBinding}
managerRuntime={managerRuntime}
@@ -2933,10 +2815,7 @@ export function PersonalWorkspacePage({
onOpenManagerSettings={!readOnly ? () => openSettings({ kind: "settings", tab: "steward" }) : undefined}
onRefresh={callbacks.onRefresh && !runtimeSourceUnavailable ? () => void refreshWorkspace() : undefined}
onOpenNavigation={() => setMobileSidebarOpen(true)}
- onOpenManagerChat={() => {
- setManagerConversationReceiptVisible(false);
- setSelectedGoalTab("chat");
- }}
+ onOpenManagerChat={() => setSelectedGoalTab("chat")}
onSelectGoalTab={(tab) => {
if (tab === "chat") openGoalConversation();
else setSelectedGoalTab(tab);
@@ -2945,7 +2824,6 @@ export function PersonalWorkspacePage({
onReturnManagerHome={() => {
if (selectedWorkspaceRef || originalConversation) workspaceConversations?.onSelect(null);
setSelectedGoalTab("overview");
- setManagerConversationReceiptVisible(false);
window.requestAnimationFrame(() => channelScrollRef.current?.scrollTo({ behavior: "smooth", top: 0 }));
}}
selectedAgentId={selectedAgentId}
@@ -3131,34 +3009,6 @@ export function PersonalWorkspacePage({
{readOnly ? (
sourceGoal?.sourceAccess ? null :
{t("source.readOnlyNoticeTitle")}{t("source.readOnlyNoticeDescription")}
) : <>
- {!selectedGoal && !managerChatOpen && managerConversationReceiptVisible && managerMessages.length ? (
-
callbacks.onCancelConversationPreparation!(conversationKey) : undefined}
- onInterruptTurn={!readOnly && callbacks.onInterruptConversationTurn ? (turnId) => callbacks.onInterruptConversationTurn!(conversationKey, turnId) : undefined}
- onAdjustTurn={steeringTurnId ? focusTurnComposer : undefined}
- messages={managerMessages}
- onClose={() => setManagerConversationReceiptVisible(false)}
- onOpenConversation={() => {
- setManagerConversationReceiptVisible(false);
- setSelectedGoalTab("chat");
- }} />
- ) : null}
- {selectedGoal && selectedGoalTab !== "chat" && goalConversationReceiptVisible && goalMessages.length ? (
- callbacks.onCancelConversationPreparation!(selectedGoal.goalId) : undefined}
- onInterruptTurn={!readOnly && callbacks.onInterruptConversationTurn ? (turnId) => callbacks.onInterruptConversationTurn!(selectedGoal.goalId, turnId) : undefined}
- onAdjustTurn={steeringTurnId ? focusTurnComposer : undefined}
- messages={goalMessages}
- onClose={() => setGoalConversationReceiptVisible(false)}
- onDraftTask={selectedGoalTab === "tasks" ? (reply) => {
- const taskDraft = reply.trim();
- setActionDraft({ kind: "todo", text: taskDraft, goalId: selectedGoalId, goalTitle: selectedGoal.title, agentId: selectedAgentId });
- } : undefined}
- onOpenConversation={openGoalConversation}
- title={`${selectedGoal.title} · ${selectedAgentLabel}`}
- />
- ) : null}
{actionFeedback ? (
{actionFeedback}
diff --git a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace.css b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace.css
index b5ed92ce26..7f7d441c84 100644
--- a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace.css
+++ b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace.css
@@ -246,6 +246,7 @@
.personal-channel-header { position: relative; z-index: 8; display: flex; align-items: center; justify-content: space-between; gap: 16px; min-height: 74px; padding: 12px 32px; border-bottom: 1px solid var(--pw-line); background: rgb(251 250 247 / 92%); backdrop-filter: blur(12px); overflow: visible; }
.personal-goal-tabs { display: flex; align-items: center; align-self: center; gap: 2px; margin-left: auto; padding: 3px; border-radius: 10px; background: #f2f1ed; }
.personal-goal-tabs button { min-height: 30px; padding: 0 13px; border: 0; border-radius: 8px; background: transparent; color: var(--pw-muted); cursor: pointer; font-size: var(--text-body); line-height: var(--leading-body); font-weight: 500; }
+.personal-chat-activity { display: inline-flex; margin-left: 6px; vertical-align: middle; }
.personal-goal-tabs button:hover { color: var(--pw-text); }
.personal-goal-tabs button[aria-current="page"] { background: #fff; color: var(--pw-text); box-shadow: 0 1px 3px rgb(30 28 20 / 10%); }
.personal-channel-title { min-width: 0; }
@@ -1214,29 +1215,6 @@ button.personal-execution-chip:focus-visible { outline: 2px solid #0070f3; outli
.personal-channel:has(.personal-home-board) > .personal-composer-wrap { padding-inline: max(32px, calc((100% - 960px) / 2)); }
.personal-read-only-notice { display: flex; align-items: center; justify-content: center; gap: 4px; width: fit-content; min-height: 36px; margin-inline: auto; color: var(--pw-muted); font-size: var(--text-body); line-height: var(--leading-body); }
.personal-read-only-notice strong { font-weight: 500; white-space: nowrap; }
-.personal-manager-conversation-tray { display: grid; width: 100%; gap: 9px; max-height: 320px; padding: 12px 14px; margin-bottom: 10px; overflow: hidden; border: 1px solid #bfcdf1; border-radius: 14px; background: rgb(255 255 255 / 97%); box-shadow: 0 5px 20px rgb(34 62 120 / 10%); color: inherit; font: inherit; text-align: left; animation: pw-tray-in 0.16s cubic-bezier(0.16, 1, 0.3, 1); }
-@keyframes pw-tray-in { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: translateY(0); } }
-.personal-manager-conversation-tray:hover { border-color: #8fa9ee; box-shadow: 0 7px 24px rgb(34 62 120 / 14%); }
-.personal-manager-conversation-tray:focus-within { border-color: var(--pw-blue); box-shadow: 0 0 0 3px rgb(47 102 233 / 13%), 0 5px 20px rgb(34 62 120 / 10%); }
-.personal-manager-conversation-tray > header { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
-.personal-manager-conversation-tray > header > span { display: flex; align-items: center; gap: 7px; color: var(--pw-blue-ink); }
-.personal-manager-conversation-tray > header strong { font-size: var(--text-lead); font-weight: 600; line-height: var(--leading-lead); }
-.personal-manager-conversation-tray > header small { color: var(--pw-faint); font-size: var(--text-caption); font-weight: 500; line-height: var(--leading-caption); }
-.personal-manager-conversation-actions { display: flex; align-items: center; gap: 8px; }
-.personal-manager-conversation-btn { display: inline-flex; align-items: center; gap: 5px; min-height: 32px; padding: 5px 10px; border: 1px solid #b9c9ee; border-radius: 8px; background: #f4f7fe; color: var(--pw-blue-ink); font-size: var(--text-caption); font-weight: 600; line-height: var(--leading-caption); cursor: pointer; transition: all .12s ease; }
-.personal-manager-conversation-btn:hover { background: var(--pw-blue-soft); border-color: var(--pw-blue); }
-.personal-manager-conversation-link { display: inline-flex; align-items: center; min-height: 32px; padding: 5px 8px; border: 0; border-radius: 7px; background: transparent; color: var(--pw-blue); font-size: var(--text-body); font-weight: 500; line-height: var(--leading-body); cursor: pointer; transition: background .12s ease; }
-.personal-manager-conversation-link:hover { background: var(--pw-blue-soft); }
-.personal-manager-conversation-close { display: inline-flex; align-items: center; justify-content: center; width: 32px; height: 32px; padding: 0; border: 0; border-radius: 6px; background: transparent; color: var(--pw-faint); cursor: pointer; transition: all .12s ease; }
-.personal-manager-conversation-close:hover { background: #fee2e2; color: #ef4444; }
-.personal-manager-conversation-messages { display: grid; gap: 7px; overflow-y: auto; max-height: 220px; }
-.personal-manager-conversation-messages article { display: grid; grid-template-columns: 86px minmax(0, 1fr); gap: 10px; padding: 10px 12px; border-radius: 10px; background: #f5f7fc; font-size: var(--text-body); line-height: var(--leading-body); }
-.personal-manager-conversation-messages article.is-user { background: #edf3ff; }
-.personal-manager-conversation-messages article strong { color: var(--pw-muted); font-size: var(--text-caption); font-weight: 500; line-height: var(--leading-caption); }
-.personal-manager-conversation-bubble { min-width: 0; }
-.personal-manager-conversation-bubble p { margin: 0; white-space: pre-wrap; overflow-wrap: anywhere; }
-.personal-manager-conversation-bubble .personal-md { font-size: var(--text-body); line-height: var(--leading-prose); }
-.personal-manager-conversation-bubble small { display: inline-block; margin-top: 4px; color: var(--pw-blue); font-size: var(--text-caption); line-height: var(--leading-caption); }
.personal-goal-suggestions { display: flex; gap: 8px; margin-bottom: 9px; flex-wrap: wrap; }
.personal-goal-suggestions button { display: inline-flex; align-items: center; gap: 6px; min-height: 44px; padding: 5px 12px; border: 1px solid var(--pw-blue-soft); border-radius: 99px; background: var(--pw-blue-soft); color: var(--pw-blue-ink); font-size: var(--text-body); line-height: var(--leading-body); cursor: pointer; transition: all .14s ease; }
.personal-goal-suggestions button:hover { border-color: var(--pw-blue); }
@@ -1594,9 +1572,6 @@ button.personal-execution-chip:focus-visible { outline: 2px solid #0070f3; outli
.personal-task-lane-scroll, .personal-task-lane-scroll.has-overflow-before, .personal-task-lane-scroll.has-overflow-after { flex: none; min-height: auto; padding-right: 8px; overflow: visible; box-shadow: none; scrollbar-gutter: auto; }
.personal-quick-prompts { overflow-x: auto; flex-wrap: nowrap; }
.personal-quick-prompts button { flex: 0 0 auto; min-height: 36px; }
- .personal-manager-conversation-tray > header { align-items: flex-start; flex-wrap: wrap; }
- .personal-manager-conversation-actions { width: 100%; justify-content: flex-end; }
- .personal-manager-conversation-messages article { grid-template-columns: minmax(0, 1fr); gap: 4px; }
.personal-timeline-row, .personal-output-row { grid-template-columns: 34px minmax(0, 1fr) 14px; gap: 9px; padding: 10px 12px; }
.personal-timeline-row > time, .personal-timeline-row > .personal-row-status { display: none; }
.personal-timeline-row .personal-priority-dot { display: none; }
@@ -1667,7 +1642,7 @@ button.personal-execution-chip:focus-visible { outline: 2px solid #0070f3; outli
.personal-goal-lifecycle.is-pending svg { animation: none; }
.personal-goal-mark.is-live::before { animation: none; background: linear-gradient(135deg, #007cf0, #00dfd8); }
.personal-goal-activity-chip.is-live .personal-goal-activity-dot::after { animation: none; }
- .personal-spin { animation: none; }
+ .personal-spin, .personal-chat-activity .is-spinning { animation: none; }
}
/* ===== LoopX standard: docs/development/design.md product tokens ===== */
@@ -1957,15 +1932,6 @@ button.personal-execution-chip:focus-visible { outline: 2px solid #0070f3; outli
.personal-workspace-shell[data-pw-theme="brutal"] .personal-system-health-header { color: #141414; }
.personal-workspace-shell[data-pw-theme="brutal"] .personal-system-health-header small { color: #555; }
.personal-workspace-shell[data-pw-theme="brutal"] .personal-system-health-issues { color: #141414; }
-.personal-workspace-shell[data-pw-theme="brutal"] .personal-manager-conversation-tray { border: 2px solid #141414; border-radius: 6px; background: #fff; box-shadow: 4px 4px 0 #141414; }
-.personal-workspace-shell[data-pw-theme="brutal"] .personal-manager-conversation-tray:hover { border-color: #141414; box-shadow: 5px 5px 0 #141414; }
-.personal-workspace-shell[data-pw-theme="brutal"] .personal-manager-conversation-tray:focus-within { border-color: #141414; box-shadow: 5px 5px 0 #141414; }
-.personal-workspace-shell[data-pw-theme="brutal"] .personal-manager-conversation-btn { border: 2px solid #141414; border-radius: 4px; background: #ffe23f; color: #141414; box-shadow: 2px 2px 0 #141414; }
-.personal-workspace-shell[data-pw-theme="brutal"] .personal-manager-conversation-btn:hover { background: #ffd91a; }
-.personal-workspace-shell[data-pw-theme="brutal"] .personal-manager-conversation-link { color: #141414; text-decoration: underline; }
-.personal-workspace-shell[data-pw-theme="brutal"] .personal-manager-conversation-close { border: 1.5px solid #141414; border-radius: 4px; color: #141414; }
-.personal-workspace-shell[data-pw-theme="brutal"] .personal-manager-conversation-messages article { border: 1.5px solid #141414; border-radius: 4px; background: #fdf8e7; }
-.personal-workspace-shell[data-pw-theme="brutal"] .personal-manager-conversation-messages article.is-user { background: #8fdcff; }
.personal-workspace-shell[data-pw-theme="brutal"] .personal-quick-prompts button { border: 2px solid #141414; background: #fff; color: #141414; box-shadow: 2px 2px 0 #141414; }
.personal-workspace-shell[data-pw-theme="brutal"] .personal-quick-prompts button:hover { background: #ffe23f; border-color: #141414; color: #141414; }
.personal-workspace-shell[data-pw-theme="brutal"] .personal-first-goal-create { border: 2px solid #141414; border-radius: 4px; background: #ffe23f; color: #141414; box-shadow: 3px 3px 0 #141414; }
diff --git a/apps/presentation/dashboard/src/features/personal-workspace/todo-completion-feedback.ts b/apps/presentation/dashboard/src/features/personal-workspace/todo-completion-feedback.ts
index e2d2157ecc..925a54c75b 100644
--- a/apps/presentation/dashboard/src/features/personal-workspace/todo-completion-feedback.ts
+++ b/apps/presentation/dashboard/src/features/personal-workspace/todo-completion-feedback.ts
@@ -1,6 +1,6 @@
import { ChatApiError } from "../../data/chat";
import type { WorkspaceTranslate } from "./i18n";
-import type { WorkspaceActionPreviewRequest } from "./personal-workspace-model";
+import type { WorkspaceActionPreview, WorkspaceActionPreviewRequest } from "./personal-workspace-model";
/** Input shape only; current execution authority is always checked by the backend. */
export function parseTodoCompletionProof(text: string): {
@@ -36,3 +36,13 @@ export function todoCompletionPreviewFeedback(
|| !todoCompletionProofRequired(error)) return null;
return t("feedback.taskCompletionUnavailable");
}
+
+/** Call only after the action owner confirms completion of this exact proposal. */
+export function completedActionFeedback(
+ proposal: Pick
,
+ t: WorkspaceTranslate,
+): string {
+ return proposal.actionKind === "todo.update" && proposal.todoOperation === "complete"
+ ? t("feedback.todoMarkedComplete")
+ : t("feedback.completed", { title: proposal.title });
+}
diff --git a/examples/personal-workspace-browser/attention-user-action.mjs b/examples/personal-workspace-browser/attention-user-action.mjs
index 2df81d06d0..d27e409fc3 100644
--- a/examples/personal-workspace-browser/attention-user-action.mjs
+++ b/examples/personal-workspace-browser/attention-user-action.mjs
@@ -126,6 +126,14 @@ print(json.dumps({'port':s.server_port,'ids':ids}),flush=True); s.serve_forever(
} catch (error) {
throw new Error(`${error.message}; drawer=${await drawer.innerText()}; errors=${context.errors.join(" | ")}`);
}
+ if (operation === "complete") {
+ await page.getByRole("button", {name: "查看更新后的 Goal", exact: true}).waitFor();
+ const feedback = page.locator(".personal-action-feedback");
+ await feedback.waitFor();
+ await page.screenshot({path: resolve(outputDir, `user-action-completed-${registerDefault}.png`), animations: "disabled"});
+ assert.equal(await feedback.locator(":scope > span").innerText(), "已标记完成",
+ "Completion confirms the record change without repeating its task or claiming external execution");
+ }
await page.getByRole("button", { name: "查看更新后的 Goal" }).click();
assert.equal((await read(actionId)).status, "done");
assert.equal((await read(targetId)).status, operation === "complete" ? "open" : "blocked");
diff --git a/examples/personal-workspace-browser/chat-recovery.mjs b/examples/personal-workspace-browser/chat-recovery.mjs
index d0f721bbb0..5f2e3e1d4b 100644
--- a/examples/personal-workspace-browser/chat-recovery.mjs
+++ b/examples/personal-workspace-browser/chat-recovery.mjs
@@ -8,6 +8,18 @@ export const chatRecoveryScenario = {
async run({ browser, collectCoverage, url }) {
const context = await openWorkspacePage(browser, url, { collectCoverage });
const { api, checkpointCoverage, page } = context;
+ // An overview intentionally renders no pending reply. Wait on the native
+ // Session/message owner before sending the next ordinary question.
+ const backgroundReply = async prefix => {
+ const deadline = Date.now() + 10000;
+ while (Date.now() < deadline) {
+ const turn = api.turnRequests.findLast(row => row.message.includes(prefix));
+ if (turn && !page.__loopxRuntime.sessions.get(turn.sessionId)?.active_turn_id
+ && page.__loopxRuntime.messages.get(turn.sessionId)?.some(row => row.turn_id === turn.turnId && row.role !== "user")) return;
+ await page.waitForTimeout(50);
+ }
+ throw new Error(`Background reply did not complete: ${prefix}`);
+ };
const failures = [];
const notes = [];
const observations = [];
@@ -50,30 +62,35 @@ export const chatRecoveryScenario = {
await new Promise((resolveWait) => setTimeout(resolveWait, 50));
}
if (!api.turnRequests.some((turn) => turn.message.includes("汇总所有活跃 Goal 的最新进展与阻塞"))) throw new Error("Progress report shortcut did not send a useful scoped request");
+ await backgroundReply("汇总所有活跃 Goal");
await page.locator(".personal-message-pending").waitFor({ state: "hidden" });
- await page.locator(".personal-manager-conversation-tray").waitFor({ state: "visible" });
+ if (await page.locator(".personal-manager-conversation-tray").count()) throw new Error("A reply covered the manager overview");
if (!(await page.locator(".personal-home-lanes").isVisible())) throw new Error("Manager send replaced the home lane overview");
const managerUrlBefore = page.url();
// This is a fresh question, not an instruction to the shortcut's active Turn.
await page.getByLabel("向 LoopX 发送消息").fill("我现在该做什么?只读回答,不要创建或修改任何状态。");
await page.getByRole("button", { name: "发送", exact: true }).click();
+ await backgroundReply("我现在该做什么?");
+ if (page.url() !== managerUrlBefore) throw new Error("A background reply navigated away from the overview");
+ if (!(await page.locator(".personal-home-lanes").isVisible())) throw new Error("A background reply hid the overview");
+ if (await page.locator(".personal-manager-conversation-tray").count()) throw new Error("A reply reopened a floating conversation");
+ await page.screenshot({path: resolve(outputDir, "manager-overview-after-reply.png"), animations: "disabled"});
+ await managerNavigation.getByRole("button", {name: "对话", exact: true}).click();
await page.getByText("管家已读取当前授权范围的 Goal 证据。", { exact: true }).waitFor({ state: "visible" });
// The steward answers as the LoopX Manager. The executor that served the
// turn belongs to the machine-capability chip, so an answer must never be
// labelled with the CLI brand the agent picker happens to hold.
const answerIdentity = (
- await page.locator(".personal-manager-conversation-tray article.is-assistant strong").last().innerText()
+ await page.locator(".personal-channel-timeline article.is-assistant header strong").last().innerText()
).trim();
if (answerIdentity !== "LoopX 管家") {
throw new Error(`Manager answer was labelled as its executor instead of the steward: ${answerIdentity}`);
}
if (!api.turnRequests.some((turn) => turn.message.startsWith("我现在该做什么?"))) throw new Error("Manager question bypassed the global runtime");
- await page.getByText("查看完整对话", { exact: true }).waitFor({ state: "visible" });
- if (page.url() !== managerUrlBefore) throw new Error(`Manager send navigated away from the overview: ${managerUrlBefore} -> ${page.url()}`);
- const managerConversationType = await page.locator(".personal-manager-conversation-tray").evaluate((tray) => {
- const message = getComputedStyle(tray.querySelector("article p, article .personal-md"));
- const role = getComputedStyle(tray.querySelector("article > strong"));
- const action = getComputedStyle(tray.querySelector(".personal-manager-conversation-link"));
+ const managerConversationType = await page.locator(".personal-channel-timeline").evaluate((timeline) => {
+ const message = getComputedStyle(timeline.querySelector("article.is-assistant p, article.is-assistant .personal-md"));
+ const role = getComputedStyle(timeline.querySelector("article.is-assistant header strong"));
+ const action = getComputedStyle(document.querySelector(".personal-goal-tabs button"));
return {
actionFontSize: Number.parseFloat(action.fontSize),
messageFontSize: Number.parseFloat(message.fontSize),
@@ -85,15 +102,14 @@ export const chatRecoveryScenario = {
|| managerConversationType.messageLineHeight < 20
|| managerConversationType.roleFontSize < 12
|| managerConversationType.actionFontSize < 14) {
- throw new Error(`Manager conversation receipt typography is below the readable UI scale: ${JSON.stringify(managerConversationType)}`);
+ throw new Error(`Manager conversation typography is below the readable UI scale: ${JSON.stringify(managerConversationType)}`);
}
- await page.screenshot({ path: resolve(outputDir, "manager-conversation-tray-compact.png"), fullPage: false, animations: "disabled" });
+ await page.screenshot({ path: resolve(outputDir, "manager-conversation-reading-desktop.png"), fullPage: false, animations: "disabled" });
await page.setViewportSize({ width: 390, height: 844 });
- const compactTrayOverflow = await page.locator(".personal-manager-conversation-tray").evaluate((tray) => tray.scrollWidth - tray.clientWidth);
- if (compactTrayOverflow > 1) throw new Error(`Manager conversation receipt has ${compactTrayOverflow}px horizontal overflow at mobile width`);
- await page.screenshot({ path: resolve(outputDir, "manager-conversation-tray-mobile.png"), fullPage: false, animations: "disabled" });
+ const conversationOverflow = await page.locator(".personal-channel-timeline").evaluate((timeline) => timeline.scrollWidth - timeline.clientWidth);
+ if (conversationOverflow > 1) throw new Error(`Manager conversation has ${conversationOverflow}px horizontal overflow at mobile width`);
+ await page.screenshot({ path: resolve(outputDir, "manager-conversation-reading-mobile.png"), fullPage: false, animations: "disabled" });
await page.setViewportSize({ width: 1512, height: 982 });
- await page.getByText("查看完整对话", { exact: true }).click();
await page.getByRole("navigation", { name: "管家视图" }).waitFor({ state: "visible" });
if (await page.locator(".personal-home-board").isVisible()) throw new Error("Full manager Chat left the Goal overview visible behind the conversation");
if (await page.locator(".personal-manager-conversation-tray").count()) throw new Error("Full manager Chat kept the compact home tray visible");
@@ -224,7 +240,7 @@ export const chatRecoveryScenario = {
await page.getByRole("button", { name: "总览", exact: true }).click();
await page.locator(".personal-home-board").waitFor({ state: "visible" });
if (await page.locator(".personal-manager-conversation-tray").count()) {
- throw new Error("Manager conversation receipt stayed permanently visible after returning to the overview");
+ throw new Error("Manager conversation stayed permanently visible after returning to the overview");
}
await page.locator(".personal-composer-tools > summary").click();
diff --git a/examples/personal-workspace-browser/conversation-activity.mjs b/examples/personal-workspace-browser/conversation-activity.mjs
index 7750f8a250..4801864e01 100644
--- a/examples/personal-workspace-browser/conversation-activity.mjs
+++ b/examples/personal-workspace-browser/conversation-activity.mjs
@@ -41,6 +41,7 @@ export const conversationActivityScenario = {
};
assert.equal(await page.getByRole("button", { name: "恢复未确认的追加指令", exact: true }).count(), 0, "A new conversation has no recovery control");
const turn = await send("请检查当前任务状态,并说明下一步。");
+ await page.getByRole("status", {name: "正在回复", exact: true}).waitFor();
const pending = page.locator(".personal-message").filter({ has: page.getByRole("button", { name: "中断本轮", exact: true }) });
await page.clock.install();
await page.clock.fastForward(25000);
@@ -51,11 +52,10 @@ export const conversationActivityScenario = {
const elapsedBefore = await pending.locator(".personal-message-elapsed").textContent();
await page.getByRole("navigation", { name: "管家视图" }).getByRole("button", { name: "总览", exact: true }).click();
const tray = page.locator(".personal-manager-conversation-tray");
- await tray.getByRole("button", { name: "中断本轮", exact: true }).waitFor();
- assert.equal(await tray.locator(".personal-message-elapsed").textContent(), elapsedBefore, "view switch keeps request elapsed time");
- await tray.locator(".personal-message-quiet").waitFor();
await page.screenshot({ path: resolve(outputDir, "conversation-waiting-overview.png"), animations: "disabled" });
- await tray.getByRole("button", { name: "查看完整对话", exact: true }).click();
+ assert.equal(await tray.count(), 0, "Background updates never cover the overview with a conversation");
+ assert.equal(await page.getByRole("status", {name: "正在回复", exact: true}).count(), 1, "Activity stays on the Chat entry");
+ await page.getByRole("navigation", { name: "管家视图" }).getByRole("button", { name: "对话", exact: true }).click();
assert.equal(await pending.locator(".personal-message-elapsed").textContent(), elapsedBefore);
await page.clock.resume();
const adjustments = [];
@@ -105,10 +105,10 @@ export const conversationActivityScenario = {
assert.equal(await composer.inputValue(), "先核对依赖,再继续当前任务。");
await page.getByRole("navigation", { name: "管家视图" }).getByRole("button", { name: "总览", exact: true }).click();
assert.equal(await composer.inputValue(), "先核对依赖,再继续当前任务。", "The same draft survives switching to the overview");
- await tray.getByRole("button", { name: "调整本轮", exact: true }).click();
- assert.equal(await composer.evaluate(node => node === document.activeElement), true, "Overview activity focuses the same input");
- assert.equal(await tray.locator("textarea").count(), 0);
- await tray.getByRole("button", { name: "查看完整对话", exact: true }).click();
+ assert.equal(await tray.count(), 0, "An uncertain steering receipt cannot recreate a floating conversation");
+ await page.getByRole("navigation", { name: "管家视图" }).getByRole("button", { name: "对话", exact: true }).click();
+ await pending.getByRole("button", { name: "调整本轮", exact: true }).click();
+ assert.equal(await composer.evaluate(node => node === document.activeElement), true, "Chat activity focuses the same input");
await submit.click();
await feedback.filter({ hasText: "回执不匹配" }).waitFor();
await page.reload({ waitUntil: "domcontentloaded" });
@@ -198,6 +198,7 @@ export const conversationActivityScenario = {
await pending.getByRole("button", { name: "中断本轮", exact: true }).click();
await page.getByText("已中断。你可以在当前会话继续发送消息。", { exact: true }).waitFor();
assert.equal(await page.locator(".personal-md").filter({ hasText: "已找到两个待处理项" }).count(), 1, "interruption preserves partial output");
+ await page.locator(".personal-chat-activity").waitFor({state: "hidden"});
assert.deepEqual(api.interrupts.at(-1), { sessionId: turn.sessionId, turnId: turn.turnId });
await page.screenshot({ path: resolve(outputDir, "conversation-activity-interrupted.png"), animations: "disabled" });
await page.unroute("**/interrupt", rejectThenMismatch);
@@ -218,6 +219,7 @@ export const conversationActivityScenario = {
});
await page.getByRole("button", { name: "中断本轮", exact: true }).click();
await page.getByText("检查完成,下一步已列出。", { exact: true }).waitFor();
+ await page.locator(".personal-chat-activity").waitFor({state: "hidden"});
assert.equal(await page.getByRole("button", { name: "中断本轮", exact: true }).count(), 0);
assert.equal(await composer.inputValue(), "保留我的未发送草稿。");
assert.equal(await submit.isEnabled(), true, "An unsent draft becomes an explicit follow-up after completion");
@@ -231,6 +233,30 @@ export const conversationActivityScenario = {
await page.locator(".personal-goal-link").first().click();
await page.getByRole("navigation", { name: "Goal 视图" }).getByRole("button", { name: /^(Chat|对话)$/ }).click();
const goalTurn = await send("请检查这个 Goal 的当前状态。");
+ const goalTabs = page.getByRole("navigation", {name: "Goal 视图"});
+ for (const width of [1512, 390]) {
+ await page.setViewportSize({width, height: width === 390 ? 844 : 982});
+ for (const tab of ["概览", "任务", "研究", "成果"]) {
+ await goalTabs.getByRole("button", {name: tab, exact: true}).click();
+ assert.equal(await tray.count(), 0, `${tab} stays clear of background conversations`);
+ assert.equal(await page.getByRole("status", {name: "正在回复", exact: true}).count(), 1);
+ assert.equal(api.turnRequests.length, 3, "Navigation neither restarts nor cancels work");
+ await page.screenshot({path: resolve(outputDir, `conversation-background-${tab}-${width}.png`), animations: "disabled"});
+ }
+ }
+ await page.setViewportSize({width: 1512, height: 982});
+ await page.locator(".personal-goal-link").nth(1).click();
+ await page.locator(".personal-chat-activity").waitFor({state: "hidden"});
+ assert.equal(api.interrupts.length, 1, "Changing Goals does not interrupt the active conversation");
+ await page.locator(".personal-goal-link").first().click();
+ await page.getByRole("status", {name: "正在回复", exact: true}).waitFor();
+ await page.emulateMedia({reducedMotion: "reduce"});
+ assert.equal(await page.locator(".personal-chat-activity svg").evaluate(node => getComputedStyle(node).animationName), "none");
+ await page.emulateMedia({reducedMotion: "no-preference"});
+ await page.setViewportSize({width: 1512, height: 982});
+ await goalTabs.getByRole("button", {name: "对话", exact: true}).focus();
+ await page.keyboard.press("Enter");
+ assert.equal(await goalTabs.getByRole("button", {name: "对话", exact: true}).getAttribute("aria-current"), "page");
await page.getByRole("button", { name: "调整本轮", exact: true }).click();
assert.equal(await composer.inputValue(), "", "another conversation never inherits the old draft");
await page.evaluate(({sessionId, turnId}) => {
@@ -280,7 +306,17 @@ export const conversationActivityScenario = {
await page.evaluate(() => { delete window.sessionStorage.setItem; });
await page.getByRole("button", { name: "中断本轮", exact: true }).click();
await page.getByText("已中断。你可以在当前会话继续发送消息。", { exact: true }).waitFor();
+ await page.locator(".personal-chat-activity").waitFor({state: "hidden"});
assert.deepEqual(api.interrupts.at(-1), { sessionId: goalTurn.sessionId, turnId: goalTurn.turnId });
+ page.__loopxRuntime.queuedTurns.set(goalTurn.sessionId, [{
+ turn_id: "waiting-for-host", created_at: "2026-08-13T01:00:01Z", origin: "web",
+ preview: "等待执行器接收的后续消息", character_count: 13, attachment_count: 0, steering: null,
+ }]);
+ await page.reload({waitUntil: "domcontentloaded"});
+ await page.getByRole("button", {name: "对话", exact: true}).click();
+ await page.getByRole("region", {name: "待处理消息", exact: true}).waitFor();
+ assert.equal(await page.locator(".personal-chat-activity").count(), 0, "A queued message alone is not active execution");
+ assert.equal(api.turnRequests.length, 3, "Queue readback does not start work");
return { coverageEntries: context.coverageEntries, note: "Shared steward/Goal activity and steering: view/reload recovery without automatic dispatch, one composer, blocked storage, terminal draft isolation, interruption and completion races." };
} finally {
for (const response of streams.values()) response.destroy();
diff --git a/examples/personal-workspace-browser/conversation-startup.mjs b/examples/personal-workspace-browser/conversation-startup.mjs
index c9a0a196ac..e10dc80575 100644
--- a/examples/personal-workspace-browser/conversation-startup.mjs
+++ b/examples/personal-workspace-browser/conversation-startup.mjs
@@ -38,22 +38,25 @@ export const conversationStartupScenario = {
await page.getByRole("button", { name: "发送", exact: true }).click();
};
await send("帮我整理研究目标,先不要执行。");
- const tray = page.locator(".personal-manager-conversation-tray");
- await tray.locator(".personal-message-work-current").getByText("已接收,正在连接执行器", { exact: true }).waitFor();
+ assert.equal(await page.locator(".personal-manager-conversation-tray").count(), 0);
+ assert.equal(await page.locator(".personal-chat-activity").count(), 0, "Preparation is not a running Turn");
+ await page.getByRole("button", {name: "对话", exact: true}).click();
+ const conversation = page.locator(".personal-channel-timeline");
+ await conversation.locator(".personal-message-work-current").getByText("已接收,正在连接执行器", { exact: true }).waitFor();
assert.equal(api.turnRequests.length, 0, "receipt precedes executor connection and turn submission");
- await tray.getByRole("button", { name: "取消发送", exact: true }).click();
- await tray.getByText("已取消发送;请求尚未交给执行器处理。", { exact: true }).waitFor();
+ await conversation.getByRole("button", { name: "取消发送", exact: true }).click();
+ await conversation.getByText("已取消发送;请求尚未交给执行器处理。", { exact: true }).waitFor();
await page.getByLabel("向 LoopX 发送消息").fill("重试整理目标。");
assert.equal(await page.getByRole("button", { name: "发送", exact: true }).isEnabled(), true);
fail = true;
waiting.splice(0).forEach(resolve => resolve());
assert.equal(api.turnRequests.length, 0, "cancelled preparation cannot dispatch a turn");
await send("重试整理目标。");
- await tray.getByText("尚未提交请求。连接执行器失败,可以重新发送。", { exact: false }).waitFor();
- assert.equal(await tray.getByRole("button", { name: "中断本轮", exact: true }).count(), 0, "no invented turn control after startup failure");
- assert.equal(await tray.locator(".personal-message-work-current").count(), 0, "failed preparation stops presenting live progress");
+ await conversation.getByText("尚未提交请求。连接执行器失败,可以重新发送。", { exact: false }).waitFor();
+ assert.equal(await conversation.getByRole("button", { name: "中断本轮", exact: true }).count(), 0, "no invented turn control after startup failure");
+ assert.equal(await conversation.locator(".personal-message-work-current").count(), 0, "failed preparation stops presenting live progress");
assert.equal(api.turnRequests.length, 0);
- return { coverageEntries: context.coverageEntries, note: "Immediate preparation receipt, pre-dispatch cancellation, retry and honest startup failure through the overview conversation." };
+ return { coverageEntries: context.coverageEntries, note: "Immediate preparation receipt, pre-dispatch cancellation, retry and honest startup failure through the explicit Chat entry; preparation never covers the overview or claims to be running." };
} finally {
waiting.splice(0).forEach(resolve => resolve());
await context.close();
diff --git a/examples/personal-workspace-browser/goal-draft.mjs b/examples/personal-workspace-browser/goal-draft.mjs
index 6819fdaefb..69402a06d6 100644
--- a/examples/personal-workspace-browser/goal-draft.mjs
+++ b/examples/personal-workspace-browser/goal-draft.mjs
@@ -11,6 +11,7 @@ export const goalDraftScenario = {
const draft = {objective: "研究微软近三年的现金流", completion_criteria: "有公开来源的数据和可读报告", execution_boundary: "仅使用公开财报", question: "更想了解哪方面?", options: ["现金流趋势", "资本支出变化"]};
api.answerForMessage = (message) => message === "解释一下 Goal 是什么" ? "Goal 保存目标和完成标准。" : {message: "先确认研究重点,再检查目标设置。", goal_draft: {...draft, ...(message.includes("资本支出") ? {completion_criteria: "重点比较资本支出,列出来源", question: "", options: []} : {})}};
try {
+ await page.getByRole("navigation", {name: "管家视图"}).getByRole("button", {name: "对话", exact: true}).click();
const composer = page.getByLabel("向 LoopX 发送消息");
async function send(text) {
await composer.fill(text);
@@ -32,7 +33,6 @@ export const goalDraftScenario = {
assert.equal(api.durableWriteCount, writes);
await send("重点比较资本支出,列出来源");
await card.getByText("重点比较资本支出,列出来源", {exact: true}).waitFor();
- await page.getByRole("button", {name: "查看完整对话", exact: true}).click();
await page.reload({waitUntil: "networkidle"});
await page.getByRole("navigation", {name: "管家视图"}).getByRole("button", {name: /^(Chat|对话)$/}).click();
await card.getByText("重点比较资本支出,列出来源", {exact: true}).waitFor();
From ba3e39433f3085c6477f091173be3dd7180cdfab Mon Sep 17 00:00:00 2001
From: LoopXAgent <337587101+loopx-agent@users.noreply.github.com>
Date: Fri, 6 Nov 2026 08:35:44 +0800
Subject: [PATCH 2/8] test(workspace): read replies through the retained Chat
entry
Signed-off-by: LoopXAgent <337587101+loopx-agent@users.noreply.github.com>
---
.../smoke/workspace-locale-browser-smoke.mjs | 8 +++++++-
.../chat-todo-proposal.mjs | 16 ++++++++++------
2 files changed, 17 insertions(+), 7 deletions(-)
diff --git a/apps/presentation/dashboard/smoke/workspace-locale-browser-smoke.mjs b/apps/presentation/dashboard/smoke/workspace-locale-browser-smoke.mjs
index 91abd4525d..0ef2a2abc0 100644
--- a/apps/presentation/dashboard/smoke/workspace-locale-browser-smoke.mjs
+++ b/apps/presentation/dashboard/smoke/workspace-locale-browser-smoke.mjs
@@ -78,9 +78,15 @@ export async function verifyWorkspaceLocales({ browser, url, installApi, outputD
assert.equal(await statusOnly.getAttribute("aria-pressed"), "true");
await page.getByLabel("Send a message to LoopX").fill("Show the current state.");
await page.getByRole("button", { name: "Send", exact: true }).click();
- await page.locator(".personal-manager-conversation-messages article.is-assistant > strong").getByText("Status only", { exact: true }).waitFor();
+ const managerViews = page.getByRole("navigation", { name: "Manager view", exact: true });
+ const overview = managerViews.getByRole("button", { name: "Overview", exact: true });
+ assert.equal(await overview.getAttribute("aria-current"), "page", "A reply preserves the selected overview");
+ assert.equal(await page.locator(".personal-manager-conversation-tray").count(), 0, "Status replies do not cover the overview");
+ await managerViews.getByRole("button", { name: "Chat", exact: true }).click();
+ await page.locator(".personal-channel-timeline article.is-assistant header strong").getByText("Status only", { exact: true }).waitFor();
assert.equal(api.turnRequests.length, 0, "Localizing the status-only route must not start an Agent Turn");
assert.ok(!(await page.locator("body").innerText()).includes("LoopX 状态投影"), "The status-only reply source follows the selected language");
+ await overview.click();
await page.setViewportSize({ width: 390, height: 844 });
await page.locator(".personal-channel-scroll").evaluate((element) => { element.scrollTop = 0; });
const clippedStats = await page.locator(".personal-digest-stats span").evaluateAll((stats) =>
diff --git a/examples/personal-workspace-browser/chat-todo-proposal.mjs b/examples/personal-workspace-browser/chat-todo-proposal.mjs
index 401be96ce1..235ec9bfd2 100644
--- a/examples/personal-workspace-browser/chat-todo-proposal.mjs
+++ b/examples/personal-workspace-browser/chat-todo-proposal.mjs
@@ -63,11 +63,21 @@ export const chatTodoProposalScenario = {
&& preview.normalized_parameters?.text === text);
const todoPreviews = () => previewsWithText(PROPOSAL_TEXT);
const composer = page.getByLabel("向 LoopX 发送消息");
+ const openManagerChat = async () => {
+ await page.locator(".personal-manager-link").first().click();
+ const managerChatTab = page.getByRole("navigation", { name: /Manager|管家/ }).getByRole("button", { name: /^(Chat|对话)$/ });
+ await managerChatTab.click();
+ if (await managerChatTab.getAttribute("aria-current") !== "page") throw new Error("Manager Chat did not open");
+ };
try {
// Manager channel: no target Goal, so the proposal cannot become a write.
await composer.fill(MANAGER_PROMPT);
await page.getByRole("button", { name: "发送", exact: true }).click();
+ await waitFor(() => api.turnRequests.length === 1, "The unscoped manager Turn was not admitted");
+ if (await page.locator(".personal-manager-conversation-tray").count()) throw new Error("A proposal reply covered the manager overview");
+ await openManagerChat();
await page.getByText("我找到一个可评审的步骤。", { exact: true }).first().waitFor({ state: "visible", timeout: 10_000 });
+ if (api.turnRequests.length !== 1) throw new Error("Opening Chat sent the manager request again");
await page.waitForTimeout(500);
if (todoPreviews().length) throw new Error("A manager-channel proposal created a Todo preview without a target Goal");
@@ -75,12 +85,6 @@ export const chatTodoProposalScenario = {
await page.locator(".personal-goal-link", { hasText: "Product Release" }).click();
await page.getByRole("navigation", { name: "Goal 视图" }).getByRole("button", { name: /^(Chat|对话)$/ }).click();
};
- const openManagerChat = async () => {
- await page.locator(".personal-manager-link").first().click();
- const managerChatTab = page.getByRole("navigation", { name: /Manager|管家/ }).getByRole("button", { name: /^(Chat|对话)$/ });
- await managerChatTab.click();
- if (await managerChatTab.getAttribute("aria-current") !== "page") throw new Error("Manager Chat did not open");
- };
// Explicit manager targets reach the existing confirmation card in place;
// this is a preview, never an implicit apply or a new execution.
await openManagerChat();
From 4e2a1c850aa51e18e8defa8ffd80474b49b17138 Mon Sep 17 00:00:00 2001
From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com>
Date: Fri, 6 Nov 2026 09:31:16 +0800
Subject: [PATCH 3/8] fix(chat): narrow native recovery thread identities
Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com>
---
loopx/control_plane/collaboration/conversation_binding.ts | 5 +++--
1 file changed, 3 insertions(+), 2 deletions(-)
diff --git a/loopx/control_plane/collaboration/conversation_binding.ts b/loopx/control_plane/collaboration/conversation_binding.ts
index 6a675457f3..b98b666938 100644
--- a/loopx/control_plane/collaboration/conversation_binding.ts
+++ b/loopx/control_plane/collaboration/conversation_binding.ts
@@ -1141,7 +1141,8 @@ function planExhaustedStewardAnswer(recovery: JsonObject, row: JsonObject, curre
&& Number(item.manager_context_version) <= version)
.map(item => sessionIdentity(item.thread_id, "original native thread"))];
const priorNative = budget.native_observation as JsonObject | undefined;
- const nativeThreads = [...new Set([...(threads.includes(priorNative?.thread_id) ? [priorNative!.thread_id] : []), ...threads])];
+ const priorThreadId = priorNative?.thread_id;
+ const nativeThreads = [...new Set([...(typeof priorThreadId === "string" && threads.includes(priorThreadId) ? [priorThreadId] : []), ...threads])];
const candidate = {...no, recovery_candidate: true, answer_review: true, read_only_review: true,
client_turn_id: client, session_id: current.session_id, failed_turn_id: failed.turn_id,
native_thread_ids: nativeThreads};
@@ -1158,7 +1159,7 @@ function planExhaustedStewardAnswer(recovery: JsonObject, row: JsonObject, curre
|| !Number.isFinite(now) || !Number.isFinite(ended) || now < ended + 60_000) return no;
if (recovery.native_observation == null) return candidate;
const proof = requireJsonObject(recovery.native_observation, "exhausted original request inventory");
- if (!nativeThreads.includes(proof.thread_id) || proof.turn_id !== failed.upstream_turn_id
+ if (typeof proof.thread_id !== "string" || !nativeThreads.includes(proof.thread_id) || proof.turn_id !== failed.upstream_turn_id
|| proof.only_original_user_item !== true || proof.codex_error_info !== "usageLimitExceeded") return candidate;
const capacity = nativeRecoveryCapacity(recovery, row, current, candidate);
return {...capacity, recovery_eligible: capacity.recovery_eligible === true && capacity.answer_executor === "codex"};
From 9eb27b49efb8f649b7feb4bd5767ee46dce1ab60 Mon Sep 17 00:00:00 2001
From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com>
Date: Fri, 6 Nov 2026 09:31:16 +0800
Subject: [PATCH 4/8] fix(workspace): retain original observers through queued
admission
Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com>
---
.../dashboard/src/views/dashboard-page.tsx | 28 ++++++++------
.../conversation-activity.mjs | 38 +++++++++++++++++++
2 files changed, 54 insertions(+), 12 deletions(-)
diff --git a/apps/presentation/dashboard/src/views/dashboard-page.tsx b/apps/presentation/dashboard/src/views/dashboard-page.tsx
index c7235bc23b..6cb365cfd4 100644
--- a/apps/presentation/dashboard/src/views/dashboard-page.tsx
+++ b/apps/presentation/dashboard/src/views/dashboard-page.tsx
@@ -1526,7 +1526,7 @@ function PersonalGoalHome({
const pendingTurnAdmissions = useRef(new Map());
const preparationControllers = useRef(new Map());
const interruptedTurnIds = useRef(new Set());
- const recoveringTurnKeys = useRef(new Map());
+ const turnObservers = useRef(new Map());
// A running Turn a 409 reported, keyed by context: its pending reply holds
// the composer closed until the recovery effect adopts it or an
// authoritative Session read finds no such Turn, so the handoff never leaves
@@ -1833,6 +1833,8 @@ function PersonalGoalHome({
setManagerRuntime(created.session.manager_runtime);
}
recordSessionAdmission(targetContextId, created.session);
+ const observedSessionId = targetContext.kind === "bound" || targetContext.kind === "managed"
+ ? targetContext.sessionId : sessionIds.current.get(sessionKey);
sessionIds.current.set(sessionKey, created.session_id);
const activeSnapshot = history.snapshots.find(
(snapshot) => snapshot.session.session_id === created.session_id,
@@ -1848,19 +1850,16 @@ function PersonalGoalHome({
newSessionRequired.current.delete(sessionKey);
if (!activeTurnId) return;
// A source index can observe the Turn this very composer is already
- // streaming. Exact contexts already key ownership by their Session;
- // keep that live observer instead of replacing its controls and reply.
+ // streaming. Preserve its observer in every conversation kind, using
+ // the Session known before this read replaced the cached admission.
// The history may discover our native Turn before the original ACK
// supplies its id. Its in-flight admission still owns observation.
- if ((targetContext.kind === "bound" || targetContext.kind === "managed")
- && targetContext.sessionId === created.session_id
- && (pendingTurnAdmissions.current.get(targetContextId)?.signal.aborted === false
- || (activeTurnIds.current.get(targetContextId) === activeTurnId
- && streamControllers.current.get(targetContextId)?.signal.aborted === false))) return;
+ if (observedSessionId === created.session_id
+ && pendingTurnAdmissions.current.get(targetContextId)?.signal.aborted === false) return;
const recoveryKey = `${created.session_id}:${activeTurnId}`;
- if (recoveringTurnKeys.current.has(recoveryKey)) return;
+ if (turnObservers.current.get(recoveryKey)?.signal.aborted === false) return;
const observer = new AbortController();
- recoveringTurnKeys.current.set(recoveryKey, observer);
+ turnObservers.current.set(recoveryKey, observer);
activeTurnIds.current.set(targetContextId, activeTurnId);
recordRuntimeBinding(targetContextId, {
agentId: selectedAgent.agentId,
@@ -1889,8 +1888,8 @@ function PersonalGoalHome({
text: "",
});
const releaseObservation = () => {
- if (recoveringTurnKeys.current.get(recoveryKey) === observer) {
- recoveringTurnKeys.current.delete(recoveryKey);
+ if (turnObservers.current.get(recoveryKey) === observer) {
+ turnObservers.current.delete(recoveryKey);
}
// A late completion may still project its draft after navigation.
// It no longer owns the display or controls of a replacement observer.
@@ -2454,6 +2453,9 @@ function PersonalGoalHome({
}));
},
onPhase: (phase: string, turnId: string) => {
+ // Queued admission also owns an observer. A later native start may
+ // precede the Session read that confirms which Turn owns controls.
+ if (streamController) turnObservers.current.set(`${sessionId}:${turnId}`, streamController);
if (pendingTurnAdmissions.current.get(targetContextId) === streamController) pendingTurnAdmissions.current.delete(targetContextId);
if (submittedTurnId !== turnId) updateConversationMessage(targetContextId, userMessageId, {
sourceTurnId: turnId, sourceSessionId: sessionId,
@@ -2693,6 +2695,8 @@ function PersonalGoalHome({
&& payloadError.turn_replay_safe === true)) throw error;
} finally {
streamEnded = true;
+ const observationKey = `${submittedSessionId}:${submittedTurnId}`;
+ if (turnObservers.current.get(observationKey) === streamController) turnObservers.current.delete(observationKey);
if (pendingTurnAdmissions.current.get(targetContextId) === streamController) pendingTurnAdmissions.current.delete(targetContextId);
// A handed-off Turn is still running: its ownership stays for the
// recovery that adopts it or the read that finds it ended.
diff --git a/examples/personal-workspace-browser/conversation-activity.mjs b/examples/personal-workspace-browser/conversation-activity.mjs
index 4801864e01..2eba84897f 100644
--- a/examples/personal-workspace-browser/conversation-activity.mjs
+++ b/examples/personal-workspace-browser/conversation-activity.mjs
@@ -10,9 +10,12 @@ export const conversationActivityScenario = {
id: "conversation-activity",
async run({ browser, collectCoverage, url }) {
const streams = new Map();
+ const observations = [];
const event = (sequence, kind, payload) => `id: ${sequence}\nevent: ${kind}\ndata: ${JSON.stringify({ event_id: String(sequence), sequence, kind, payload })}\n\n`;
const server = createServer((request, response) => {
+ observations.push(request.url);
response.writeHead(200, { "Content-Type": "text/event-stream", "Access-Control-Allow-Origin": "*", "Cache-Control": "no-cache" });
+ response.write(event(0, "turn.started", {}));
response.write(event(1, "agent.phase", { label: "Agent 正在执行命令" }));
response.write(event(2, "agent.phase", { label: "Agent 正在检索" }));
response.write(event(3, "agent.phase", { label: "Agent 正在执行命令" }));
@@ -232,6 +235,27 @@ export const conversationActivityScenario = {
// The same interaction is present in Goal Chat, through the same timeline.
await page.locator(".personal-goal-link").first().click();
await page.getByRole("navigation", { name: "Goal 视图" }).getByRole("button", { name: /^(Chat|对话)$/ }).click();
+ // Native ordinary sends can be acknowledged as queued before starting.
+ // Their original observer must survive the asynchronous current-Turn read.
+ let pendingActiveRead;
+ await page.route("**/api/chat/sessions/*/turns", async route => {
+ const body = route.request().postDataJSON();
+ if (body.message !== "请检查这个 Goal 的当前状态。") return route.fallback();
+ const sessionId = new URL(route.request().url()).pathname.split("/").at(-2);
+ const turnId = `queued-start-${Date.now()}`;
+ const runtime = page.__loopxRuntime;
+ runtime.turnMessages.set(turnId, body.message);
+ runtime.sessions.set(sessionId, {...runtime.sessions.get(sessionId), active_turn_id: turnId, status: "busy"});
+ runtime.messages.get(sessionId).push({message_id: `${turnId}-user`, turn_id: turnId, role: "user", text: body.message});
+ api.turnRequests.push({message: body.message, sessionId, turnId});
+ let holdFirstRead = true;
+ await page.route(`**/api/chat/sessions/${sessionId}`, snapshot => {
+ if (holdFirstRead) { holdFirstRead = false; pendingActiveRead = snapshot; return; }
+ return snapshot.fallback();
+ });
+ await route.fulfill({status: 202, json: {ok: true, session_id: sessionId, turn_id: turnId,
+ created: true, status: "queued", events_url: `/events/${sessionId}/${turnId}`}});
+ });
const goalTurn = await send("请检查这个 Goal 的当前状态。");
const goalTabs = page.getByRole("navigation", {name: "Goal 视图"});
for (const width of [1512, 390]) {
@@ -255,8 +279,22 @@ export const conversationActivityScenario = {
await page.emulateMedia({reducedMotion: "no-preference"});
await page.setViewportSize({width: 1512, height: 982});
await goalTabs.getByRole("button", {name: "对话", exact: true}).focus();
+ const resumedSession = page.waitForResponse(response => new URL(response.url()).pathname === "/api/chat/sessions"
+ && response.request().method() === "POST");
await page.keyboard.press("Enter");
+ await resumedSession;
+ await page.evaluate(() => new Promise(resolveFrame => requestAnimationFrame(() => requestAnimationFrame(resolveFrame))));
assert.equal(await goalTabs.getByRole("button", {name: "对话", exact: true}).getAttribute("aria-current"), "page");
+ await page.screenshot({path: resolve(outputDir, "conversation-observer-desktop.png"), animations: "disabled"});
+ await page.setViewportSize({width: 390, height: 844});
+ await page.screenshot({path: resolve(outputDir, "conversation-observer-narrow.png"), animations: "disabled"});
+ await page.setViewportSize({width: 1512, height: 982});
+ assert.equal(observations.filter(path => path === `/events/${goalTurn.sessionId}/${goalTurn.turnId}`).length, 1,
+ "Returning to Goal Chat preserves the original live observer instead of attaching a recovery stream");
+ assert.equal(await page.getByRole("button", {name: "调整本轮", exact: true}).count(), 1,
+ "The same running Session/Turn has one set of controls after navigation");
+ assert.ok(pendingActiveRead, "The queued-start confirmation remains outstanding during navigation");
+ await pendingActiveRead.fallback();
await page.getByRole("button", { name: "调整本轮", exact: true }).click();
assert.equal(await composer.inputValue(), "", "another conversation never inherits the old draft");
await page.evaluate(({sessionId, turnId}) => {
From 7817de6dd5c70b1c75090fdd00d8f987c3b181c1 Mon Sep 17 00:00:00 2001
From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com>
Date: Fri, 6 Nov 2026 09:34:49 +0800
Subject: [PATCH 5/8] fix(workspace): adopt queue controls without a second
stream
Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com>
---
.../dashboard/src/views/dashboard-page.tsx | 17 ++++++++++++++++-
1 file changed, 16 insertions(+), 1 deletion(-)
diff --git a/apps/presentation/dashboard/src/views/dashboard-page.tsx b/apps/presentation/dashboard/src/views/dashboard-page.tsx
index 6cb365cfd4..9ae43ccc9e 100644
--- a/apps/presentation/dashboard/src/views/dashboard-page.tsx
+++ b/apps/presentation/dashboard/src/views/dashboard-page.tsx
@@ -1857,7 +1857,22 @@ function PersonalGoalHome({
if (observedSessionId === created.session_id
&& pendingTurnAdmissions.current.get(targetContextId)?.signal.aborted === false) return;
const recoveryKey = `${created.session_id}:${activeTurnId}`;
- if (turnObservers.current.get(recoveryKey)?.signal.aborted === false) return;
+ const existingObserver = turnObservers.current.get(recoveryKey);
+ if (existingObserver && !existingObserver.signal.aborted) {
+ // The Session may confirm a queued Turn started before its stream
+ // reports that start. Adopt its controls and row, not another stream.
+ activeTurnIds.current.set(targetContextId, activeTurnId);
+ streamControllers.current.set(targetContextId, existingObserver);
+ setMessagesByContext(current => {
+ const rows = current[targetContextId] ?? [];
+ if (!rows.some(row => row.role === "assistant" && row.queued
+ && row.sourceSessionId === created.session_id && row.sourceTurnId === activeTurnId)) return current;
+ return {...current, [targetContextId]: rows.map(row => row.role === "assistant" && row.queued
+ && row.sourceSessionId === created.session_id && row.sourceTurnId === activeTurnId
+ ? {...row, queued: false, pending: true, endedAt: undefined, startedAt: Date.now()} : row)};
+ });
+ return;
+ }
const observer = new AbortController();
turnObservers.current.set(recoveryKey, observer);
activeTurnIds.current.set(targetContextId, activeTurnId);
From 68d31c9a6a9a6da907122dbec4f3ced58e3fba19 Mon Sep 17 00:00:00 2001
From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com>
Date: Fri, 6 Nov 2026 12:38:46 +0800
Subject: [PATCH 6/8] fix(workspace): retain observed Agent activity across
reading views
Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com>
---
.../personal-workspace-page.tsx | 4 +++-
.../dashboard/src/views/dashboard-page.tsx | 9 ++++++++
.../managed-agent-sessions.mjs | 23 +++++++++++++++++++
3 files changed, 35 insertions(+), 1 deletion(-)
diff --git a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx
index 5ebda586e9..90df20bd55 100644
--- a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx
+++ b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx
@@ -843,6 +843,7 @@ export function PersonalWorkspacePage({
conversationSessionId,
conversationHistoryState,
conversationMessageFocus,
+ observedConversationRunning = false,
workBrowseSearch,
onWorkBrowseChange,
agents = [{ agentId: "codex", available: true, capability: "代码与项目执行", label: "Codex" }],
@@ -879,6 +880,7 @@ export function PersonalWorkspacePage({
conversationSessionId?: string;
conversationHistoryState?: ConversationHistoryStatus;
conversationMessageFocus?: {sessionId: string; messageId: string};
+ observedConversationRunning?: boolean;
workBrowseSearch?: Record;
onWorkBrowseChange?: (patch: Partial, source: string, replace?: boolean) => void;
agents?: WorkspaceAgentOption[];
@@ -2803,7 +2805,7 @@ export function PersonalWorkspacePage({
value: selectedWorkspaceRef ?? stewardScopeValue,
} : null}
workspaceGrantLabel={selectedWorkspaceRef ? t(workspaceUnavailable ? "workspace.grantRevoked" : selectedWorkspaceProject?.grant === "workspace_write" ? "workspace.grantWrite" : "workspace.grantRead") : null}
- conversationRunning={Boolean((runningMessage && !runningMessage.queued) || loopxDeliveryOpen)}
+ conversationRunning={Boolean(observedConversationRunning || (runningMessage && !runningMessage.queued) || loopxDeliveryOpen)}
managerChatOpen={managerChatOpen}
managerChannelBinding={managerChannelBinding}
managerRuntime={managerRuntime}
diff --git a/apps/presentation/dashboard/src/views/dashboard-page.tsx b/apps/presentation/dashboard/src/views/dashboard-page.tsx
index 9ae43ccc9e..7409796e97 100644
--- a/apps/presentation/dashboard/src/views/dashboard-page.tsx
+++ b/apps/presentation/dashboard/src/views/dashboard-page.tsx
@@ -3049,10 +3049,19 @@ function PersonalGoalHome({
},
timeline: workspaceTimeline,
};
+ // Navigation may leave an Agent's exact Session while its existing stream
+ // continues. Project that observed Turn within its Goal, without another read.
+ const observedConversationRunning = Object.entries(runtimeBindings).some(([key, binding]) => {
+ if (binding.status !== "running" || !binding.turnId) return false;
+ const target = conversationContextOfKey(key);
+ return selectedGoal ? (target.kind === "goal" || target.kind === "managed") && target.goalId === selectedGoal.goalId
+ : key === contextId;
+ });
return (
document.documentElement.scrollWidth > innerWidth), false);
await page.screenshot({path: resolve(outputDir, "managed-observer-narrow.png")});
await assertSingleObserver(ownTurn, 1);
+ const managedUrl = page.url();
+ await composer.fill("Retain this original Session's unsent draft across work views.");
+ await page.setViewportSize({width: 1512, height: 982});
+ for (const view of ["概览", "任务", "研究", "成果"]) {
+ await page.getByRole("navigation", {name: "Goal 视图"}).getByRole("button", {name: view, exact: true}).click();
+ await page.getByRole("status", {name: "正在回复", exact: true}).waitFor();
+ assert.equal(await page.locator(".personal-manager-conversation-tray").count(), 0,
+ "An observed managed Turn stays on Chat without covering a reading view");
+ }
+ await page.setViewportSize({width: 390, height: 844});
+ assert.equal(await page.getByRole("status", {name: "正在回复", exact: true}).count(), 1);
+ assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > innerWidth), false);
+ await page.screenshot({path: resolve(outputDir, "managed-observer-background-narrow.png")});
+ await page.setViewportSize({width: 1512, height: 982});
+ await page.locator(".personal-manager-link").click();
+ assert.equal(await page.getByRole("status", {name: "正在回复", exact: true}).count(), 0,
+ "Another conversation never inherits the Goal's managed Turn activity");
+ for (let step = 0; step < 5; step += 1) await page.goBack();
+ await page.waitForURL(managedUrl);
+ await pending.waitFor();
+ assert.equal(await composer.inputValue(), "Retain this original Session's unsent draft across work views.");
+ await assertSingleObserver(ownTurn, 1);
await interrupt(ownTurn);
+ await page.getByRole("status", {name: "正在回复", exact: true}).waitFor({state: "hidden"});
// Start through the same real ingress while another Session is selected.
// Entering the original must recover it without a composer-owned observer.
From 6fc5f054837ca201278f7480fda79d01c3c91ca6 Mon Sep 17 00:00:00 2001
From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com>
Date: Fri, 6 Nov 2026 12:38:46 +0800
Subject: [PATCH 7/8] fix(validation): preserve status callers and native
context contracts
Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com>
---
.../autonomous-replan-obligation-smoke.py | 5 +-
loopx/canary/module_metric_baseline.json | 3 -
loopx/control_plane/testing/canary_harness.py | 4 +
.../work_items/interaction_contract.py | 43 ++++----
loopx/status.py | 4 +
.../test_canary_cli_transport.py | 56 +++++++++++
.../test_cli_output_probe_runner.py | 7 +-
.../test_replan_writeback_projection.py | 98 +++++++++++++++++++
8 files changed, 196 insertions(+), 24 deletions(-)
diff --git a/examples/autonomous-replan-obligation-smoke.py b/examples/autonomous-replan-obligation-smoke.py
index 5512834286..ea24024143 100644
--- a/examples/autonomous-replan-obligation-smoke.py
+++ b/examples/autonomous-replan-obligation-smoke.py
@@ -18,6 +18,7 @@
from loopx import cli as loopx_cli # noqa: E402
from loopx.control_plane.testing.canary_harness import import_fixture_authority # noqa: E402
+from tests.control_plane.cli_output_fixture import read_cli_context_for_oracle # noqa: E402
GOAL_ID = "autonomous-replan-fixture"
@@ -55,7 +56,7 @@ def run_cli(
f"loopx fixture command failed with exit {exit_code}: {' '.join(args)}\n"
f"{stderr.getvalue().strip()}\n{stdout.getvalue().strip()}"
)
- return json.loads(stdout.getvalue())
+ return json.loads(read_cli_context_for_oracle(stdout.getvalue())[0])
command = [sys.executable, "-m", "loopx.cli", *argv]
try:
@@ -71,7 +72,7 @@ def run_cli(
raise AssertionError(
f"loopx fixture command timed out after {timeout}s: {' '.join(args)}"
) from exc
- return json.loads(result.stdout)
+ return json.loads(read_cli_context_for_oracle(result.stdout)[0])
def run_cli_error(
diff --git a/loopx/canary/module_metric_baseline.json b/loopx/canary/module_metric_baseline.json
index bded4f426d..11b6952a02 100644
--- a/loopx/canary/module_metric_baseline.json
+++ b/loopx/canary/module_metric_baseline.json
@@ -33,9 +33,6 @@
"any_count": 52,
"dict_any_count": 0
},
- "loopx/chat_agent.py": {
- "lines": 2031
- },
"loopx/chat_runtime.py": {
"any_count": 38,
"dict_any_count": 0,
diff --git a/loopx/control_plane/testing/canary_harness.py b/loopx/control_plane/testing/canary_harness.py
index c1599112f1..13dfd8a0f2 100644
--- a/loopx/control_plane/testing/canary_harness.py
+++ b/loopx/control_plane/testing/canary_harness.py
@@ -1,6 +1,7 @@
from __future__ import annotations
import json
+import os
import subprocess
import sys
from datetime import datetime
@@ -44,6 +45,9 @@ def run_json_cli(
command,
cwd=cwd or REPO_ROOT,
check=False,
+ # This helper consumes machine JSON. Keep model-facing transport in
+ # the parent; native-context fixtures declare it explicitly in argv.
+ env={**os.environ, "LOOPX_CONTEXT_DELIVERY": "off"},
text=True, encoding="utf-8", errors="replace",
capture_output=True,
)
diff --git a/loopx/control_plane/work_items/interaction_contract.py b/loopx/control_plane/work_items/interaction_contract.py
index e4a935af9f..5c7869c37a 100644
--- a/loopx/control_plane/work_items/interaction_contract.py
+++ b/loopx/control_plane/work_items/interaction_contract.py
@@ -649,6 +649,30 @@ def interaction_next_cli_actions(
)
+def _automation_prompt_upgrade_cli_actions(
+ payload: Mapping[str, object], *, command_prefix: str, goal_id: str,
+) -> list[str]:
+ """Render the existing prompt-upgrade commands without selecting work."""
+ automation_prompt_upgrade = (
+ cast(dict[str, Any], payload.get("automation_prompt_upgrade"))
+ if isinstance(payload.get("automation_prompt_upgrade"), dict)
+ else {}
+ )
+ actions = [
+ str(item.get("command") or "").strip()
+ for item in automation_prompt_upgrade.get("agent_example_commands") or []
+ if isinstance(item, dict)
+ ]
+ completion_command = str(
+ automation_prompt_upgrade.get("completion_command") or ""
+ ).strip()
+ if completion_command:
+ actions.append(completion_command)
+ return [action for action in actions if action] or [
+ f"{command_prefix} heartbeat-prompt --thin --goal-id {goal_id} --agent-id --agent-scope ''",
+ ]
+
+
def _admitted_interaction_next_cli_actions(
payload: dict[str, Any],
*,
@@ -779,24 +803,9 @@ def _admitted_interaction_next_cli_actions(
"no quota spend until a due monitor, verified direct reply, or explicit work-mode change"
]
if mode == "automation_prompt_upgrade":
- automation_prompt_upgrade = (
- cast(dict[str, Any], payload.get("automation_prompt_upgrade"))
- if isinstance(payload.get("automation_prompt_upgrade"), dict)
- else {}
+ return _automation_prompt_upgrade_cli_actions(
+ payload, command_prefix=command_prefix, goal_id=goal_id,
)
- actions = [
- str(item.get("command") or "").strip()
- for item in automation_prompt_upgrade.get("agent_example_commands") or []
- if isinstance(item, dict)
- ]
- completion_command = str(
- automation_prompt_upgrade.get("completion_command") or ""
- ).strip()
- if completion_command:
- actions.append(completion_command)
- return [action for action in actions if action] or [
- f"{command_prefix} heartbeat-prompt --thin --goal-id {goal_id} --agent-id --agent-scope ''",
- ]
if mode == "monitor_quiet_skip":
if heartbeat_turn_receipt_enabled:
return [typed_heartbeat_receipt_retry]
diff --git a/loopx/status.py b/loopx/status.py
index cc9c56f6c0..f19cd1c2d6 100644
--- a/loopx/status.py
+++ b/loopx/status.py
@@ -228,6 +228,8 @@
attach_dependency_blockers,
compact_todo_group as compact_todo_group,
compact_todo_item as compact_todo_item,
+ open_todo_items as open_todo_items,
+ todo_item_task_class as todo_item_task_class,
first_open_todo_text,
project_asset_todo_summary,
sync_connected_attention_action_from_todos as _sync_connected_attention_action_from_todos_read_model,
@@ -267,6 +269,8 @@
"project_asset_summary_is_public_safe": "loopx.control_plane.work_items.project_asset",
"compact_todo_group": "loopx.control_plane.todos.todo_summary",
"compact_todo_item": "loopx.control_plane.todos.todo_summary",
+ "open_todo_items": "loopx.control_plane.todos.todo_summary",
+ "todo_item_task_class": "loopx.control_plane.todos.todo_summary",
"todo_lane_items": "loopx.control_plane.todos.todo_summary",
"todo_item_is_deferred": "loopx.control_plane.todos.todo_summary",
"todo_item_is_due_monitor": "loopx.control_plane.todos.todo_summary",
diff --git a/tests/control_plane/test_canary_cli_transport.py b/tests/control_plane/test_canary_cli_transport.py
index a1da7b10cc..b4be288591 100644
--- a/tests/control_plane/test_canary_cli_transport.py
+++ b/tests/control_plane/test_canary_cli_transport.py
@@ -3,6 +3,9 @@
import hashlib
import json
+import os
+from pathlib import Path
+import runpy
import subprocess
import sys
from types import SimpleNamespace
@@ -12,6 +15,59 @@
from loopx.control_plane.testing import canary_harness
+@pytest.mark.parametrize("parent_setting", ["on", "off"])
+def test_machine_cli_child_is_inline_without_changing_parent_or_raw_helper(monkeypatch, tmp_path, parent_setting):
+ monkeypatch.setenv("LOOPX_CONTEXT_DELIVERY", parent_setting)
+ observed = []
+ def run(*args, **kwargs):
+ observed.append(kwargs.get("env"))
+ return SimpleNamespace(stdout='{"ok":true,"unknown":null}', stderr="", returncode=0)
+ monkeypatch.setattr(canary_harness.subprocess, "run", run)
+ assert canary_harness.run_json_cli("probe", registry_path=tmp_path / "registry.json")["ok"] is True
+ assert observed[0]["LOOPX_CONTEXT_DELIVERY"] == "off"
+ canary_harness.run_json_cli_result("probe", registry_path=tmp_path / "registry.json")
+ assert observed[1] is None # Raw-wire callers retain their own transport.
+ assert os.environ["LOOPX_CONTEXT_DELIVERY"] == parent_setting
+
+
+@pytest.mark.parametrize("parent_setting", ["on", "off"])
+def test_real_peer_guard_and_successor_remain_machine_decisions(monkeypatch, parent_setting):
+ monkeypatch.setenv("LOOPX_CONTEXT_DELIVERY", parent_setting)
+ path = Path(__file__).resolve().parents[2] / "examples/control_plane/peer-agent-continuation-state-machine-smoke.py"
+ fixture = runpy.run_path(str(path))
+ fixture["assert_same_peer_continuation"]()
+ fixture["assert_review_is_action_semantics_over_generic_handoff"]()
+ assert os.environ["LOOPX_CONTEXT_DELIVERY"] == parent_setting
+
+
+@pytest.mark.parametrize("parent_setting", ["on", "off"])
+@pytest.mark.parametrize("gate", ["user", "quota"])
+def test_inline_machine_transport_preserves_real_core_gates(monkeypatch, tmp_path, parent_setting, gate):
+ monkeypatch.setenv("LOOPX_CONTEXT_DELIVERY", parent_setting)
+ original = canary_harness.write_fixture_registry
+ def write_registry(**kwargs):
+ if gate == "quota":
+ kwargs["quota_allowed_slots"] = 0
+ registry = original(**kwargs)
+ if gate == "user":
+ state = canary_harness.project_state_path(kwargs["project"], kwargs["goal_id"])
+ state.write_text(state.read_text() + "\n## User Todo\n\n- [ ] [P0] Confirm this fixture's boundary.\n"
+ " \n")
+ return registry
+ monkeypatch.setattr(canary_harness, "write_fixture_registry", write_registry)
+ path = Path(__file__).resolve().parents[2] / "examples/control_plane/peer-agent-continuation-state-machine-smoke.py"
+ fixture = runpy.run_path(str(path))
+ project, runtime, registry = fixture["write_fixture"](tmp_path)
+ result = fixture["should_run"](project, runtime, registry, fixture["PEER_ALPHA"])
+ assert result["should_run"] is False
+ if gate == "quota":
+ assert result["quota"]["allowed_slots"] == 0
+ else:
+ assert result["requires_user_action"] is True
+ assert os.environ["LOOPX_CONTEXT_DELIVERY"] == parent_setting
+
+
@pytest.mark.parametrize("exit_code", [0, 7])
@pytest.mark.parametrize("producer_field", [False, True])
def test_result_transport_does_not_inject_or_overwrite_source_fields(tmp_path, monkeypatch, exit_code, producer_field):
diff --git a/tests/control_plane/test_cli_output_probe_runner.py b/tests/control_plane/test_cli_output_probe_runner.py
index b0ff0672d9..e3a42690c2 100644
--- a/tests/control_plane/test_cli_output_probe_runner.py
+++ b/tests/control_plane/test_cli_output_probe_runner.py
@@ -204,9 +204,12 @@ def test_public_quota_reader_preserves_long_goal_and_is_read_only(tmp_path, prov
state.write_text(state.read_text() + "\n## Constraints\n\n" + stop)
import_fixture_authority(registry, probe.GOAL_ID, provider=provider)
command = probe._surface_commands(project=project, runtime=runtime, registry_path=registry,
- state_file=state, output_format="json")["quota_should_run"]
+ state_file=state, output_format="json", context_delivery="on")["quota_should_run"]
+ command += ["--codex-app", "--turn-instance-id", "public-reader",
+ "--codex-context-root", str(tmp_path),
+ "--decision-output-root", str(tmp_path)]
code, navigation = probe._invoke_cli(command)
- assert code == 0
+ assert code == 0, navigation
assert json.loads(navigation)["schema_version"] == "context_capture_navigation_v1"
events = rollout_event_log_path(runtime, probe.GOAL_ID)
before = events.read_bytes()
diff --git a/tests/control_plane/test_replan_writeback_projection.py b/tests/control_plane/test_replan_writeback_projection.py
index bc4b217a8b..96258b7975 100644
--- a/tests/control_plane/test_replan_writeback_projection.py
+++ b/tests/control_plane/test_replan_writeback_projection.py
@@ -11,6 +11,104 @@
)
+@pytest.mark.parametrize("upgrade,expected", [
+ (None, None),
+ ([], None),
+ ({"agent_example_commands": [{"command": " first "}, None, {"command": ""}],
+ "completion_command": " finish "}, ["first", "finish"]),
+ ({"completion_command": " finish "}, ["finish"]),
+ ({"agent_example_commands": [{"command": " "}]}, None),
+])
+def test_prompt_upgrade_keeps_supplied_commands_in_order_or_actionable_fallback(upgrade, expected):
+ from loopx.control_plane.work_items.interaction_contract import interaction_next_cli_actions
+
+ actions = interaction_next_cli_actions(
+ {"goal_id": "example", "automation_prompt_upgrade": upgrade},
+ mode="automation_prompt_upgrade", capability_reentry_resolved=True,
+ runtime_root="/tmp/public-safe runtime",
+ )
+ if expected is not None:
+ assert actions == expected
+ else:
+ assert len(actions) == 1
+ assert "--runtime-root '/tmp/public-safe runtime' heartbeat-prompt" in actions[0]
+ assert "--goal-id example" in actions[0]
+ assert "--agent-id " in actions[0]
+
+
+@pytest.mark.parametrize("profile", ["generic_cli", "codex_app_heartbeat", "outer_controller"])
+@pytest.mark.parametrize("bound_todo", ["todo_deferred", "todo_other"])
+def test_deferred_reopen_requires_same_todo_readmission_before_execution(profile, bound_todo):
+ import shlex
+ from copy import deepcopy
+ from loopx.control_plane.quota.settlement import build_turn_scoped_cli_settlement_plan
+ from loopx.control_plane.scheduler.execution_context import scheduler_execution_context_for_runtime_profile
+ from loopx.control_plane.work_items.interaction_contract import interaction_next_cli_actions
+
+ payload = {"goal_id": "example", "agent_identity": {"agent_id": "agent-example"},
+ "agent_scope_frontier": {"deferred_resume_candidates": [{"todo_id": "todo_deferred"}]}}
+ original = deepcopy(payload)
+ plan = build_turn_scoped_cli_settlement_plan(
+ goal_id="example", agent_id="agent-example", todo_id=bound_todo,
+ turn_instance_id="turn-original", scoped_cli_args=" --agent-id agent-example",
+ lifecycle_actor_args=" --agent-id agent-example",
+ ).as_dict()
+ actions = interaction_next_cli_actions(
+ payload, mode="successor_replan_required", settlement_plan=plan,
+ scheduler_execution_context=scheduler_execution_context_for_runtime_profile(profile),
+ capability_reentry_resolved=True, runtime_root="/tmp/public-safe runtime",
+ registry_path="/tmp/public-safe registry.json",
+ )
+ reopen = shlex.split(actions[0])
+ assert reopen[reopen.index("--todo-id") + 1] == "todo_deferred"
+ assert reopen[reopen.index("--status") + 1] == "open"
+ assert "--clear-resume-when" in reopen
+ assert payload == original
+ if bound_todo == "todo_deferred":
+ guard = shlex.split(actions[1])
+ assert guard[guard.index("--todo-id") + 1] == bound_todo
+ assert guard[guard.index("--turn-instance-id") + 1] == "turn-original"
+ assert guard[guard.index("--runtime-root") + 1] == "/tmp/public-safe runtime"
+ assert "Only if it admits this same Todo" in actions[2]
+ assert "fresh execution claim/lease" in actions[2]
+ assert not any("spend-slot" in action or "refresh-state" in action for action in actions)
+ else:
+ assert "successor_replan_recorded" in actions[1]
+ assert "quota spend-slot" in actions[2]
+ assert all("--todo-id todo_deferred" not in action for action in actions[1:])
+
+
+@pytest.mark.parametrize("repair", ["cycle", "monitor", "route"])
+def test_successor_repair_preserves_projected_candidate_precedence(repair):
+ from loopx.control_plane.work_items.interaction_contract import interaction_next_cli_actions
+
+ frontier = {"deferred_resume_candidates": [{"todo_id": "todo_deferred"}],
+ "route_continuation_replan_candidates": [{"todo_id": "todo_route"}]}
+ if repair in {"cycle", "monitor"}:
+ frontier["monitor_blocked_resume_candidates"] = [
+ {"todo_id": "todo_gated", "blocking_monitor_todo_id": "todo_monitor"}]
+ if repair == "cycle":
+ frontier["dependency_cycle_candidates"] = [{"todo_id": "todo_cycle"}]
+ frontier["recommended_action"] = "inspect the existing dependency cycle"
+ actions = interaction_next_cli_actions(
+ {"goal_id": "example", "agent_identity": {"agent_id": "agent-example"},
+ "agent_scope_frontier": frontier}, mode="successor_replan_required",
+ capability_reentry_resolved=True,
+ )
+ if repair == "cycle":
+ assert "todo list" in actions[0] and "--todo-id todo_cycle" in actions[0]
+ assert actions[1] == frontier["recommended_action"]
+ assert not any("spend-slot" in action for action in actions)
+ elif repair == "monitor":
+ assert "todo complete" in actions[0] and "--todo-id todo_monitor" in actions[0]
+ assert "--todo-id todo_gated" in actions[1]
+ assert "--evidence ''" in actions[0]
+ else:
+ assert "todo add" in actions[0]
+ assert "route_continuation_replan_recorded" in actions[1]
+ assert not any("--todo-id todo_deferred" in action for action in actions)
+
+
@pytest.mark.parametrize("trigger", [
"required_agent_vision_missing", "vision_acceptance_gap",
"vision_checkpoint_missing", "vision_outcome_checkpoint_required",
From 61c344931d53d433df945d3445e7626686239b2e Mon Sep 17 00:00:00 2001
From: huangruiteng
Date: Fri, 6 Nov 2026 16:18:04 +0800
Subject: [PATCH 8/8] test(control-plane): keep handoff status smoke on machine
JSON
Signed-off-by: huangruiteng
---
.../control_plane/runtime-handoff-status-read-path-smoke.py | 2 ++
1 file changed, 2 insertions(+)
diff --git a/examples/control_plane/runtime-handoff-status-read-path-smoke.py b/examples/control_plane/runtime-handoff-status-read-path-smoke.py
index a7d05c8d3a..3f7889a9f0 100644
--- a/examples/control_plane/runtime-handoff-status-read-path-smoke.py
+++ b/examples/control_plane/runtime-handoff-status-read-path-smoke.py
@@ -4,6 +4,7 @@
from __future__ import annotations
import json
+import os
import subprocess
import sys
import tempfile
@@ -46,6 +47,7 @@ def run_cli(
cwd=REPO_ROOT,
check=False,
capture_output=True,
+ env={**os.environ, "LOOPX_CONTEXT_DELIVERY": "off"},
text=True,
)
if result.returncode != 0: