diff --git a/app/src/hooks/useWorkflowBuilderChat.test.ts b/app/src/hooks/useWorkflowBuilderChat.test.ts
index aae2bf9d3..016d258b6 100644
--- a/app/src/hooks/useWorkflowBuilderChat.test.ts
+++ b/app/src/hooks/useWorkflowBuilderChat.test.ts
@@ -267,4 +267,39 @@ describe('useWorkflowBuilderChat', () => {
});
await waitFor(() => expect(result.current.error).toBe('run failed'));
});
+
+ describe('displayMessages', () => {
+ it('excludes isInterim agent messages but keeps user + terminal agent messages (incl. a clarifying question)', () => {
+ selectorState.messagesByThreadId = {
+ 'builder-1': [
+ { id: 'm1', sender: 'user', content: 'build me a digest', extraMetadata: {} },
+ {
+ id: 'm2',
+ sender: 'agent',
+ content: 'Let me check your calendar first.',
+ extraMetadata: { isInterim: true, requestId: 'r1' },
+ },
+ {
+ id: 'm3',
+ sender: 'agent',
+ content: 'Now let me build the workflow.',
+ extraMetadata: { isInterim: true, requestId: 'r1' },
+ },
+ // The #4630-style clarifying question is appended via
+ // `addMessageLocal` (the `send` fallback branch), never through
+ // `onInterim` — so it carries no `isInterim` tag and must still
+ // render as a bubble.
+ {
+ id: 'm4',
+ sender: 'agent',
+ content: 'Which Slack channel — #eng or #sales?',
+ extraMetadata: {},
+ },
+ ] as ThreadMessage[],
+ };
+ const { result } = renderHook(() => useWorkflowBuilderChat('builder-1'));
+ expect(result.current.messages).toHaveLength(4);
+ expect(result.current.displayMessages.map(m => m.id)).toEqual(['m1', 'm4']);
+ });
+ });
});
diff --git a/app/src/hooks/useWorkflowBuilderChat.ts b/app/src/hooks/useWorkflowBuilderChat.ts
index 6b99071b5..ab31b9cb1 100644
--- a/app/src/hooks/useWorkflowBuilderChat.ts
+++ b/app/src/hooks/useWorkflowBuilderChat.ts
@@ -60,12 +60,24 @@ export interface UseWorkflowBuilderChat {
/** The latest proposal the agent returned on this thread, or `null`. */
proposal: WorkflowProposal | null;
/**
- * The dedicated thread's transcript (user + agent turns) so a caller can
- * render the full conversation, not just the latest proposal. Empty until the
- * first send. Sourced from the same `messagesByThreadId` store the main chat
- * transcript reads.
+ * The dedicated thread's FULL transcript (user + agent turns, including
+ * between-tool narration bubbles) so a caller that needs the complete
+ * history can still get it. Empty until the first send. Sourced from the
+ * same `messagesByThreadId` store the main chat transcript reads. Most
+ * callers should render `displayMessages` instead (see below).
*/
messages: ThreadMessage[];
+ /**
+ * `messages` filtered for RENDERING as chat bubbles: drops agent messages
+ * tagged `extraMetadata.isInterim` (the between-tool "Let me check…/Now let
+ * me build…" narration `ChatRuntimeProvider`'s `onInterim` persists) since
+ * that narration already renders live via `toolTimeline`/`liveResponse`
+ * below — showing it again as a bubble double-renders it. User messages and
+ * any non-interim agent message (the turn's terminal answer, including a
+ * clarifying question appended via the `assistantText` fallback in `send`)
+ * are always kept.
+ */
+ displayMessages: ThreadMessage[];
/**
* The dedicated thread's live tool timeline (streamed by `ChatRuntimeProvider`
* as the builder turn runs) — bound straight into the shared
@@ -132,6 +144,16 @@ export function useWorkflowBuilderChat(seedThreadId?: string | null): UseWorkflo
[threadId, messagesByThreadId]
);
+ // Render-layer filter: drop interim narration bubbles (already shown live
+ // via `toolTimeline`/`liveResponse`), keeping every user turn and every
+ // non-interim agent turn (the terminal answer for a round, including a
+ // clarifying question with no `isInterim` tag). `messages` itself stays the
+ // full set — any future persistence/rehydration needs it intact.
+ const displayMessages = useMemo(
+ () => messages.filter(m => m.sender === 'user' || !m.extraMetadata?.isInterim),
+ [messages]
+ );
+
const toolTimeline = useMemo(
() => (threadId ? (toolTimelineByThread[threadId] ?? EMPTY_TIMELINE) : EMPTY_TIMELINE),
[threadId, toolTimelineByThread]
@@ -261,6 +283,7 @@ export function useWorkflowBuilderChat(seedThreadId?: string | null): UseWorkflo
sending,
proposal,
messages,
+ displayMessages,
toolTimeline,
liveResponse,
error,
diff --git a/app/src/providers/ChatRuntimeProvider.tsx b/app/src/providers/ChatRuntimeProvider.tsx
index 9eb8328a8..e93b98507 100644
--- a/app/src/providers/ChatRuntimeProvider.tsx
+++ b/app/src/providers/ChatRuntimeProvider.tsx
@@ -888,11 +888,24 @@ const ChatRuntimeProvider = ({ children }: { children: React.ReactNode }) => {
if (!content) return;
// Persist this round's leading narration as its own interleaved bubble,
// stamped with the producing turn's request id (Phase 4 anchoring).
+ // `isInterim: true` marks this as between-tool narration rather than a
+ // turn's terminal answer — the main chat still renders it as a bubble
+ // (unchanged), but callers that only want the terminal turn (e.g. the
+ // Flows copilot's `displayMessages`, see `useWorkflowBuilderChat`) can
+ // filter it out.
+ rtLog('interim_narration_tagged', {
+ thread: event.thread_id,
+ request: event.request_id,
+ round: event.round,
+ });
void dispatch(
addInferenceResponse({
content,
threadId: event.thread_id,
- extraMetadata: event.request_id ? { requestId: event.request_id } : undefined,
+ extraMetadata: {
+ isInterim: true,
+ ...(event.request_id ? { requestId: event.request_id } : {}),
+ },
})
);
// The narration has now become a bubble, so drop it from the live