mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
fix(flows): route Copilot reasoning to Agentic task insights (B4) (#4662)
This commit is contained in:
@@ -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']);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user