fix(flows): route Copilot reasoning to Agentic task insights (B4) (#4662)

This commit is contained in:
Cyrus Gray
2026-07-08 00:50:37 +05:30
committed by GitHub
parent 7f74db4f21
commit 5d3a18eff8
5 changed files with 132 additions and 13 deletions
@@ -7,10 +7,20 @@ import WorkflowCopilotPanel from './WorkflowCopilotPanel';
vi.mock('../../lib/i18n/I18nContext', () => ({ useT: () => ({ t: (key: string) => key }) }));
interface MockMessage {
id: string;
content: string;
sender: 'user' | 'agent';
extraMetadata?: { isInterim?: boolean };
}
const hookState = vi.hoisted(() => ({
sending: false,
proposal: null as WorkflowProposal | null,
messages: [] as Array<{ id: string; content: string; sender: 'user' | 'agent' }>,
// Panel renders `displayMessages` (already interim-filtered upstream by
// `useWorkflowBuilderChat`) — kept separate from `messages` in these tests
// so a mismatch between the two proves the panel is reading the right field.
displayMessages: [] as MockMessage[],
toolTimeline: [] as ToolTimelineEntry[],
liveResponse: '',
error: null as string | null,
@@ -41,7 +51,7 @@ describe('WorkflowCopilotPanel', () => {
beforeEach(() => {
hookState.sending = false;
hookState.proposal = null;
hookState.messages = [];
hookState.displayMessages = [];
hookState.toolTimeline = [];
hookState.liveResponse = '';
hookState.error = null;
@@ -136,7 +146,7 @@ describe('WorkflowCopilotPanel', () => {
});
it('renders the conversation transcript (user + agent turns)', () => {
hookState.messages = [
hookState.displayMessages = [
{ id: 'm1', content: 'add a Slack step', sender: 'user' },
{ id: 'm2', content: 'Done — proposed a Slack notification.', sender: 'agent' },
];
@@ -157,6 +167,41 @@ describe('WorkflowCopilotPanel', () => {
expect(screen.queryByTestId('workflow-copilot-empty')).not.toBeInTheDocument();
});
it('does not render an isInterim agent message as a bubble, only the terminal one', () => {
// The panel only ever renders `displayMessages` — the same filtered set
// `useWorkflowBuilderChat` computes from the raw transcript (isInterim
// agent messages dropped since that narration already streams live via
// the tool timeline / live text). Mirror that filter here so this test
// documents (and would catch a regression in) the composition: an
// isInterim message must never reach the panel as a bubble, while the
// terminal (non-interim) answer still does.
const raw: MockMessage[] = [
{ id: 'm1', content: 'build me a Slack digest', sender: 'user' },
{
id: 'm2',
content: 'Let me check your calendar first.',
sender: 'agent',
extraMetadata: { isInterim: true },
},
{ id: 'm3', content: 'Done — proposed a Slack notification.', sender: 'agent' },
];
hookState.displayMessages = raw.filter(m => m.sender === 'user' || !m.extraMetadata?.isInterim);
render(
<WorkflowCopilotPanel
graph={baseGraph}
onProposal={vi.fn()}
onAccept={vi.fn()}
onReject={vi.fn()}
onClose={vi.fn()}
/>
);
expect(screen.queryByText('Let me check your calendar first.')).not.toBeInTheDocument();
expect(screen.getByTestId('workflow-copilot-agent')).toHaveTextContent(
'Done — proposed a Slack notification.'
);
});
it('renders the shared tool timeline + streaming reply during a builder turn', () => {
hookState.sending = true;
hookState.toolTimeline = [
@@ -106,7 +106,7 @@ export default function WorkflowCopilotPanel({
threadId,
sending,
proposal,
messages,
displayMessages,
toolTimeline,
liveResponse,
error,
@@ -217,7 +217,7 @@ export default function WorkflowCopilotPanel({
// `scrollTo` is optional-chained: jsdom (tests) doesn't implement it.
useEffect(() => {
scrollRef.current?.scrollTo?.({ top: scrollRef.current.scrollHeight });
}, [messages, sending, proposal, toolTimeline, liveResponse]);
}, [displayMessages, sending, proposal, toolTimeline, liveResponse]);
const submit = useCallback(
async (raw?: string) => {
@@ -267,7 +267,7 @@ export default function WorkflowCopilotPanel({
const hasTimeline = toolTimeline.length > 0;
const hasLiveText = liveResponse.trim().length > 0;
const isEmpty =
messages.length === 0 && !proposal && !sending && !error && !hasTimeline && !hasLiveText;
displayMessages.length === 0 && !proposal && !sending && !error && !hasTimeline && !hasLiveText;
return (
<aside
@@ -298,8 +298,11 @@ export default function WorkflowCopilotPanel({
</p>
)}
{/* Conversation transcript: user turns right-aligned, agent turns left. */}
{messages.map(message =>
{/* Conversation transcript: user turns right-aligned, agent turns left.
Renders `displayMessages` (interim narration bubbles filtered out —
that narration already streams via the tool timeline / live text
below it double-renders it as a bubble too, see B4). */}
{displayMessages.map(message =>
message.sender === 'user' ? (
<div key={message.id} className="flex justify-end" data-testid="workflow-copilot-user">
<div className="max-w-[85%] rounded-2xl bg-primary-500 px-3 py-1.5 text-sm text-content-inverted">
@@ -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']);
});
});
});
+27 -4
View File
@@ -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,
+14 -1
View File
@@ -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