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: