From c2bb888a85c97bd3aeacb9be22274c66f9395fd9 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 4 Jun 2026 13:56:56 +0000 Subject: [PATCH 1/2] =?UTF-8?q?=F0=9F=A4=96=20fix:=20avoid=20resurrecting?= =?UTF-8?q?=20discarded=20workflow=20runs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Only refresh workflow run cards that are already anchored in the current transcript. Durable workflow run records can outlive the chat rows that launched them after message edits truncate history, so unanchored runs must not be projected back into the chat. Validation: - bun test src/browser/utils/workflowRunMessages.test.ts - make typecheck - make lint --- _Generated with `mux` • Model: `openai:gpt-5.5` • Thinking: `xhigh` • Cost: `$8.21`_ --- src/browser/utils/workflowRunMessages.test.ts | 19 +++++++++++++++++++ src/browser/utils/workflowRunMessages.ts | 5 ++++- 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/src/browser/utils/workflowRunMessages.test.ts b/src/browser/utils/workflowRunMessages.test.ts index 95b6593a8b..271d44f968 100644 --- a/src/browser/utils/workflowRunMessages.test.ts +++ b/src/browser/utils/workflowRunMessages.test.ts @@ -118,6 +118,25 @@ describe("buildWorkflowRunCardMessage", () => { ); }); + test("does not project workflow runs that are no longer anchored in the transcript", () => { + const run = { + id: "wfr_discarded", + definition: { + name: "deep-research", + description: "Deep research", + scope: "built-in" as const, + executable: true, + }, + args: { topic: "discarded" }, + status: "completed" as const, + }; + + expect(getWorkflowRunCardProjection([], run)).toEqual({ + shouldProject: false, + existingMessage: null, + }); + }); + test("projects updated terminal workflow cards while preserving the existing card slot", () => { const run = { id: "wfr_refresh", diff --git a/src/browser/utils/workflowRunMessages.ts b/src/browser/utils/workflowRunMessages.ts index 854c21ef6e..cdffbccb28 100644 --- a/src/browser/utils/workflowRunMessages.ts +++ b/src/browser/utils/workflowRunMessages.ts @@ -105,7 +105,10 @@ export function getWorkflowRunCardProjection( return { shouldProject: false, existingMessage: null }; } - return { shouldProject: true, existingMessage: null }; + // A durable workflow run can outlive the chat row that launched it (for example after editing a + // prior message truncates history). Without a current transcript anchor, projecting it would + // resurrect discarded workflow cards at the bottom of the chat. + return { shouldProject: false, existingMessage: null }; } export function addWorkflowRunCardMessage( From acb1285f8d6592024c18febad40f1fa36e1164e3 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 4 Jun 2026 14:42:39 +0000 Subject: [PATCH 2/2] =?UTF-8?q?=F0=9F=A4=96=20fix:=20harden=20workflow=20r?= =?UTF-8?q?un=20projection=20anchors?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address deep-review feedback on discarded slash workflow runs: - delay workflow run projection until the transcript catch-up marker has hydrated current history - treat durable workflow card metadata as a repairable card anchor - treat surviving workflow trigger rows as anchors for repairing a missing card while still suppressing fully unanchored durable runs Validation: - bun test src/browser/utils/workflowRunMessages.test.ts - bun test src/browser/utils/workflowRunMessages.test.ts src/browser/components/PinnedTodoList/PinnedTodoList.test.tsx src/browser/features/RightSidebar/CodeReview/ImmersiveReviewAgentStatusBar.test.tsx src/browser/utils/commands/sources.test.ts - make typecheck - make lint - git diff --check --- _Generated with `mux` • Model: `openai:gpt-5.5` • Thinking: `xhigh` • Cost: `$60.66`_ --- src/browser/components/ChatPane/ChatPane.tsx | 4 ++ .../PinnedTodoList/PinnedTodoList.test.tsx | 1 + src/browser/features/ChatInput/index.tsx | 14 +++- src/browser/features/ChatInput/types.ts | 1 + .../ImmersiveReviewAgentStatusBar.test.tsx | 1 + src/browser/stores/WorkspaceStore.ts | 2 + src/browser/utils/commands/sources.test.ts | 1 + src/browser/utils/workflowRunMessages.test.ts | 65 +++++++++++++++++++ src/browser/utils/workflowRunMessages.ts | 49 +++++++++++++- 9 files changed, 135 insertions(+), 3 deletions(-) diff --git a/src/browser/components/ChatPane/ChatPane.tsx b/src/browser/components/ChatPane/ChatPane.tsx index 9e18dcb34f..6e16a70c3c 100644 --- a/src/browser/components/ChatPane/ChatPane.tsx +++ b/src/browser/components/ChatPane/ChatPane.tsx @@ -410,6 +410,7 @@ const ChatPaneContent: React.FC = (props) => { isStreamStarting, loading, isHydratingTranscript, + isTranscriptCaughtUp, hasOlderHistory, loadingOlderHistory, } = workspaceState; @@ -1524,6 +1525,7 @@ const ChatPaneContent: React.FC = (props) => { projectName={projectName} workspaceName={workspaceName} isStreamStarting={isStreamStarting} + isTranscriptCaughtUp={isTranscriptCaughtUp} isHydratingTranscript={isHydratingTranscript} runtimeConfig={runtimeConfig} isQueuedAgentTask={isQueuedAgentTask} @@ -1581,6 +1583,7 @@ interface ChatInputPaneProps { isQueuedAgentTask: boolean; isCompacting: boolean; isStreamStarting: boolean; + isTranscriptCaughtUp: boolean; isHydratingTranscript: boolean; shouldShowPinnedTodoList: boolean; shouldShowReviewsBanner: boolean; @@ -1729,6 +1732,7 @@ const ChatInputPane: React.FC = (props) => { ? "Queued - waiting for an available parallel task slot. This will start automatically." : undefined } + isTranscriptCaughtUp={props.isTranscriptCaughtUp} isStreamStarting={props.isStreamStarting} isCompacting={props.isCompacting} editingMessage={props.editingMessage} diff --git a/src/browser/components/PinnedTodoList/PinnedTodoList.test.tsx b/src/browser/components/PinnedTodoList/PinnedTodoList.test.tsx index e0a88d9b24..915a883320 100644 --- a/src/browser/components/PinnedTodoList/PinnedTodoList.test.tsx +++ b/src/browser/components/PinnedTodoList/PinnedTodoList.test.tsx @@ -36,6 +36,7 @@ function buildWorkspaceState(workspaceId: string, state: MockWorkspaceState): Wo isStreamStarting: false, awaitingUserQuestion: false, loading: false, + isTranscriptCaughtUp: true, isHydratingTranscript: false, hasOlderHistory: false, loadingOlderHistory: false, diff --git a/src/browser/features/ChatInput/index.tsx b/src/browser/features/ChatInput/index.tsx index 6757b0a763..e0213d2075 100644 --- a/src/browser/features/ChatInput/index.tsx +++ b/src/browser/features/ChatInput/index.tsx @@ -295,6 +295,8 @@ const ChatInputInner: React.FC = (props) => { ); const editingMessageForUi = editingMessage?.id === optimisticallyDismissedEditId ? undefined : editingMessage; + const isTranscriptCaughtUp = + variant === "workspace" ? (props.isTranscriptCaughtUp ?? false) : false; const isStreamStarting = variant === "workspace" ? (props.isStreamStarting ?? false) : false; const isCompacting = variant === "workspace" ? (props.isCompacting ?? false) : false; const [isMobileTouch, setIsMobileTouch] = useState( @@ -1559,7 +1561,7 @@ const ChatInputInner: React.FC = (props) => { const workflows = await api.workflows.listDefinitions(discoveryInput); const discoveryWorkspaceId = variant === "workspace" && workspaceId ? workspaceId : null; const runs = - discoveryWorkspaceId != null + discoveryWorkspaceId != null && isTranscriptCaughtUp ? await api.workflows.listRuns({ workspaceId: discoveryWorkspaceId }) : []; if (!isMounted || workflowsRequestIdRef.current !== requestId) { @@ -1598,7 +1600,15 @@ const ChatInputInner: React.FC = (props) => { return () => { isMounted = false; }; - }, [api, variant, workspaceId, atMentionProjectPath, dynamicWorkflowsExperimentEnabled, store]); + }, [ + api, + variant, + workspaceId, + atMentionProjectPath, + dynamicWorkflowsExperimentEnabled, + isTranscriptCaughtUp, + store, + ]); // Load agent skills for suggestions useEffect(() => { diff --git a/src/browser/features/ChatInput/types.ts b/src/browser/features/ChatInput/types.ts index 96b9a13040..ad48d0b7a9 100644 --- a/src/browser/features/ChatInput/types.ts +++ b/src/browser/features/ChatInput/types.ts @@ -37,6 +37,7 @@ export interface ChatInputWorkspaceVariant { onResetContext: () => Promise<"reset" | "noop">; onTruncateHistory: (percentage?: number) => Promise; onModelChange?: (model: string) => void; + isTranscriptCaughtUp?: boolean; isCompacting?: boolean; isStreamStarting?: boolean; editingMessage?: EditingMessageState; diff --git a/src/browser/features/RightSidebar/CodeReview/ImmersiveReviewAgentStatusBar.test.tsx b/src/browser/features/RightSidebar/CodeReview/ImmersiveReviewAgentStatusBar.test.tsx index 91e918e5b1..176bfb0618 100644 --- a/src/browser/features/RightSidebar/CodeReview/ImmersiveReviewAgentStatusBar.test.tsx +++ b/src/browser/features/RightSidebar/CodeReview/ImmersiveReviewAgentStatusBar.test.tsx @@ -46,6 +46,7 @@ function buildState(workspaceId: string, input: SeedInput): WorkspaceState { isStreamStarting: input.isStarting ?? false, awaitingUserQuestion: input.awaitingUserQuestion ?? false, loading: false, + isTranscriptCaughtUp: true, isHydratingTranscript: false, hasOlderHistory: false, loadingOlderHistory: false, diff --git a/src/browser/stores/WorkspaceStore.ts b/src/browser/stores/WorkspaceStore.ts index 9261d175a4..57a533bfcd 100644 --- a/src/browser/stores/WorkspaceStore.ts +++ b/src/browser/stores/WorkspaceStore.ts @@ -108,6 +108,7 @@ export interface WorkspaceState { isStreamStarting: boolean; awaitingUserQuestion: boolean; loading: boolean; + isTranscriptCaughtUp: boolean; isHydratingTranscript: boolean; hasOlderHistory: boolean; loadingOlderHistory: boolean; @@ -1913,6 +1914,7 @@ export class WorkspaceStore { isStreamStarting, awaitingUserQuestion: aggregator.hasAwaitingUserQuestion(), loading: !hasMessages && !hasRunningInitMessage && !transient.caughtUp, + isTranscriptCaughtUp: transient.caughtUp, isHydratingTranscript, hasOlderHistory: historyPagination.hasOlder, loadingOlderHistory: historyPagination.loading, diff --git a/src/browser/utils/commands/sources.test.ts b/src/browser/utils/commands/sources.test.ts index ea148c9a72..a5dc8f10c8 100644 --- a/src/browser/utils/commands/sources.test.ts +++ b/src/browser/utils/commands/sources.test.ts @@ -524,6 +524,7 @@ function makeWorkspaceState(goal: WorkspaceState["goal"]): WorkspaceState { isStreamStarting: false, awaitingUserQuestion: false, loading: false, + isTranscriptCaughtUp: true, isHydratingTranscript: false, hasOlderHistory: false, loadingOlderHistory: false, diff --git a/src/browser/utils/workflowRunMessages.test.ts b/src/browser/utils/workflowRunMessages.test.ts index 271d44f968..2dfe845e1f 100644 --- a/src/browser/utils/workflowRunMessages.test.ts +++ b/src/browser/utils/workflowRunMessages.test.ts @@ -137,6 +137,71 @@ describe("buildWorkflowRunCardMessage", () => { }); }); + test("uses workflow card metadata as a repairable transcript anchor", () => { + const run = { + id: "wfr_metadata_anchor", + definition: { + name: "deep-research", + description: "Deep research", + scope: "built-in" as const, + executable: true, + }, + args: { topic: "metadata" }, + status: "completed" as const, + }; + const malformedCard = buildWorkflowRunCardMessage( + { name: run.definition.name, args: run.args }, + { runId: run.id, status: "running", result: null }, + 123 + ); + malformedCard.metadata = { + historySequence: 7, + muxMetadata: { type: "workflow-run-card-display", runId: run.id }, + }; + const part = malformedCard.parts[0]; + if (part?.type !== "dynamic-tool" || part.state !== "output-available") { + throw new Error("Expected workflow card dynamic tool part"); + } + part.output = { status: "running" }; + + expect(getWorkflowRunCardProjection([malformedCard], run)).toEqual({ + shouldProject: true, + existingMessage: malformedCard, + }); + }); + + test("uses workflow trigger rows as anchors to repair missing cards", () => { + const run = { + id: "wfr_trigger_anchor", + definition: { + name: "deep-research", + description: "Deep research", + scope: "built-in" as const, + executable: true, + }, + args: { topic: "trigger" }, + status: "completed" as const, + }; + const trigger: MuxMessage = { + id: "workflow-run-command-wfr_trigger_anchor", + role: "user", + parts: [{ type: "text", text: "/deep-research trigger" }], + metadata: { + historySequence: 6, + muxMetadata: { + type: "workflow-trigger-display", + rawCommand: "/deep-research trigger", + runId: run.id, + }, + }, + }; + + expect(getWorkflowRunCardProjection([trigger], run)).toEqual({ + shouldProject: true, + existingMessage: trigger, + }); + }); + test("projects updated terminal workflow cards while preserving the existing card slot", () => { const run = { id: "wfr_refresh", diff --git a/src/browser/utils/workflowRunMessages.ts b/src/browser/utils/workflowRunMessages.ts index cdffbccb28..c97be31a94 100644 --- a/src/browser/utils/workflowRunMessages.ts +++ b/src/browser/utils/workflowRunMessages.ts @@ -3,6 +3,8 @@ import type { MuxMessage } from "@/common/types/message"; import type { WorkflowRunRecord } from "@/common/types/workflow"; import assert from "@/common/utils/assert"; import { + WORKFLOW_RUN_CARD_DISPLAY_METADATA_TYPE, + WORKFLOW_TRIGGER_DISPLAY_METADATA_TYPE, buildWorkflowRunCardMessage, filterWorkflowDisplayOnlyMessages, type WorkflowRunCardInput, @@ -49,11 +51,51 @@ function getProjectedWorkflowRunCardMessageId(runId: string): string { return `workflow-run-${runId}`; } +function hasWorkflowRunCardMetadata(message: MuxMessage, runId: string): boolean { + return ( + message.id === getProjectedWorkflowRunCardMessageId(runId) && + message.role === "assistant" && + message.metadata?.muxMetadata?.type === WORKFLOW_RUN_CARD_DISPLAY_METADATA_TYPE && + message.metadata.muxMetadata.runId === runId + ); +} + +function findWorkflowTriggerDisplayMessage( + messages: readonly MuxMessage[], + runId: string +): MuxMessage | null { + return ( + messages.find( + (message) => + message.metadata?.muxMetadata?.type === WORKFLOW_TRIGGER_DISPLAY_METADATA_TYPE && + message.metadata.muxMetadata.runId === runId + ) ?? null + ); +} + +function getWorkflowRunCardMetadata( + metadata: MuxMessage["metadata"] | undefined, + runId: string +): MuxMessage["metadata"] { + return { + ...metadata, + muxMetadata: { + type: WORKFLOW_RUN_CARD_DISPLAY_METADATA_TYPE, + runId, + }, + }; +} + export function findProjectedWorkflowRunCardMessage( messages: readonly MuxMessage[], runId: string ): MuxMessage | null { assert(runId.length > 0, "findProjectedWorkflowRunCardMessage: run id is required"); + const metadataMatch = messages.find((message) => hasWorkflowRunCardMetadata(message, runId)); + if (metadataMatch != null) { + return metadataMatch; + } + const messageId = getProjectedWorkflowRunCardMessageId(runId); return ( messages.find( @@ -105,6 +147,11 @@ export function getWorkflowRunCardProjection( return { shouldProject: false, existingMessage: null }; } + const triggerMessage = findWorkflowTriggerDisplayMessage(messages, run.id); + if (triggerMessage != null) { + return { shouldProject: true, existingMessage: triggerMessage }; + } + // A durable workflow run can outlive the chat row that launched it (for example after editing a // prior message truncates history). Without a current transcript anchor, projecting it would // resurrect discarded workflow cards at the bottom of the chat. @@ -120,7 +167,7 @@ export function addWorkflowRunCardMessage( assert(workspaceId.length > 0, "addWorkflowRunCardMessage: workspaceId is required"); const message = buildWorkflowRunCardMessage(input, result); if (options?.existingMessage?.metadata != null) { - message.metadata = options.existingMessage.metadata; + message.metadata = getWorkflowRunCardMetadata(options.existingMessage.metadata, result.runId); } addEphemeralMessage(workspaceId, message); }