Files
openhuman/app/src/components/flows/WorkflowCopilotPanel.test.tsx
T

943 lines
35 KiB
TypeScript

import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { WorkflowGraph, WorkflowNode } from '../../lib/flows/types';
import type { ToolTimelineEntry, WorkflowProposal } from '../../store/chatRuntimeSlice';
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,
capped: false,
// 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,
send: vi.fn(),
clearProposal: vi.fn(),
}));
vi.mock('../../hooks/useWorkflowBuilderChat', () => ({ useWorkflowBuilderChat: () => hookState }));
function node(id: string): WorkflowNode {
return { id, kind: 'agent', name: id, config: {}, ports: [] };
}
function graph(ids: string[]): WorkflowGraph {
return { schema_version: 1, name: 'g', nodes: ids.map(node), edges: [] };
}
function proposalWith(ids: string[]): WorkflowProposal {
return {
name: 'Revised flow',
graph: graph(ids),
requireApproval: true,
summary: { trigger: 'manual', steps: [] },
};
}
const baseGraph = graph(['a', 'b']);
describe('WorkflowCopilotPanel', () => {
beforeEach(() => {
hookState.sending = false;
hookState.proposal = null;
hookState.capped = false;
hookState.displayMessages = [];
hookState.toolTimeline = [];
hookState.liveResponse = '';
hookState.error = null;
hookState.send = vi.fn().mockResolvedValue({ outcome: 'dispatched', proposed: false });
hookState.clearProposal = vi.fn();
});
it('sends a revise turn that injects the current graph', async () => {
render(
<WorkflowCopilotPanel
graph={baseGraph}
onProposal={vi.fn()}
onAccept={vi.fn()}
onReject={vi.fn()}
onClose={vi.fn()}
/>
);
// The copilot now uses the shared ChatComposer (textarea by placeholder,
// `send-message-button` for send).
fireEvent.change(screen.getByPlaceholderText('flows.copilot.placeholder'), {
target: { value: 'add a Slack notification on failure' },
});
fireEvent.click(screen.getByTestId('send-message-button'));
expect(hookState.send).toHaveBeenCalledTimes(1);
const arg = hookState.send.mock.calls[0][0];
expect(arg.displayText).toBe('add a Slack notification on failure');
// The brief is rendered server-side now; the panel sends a structured
// revise request carrying the current graph as context.
expect(arg.request.mode).toBe('revise');
expect(arg.request.instruction).toBe('add a Slack notification on failure');
expect(arg.request.graph).toEqual(baseGraph);
});
it('carries the original ask forward across a clarifying-question turn, then drops it once a proposal lands', async () => {
hookState.send = vi
.fn()
// Turn 1: the agent asks a clarifying question instead of proposing.
.mockResolvedValueOnce({ proposed: false })
// Turn 2: the user's answer resolves it and a proposal lands.
.mockResolvedValueOnce({ proposed: true })
// Turn 3 (and any further calls): a normal revise turn, already resolved.
.mockResolvedValue({ proposed: true });
render(
<WorkflowCopilotPanel
graph={baseGraph}
onProposal={vi.fn()}
onAccept={vi.fn()}
onReject={vi.fn()}
onClose={vi.fn()}
/>
);
fireEvent.change(screen.getByPlaceholderText('flows.copilot.placeholder'), {
target: { value: 'post a daily summary to slack' },
});
await act(async () => {
fireEvent.click(screen.getByTestId('send-message-button'));
// Flush the microtasks `submit` awaits before it records `pendingAskRef`.
await Promise.resolve();
await Promise.resolve();
});
expect(hookState.send).toHaveBeenCalledTimes(1);
fireEvent.change(screen.getByPlaceholderText('flows.copilot.placeholder'), {
target: { value: '#eng' },
});
await act(async () => {
fireEvent.click(screen.getByTestId('send-message-button'));
await Promise.resolve();
await Promise.resolve();
});
expect(hookState.send).toHaveBeenCalledTimes(2);
const secondArg = hookState.send.mock.calls[1][0];
// The follow-up must carry the ORIGINAL ask forward — a bare "#eng" alone
// would strand the agent with no idea what it was asked to build (the
// current graph is still blank/unchanged since no proposal has landed).
expect(secondArg.request.mode).toBe('revise');
expect(secondArg.request.instruction).toContain('post a daily summary to slack');
expect(secondArg.request.instruction).toContain('#eng');
// Turn 3, after a proposal has landed: the graph itself now carries the
// state, so the original ask must NOT be repeated.
fireEvent.change(screen.getByPlaceholderText('flows.copilot.placeholder'), {
target: { value: 'also add a filter step' },
});
fireEvent.click(screen.getByTestId('send-message-button'));
expect(hookState.send).toHaveBeenCalledTimes(3);
const thirdArg = hookState.send.mock.calls[2][0];
expect(thirdArg.request.instruction).toBe('also add a filter step');
});
it('renders the conversation transcript (user + agent turns)', () => {
hookState.displayMessages = [
{ id: 'm1', content: 'add a Slack step', sender: 'user' },
{ id: 'm2', content: 'Done — proposed a Slack notification.', sender: 'agent' },
];
render(
<WorkflowCopilotPanel
graph={baseGraph}
onProposal={vi.fn()}
onAccept={vi.fn()}
onReject={vi.fn()}
onClose={vi.fn()}
/>
);
expect(screen.getByTestId('workflow-copilot-user')).toHaveTextContent('add a Slack step');
expect(screen.getByTestId('workflow-copilot-agent')).toHaveTextContent(
'Done — proposed a Slack notification.'
);
// With a transcript present, the empty-state hint is gone.
expect(screen.queryByTestId('workflow-copilot-empty')).not.toBeInTheDocument();
});
it('B25: unwraps a raw tool-call envelope message into clean text + a tool activity chip, never raw JSON', () => {
// Repro for B25: a turn that both talks and calls a tool can land in the
// thread transcript as the provider wire-format `{ content, tool_calls }`
// envelope. The panel must render only the human text — never the raw
// JSON — plus a compact status chip for the tool activity.
hookState.displayMessages = [
{ id: 'm1', content: 'build me a Slack digest', sender: 'user' },
{
id: 'm2',
content: JSON.stringify({
content: "Here's the workflow I propose.",
tool_calls: [{ id: 'call_1', name: 'propose_workflow', arguments: '{"nodes":[]}' }],
}),
sender: 'agent',
},
];
render(
<WorkflowCopilotPanel
graph={baseGraph}
onProposal={vi.fn()}
onAccept={vi.fn()}
onReject={vi.fn()}
onClose={vi.fn()}
/>
);
const bubble = screen.getByTestId('workflow-copilot-agent');
expect(bubble).toHaveTextContent("Here's the workflow I propose.");
// The raw envelope must never reach the DOM as text.
expect(bubble).not.toHaveTextContent('tool_calls');
expect(bubble).not.toHaveTextContent('"nodes":[]');
expect(screen.getByTestId('tool-activity-chip')).toHaveTextContent(
'flows.copilot.tool.proposing'
);
});
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 = [
{ id: 'call-1', name: 'propose_workflow', round: 0, status: 'running' } as ToolTimelineEntry,
];
hookState.liveResponse = 'Drafting your workflow…';
render(
<WorkflowCopilotPanel
graph={baseGraph}
onProposal={vi.fn()}
onAccept={vi.fn()}
onReject={vi.fn()}
onClose={vi.fn()}
/>
);
// The shared ToolTimelineBlock renders (not the bespoke transcript), and the
// one-shot "thinking" placeholder is suppressed once activity is streaming.
expect(screen.getByTestId('workflow-copilot-timeline')).toBeInTheDocument();
expect(screen.queryByTestId('workflow-copilot-thinking')).not.toBeInTheDocument();
});
it('shows the live reply as a bubble before the first tool call streams', () => {
hookState.sending = true;
hookState.toolTimeline = [];
hookState.liveResponse = 'Thinking about your Slack digest…';
render(
<WorkflowCopilotPanel
graph={baseGraph}
onProposal={vi.fn()}
onAccept={vi.fn()}
onReject={vi.fn()}
onClose={vi.fn()}
/>
);
expect(screen.getByTestId('workflow-copilot-streaming')).toHaveTextContent(
'Thinking about your Slack digest…'
);
// No tool timeline yet, and the plain "thinking" line is replaced by the
// streamed text.
expect(screen.queryByTestId('workflow-copilot-timeline')).not.toBeInTheDocument();
expect(screen.queryByTestId('workflow-copilot-thinking')).not.toBeInTheDocument();
});
it('surfaces a new proposal to the host and shows the added/removed diff', () => {
const onProposal = vi.fn();
// proposed drops "b" and adds "c" vs. base [a, b].
hookState.proposal = proposalWith(['a', 'c']);
render(
<WorkflowCopilotPanel
graph={baseGraph}
onProposal={onProposal}
onAccept={vi.fn()}
onReject={vi.fn()}
onClose={vi.fn()}
/>
);
expect(onProposal).toHaveBeenCalledWith(hookState.proposal);
// Both a single added ("c") and a single removed ("b") badge appear.
expect(screen.getByTestId('workflow-copilot-added')).toBeInTheDocument();
expect(screen.getByTestId('workflow-copilot-removed')).toBeInTheDocument();
});
it('Accept calls onAccept (host applies + saves) and clears the proposal once it resolves', async () => {
const onAccept = vi.fn().mockResolvedValue(undefined);
hookState.proposal = proposalWith(['a', 'c']);
render(
<WorkflowCopilotPanel
graph={baseGraph}
onProposal={vi.fn()}
onAccept={onAccept}
onReject={vi.fn()}
onClose={vi.fn()}
/>
);
fireEvent.click(screen.getByTestId('workflow-copilot-accept'));
expect(onAccept).toHaveBeenCalledWith(hookState.proposal);
await waitFor(() => expect(hookState.clearProposal).toHaveBeenCalledTimes(1));
});
it('shows the saving label and disables Accept while the host save is in flight', async () => {
// Deferred promise so the test controls exactly when the host's save
// (`onAccept`) resolves, to observe the in-between "saving" state.
let resolveSave!: () => void;
const savePromise = new Promise<void>(resolve => {
resolveSave = resolve;
});
const onAccept = vi.fn().mockReturnValue(savePromise);
hookState.proposal = proposalWith(['a', 'c']);
render(
<WorkflowCopilotPanel
graph={baseGraph}
onProposal={vi.fn()}
onAccept={onAccept}
onReject={vi.fn()}
onClose={vi.fn()}
/>
);
fireEvent.click(screen.getByTestId('workflow-copilot-accept'));
await waitFor(() =>
expect(screen.getByTestId('workflow-copilot-accept')).toHaveTextContent(
'flows.copilot.saving'
)
);
expect(screen.getByTestId('workflow-copilot-accept')).toBeDisabled();
expect(hookState.clearProposal).not.toHaveBeenCalled();
resolveSave();
await waitFor(() => expect(hookState.clearProposal).toHaveBeenCalledTimes(1));
});
it('leaves the proposal visible for retry when the host save rejects', async () => {
const onAccept = vi.fn().mockRejectedValue(new Error('save failed'));
hookState.proposal = proposalWith(['a', 'c']);
render(
<WorkflowCopilotPanel
graph={baseGraph}
onProposal={vi.fn()}
onAccept={onAccept}
onReject={vi.fn()}
onClose={vi.fn()}
/>
);
fireEvent.click(screen.getByTestId('workflow-copilot-accept'));
await waitFor(() => expect(onAccept).toHaveBeenCalledTimes(1));
// The button re-enables once the rejected save settles, and the proposal
// was never cleared — the card stays up so the user can retry.
await waitFor(() => expect(screen.getByTestId('workflow-copilot-accept')).not.toBeDisabled());
expect(hookState.clearProposal).not.toHaveBeenCalled();
});
it('disables Reject while an Accept save is in flight, so it cannot race the persisted save', async () => {
// Regression for the CodeRabbit finding: Reject must not stay clickable
// while `onAccept`'s save is still pending, otherwise the user's cancel
// can be silently overridden by the earlier Accept's save landing after.
let resolveSave!: () => void;
const savePromise = new Promise<void>(resolve => {
resolveSave = resolve;
});
const onAccept = vi.fn().mockReturnValue(savePromise);
const onReject = vi.fn();
hookState.proposal = proposalWith(['a', 'c']);
render(
<WorkflowCopilotPanel
graph={baseGraph}
onProposal={vi.fn()}
onAccept={onAccept}
onReject={onReject}
onClose={vi.fn()}
/>
);
fireEvent.click(screen.getByTestId('workflow-copilot-accept'));
await waitFor(() => expect(screen.getByTestId('workflow-copilot-reject')).toBeDisabled());
// A click while disabled is a no-op in jsdom/RTL — Reject must not fire.
fireEvent.click(screen.getByTestId('workflow-copilot-reject'));
expect(onReject).not.toHaveBeenCalled();
expect(hookState.clearProposal).not.toHaveBeenCalled();
resolveSave();
await waitFor(() => expect(hookState.clearProposal).toHaveBeenCalledTimes(1));
expect(screen.getByTestId('workflow-copilot-reject')).not.toBeDisabled();
});
it('Reject discards the proposal without applying it', () => {
const onReject = vi.fn();
const onAccept = vi.fn();
hookState.proposal = proposalWith(['a', 'c']);
render(
<WorkflowCopilotPanel
graph={baseGraph}
onProposal={vi.fn()}
onAccept={onAccept}
onReject={onReject}
onClose={vi.fn()}
/>
);
fireEvent.click(screen.getByTestId('workflow-copilot-reject'));
expect(onReject).toHaveBeenCalledTimes(1);
expect(onAccept).not.toHaveBeenCalled();
expect(hookState.clearProposal).toHaveBeenCalledTimes(1);
});
it('B34: renders a "Continue building" card when the turn hit the iteration cap', () => {
hookState.capped = true;
render(
<WorkflowCopilotPanel
graph={baseGraph}
onProposal={vi.fn()}
onAccept={vi.fn()}
onReject={vi.fn()}
onClose={vi.fn()}
/>
);
expect(screen.getByTestId('workflow-copilot-capped')).toBeInTheDocument();
expect(screen.getByTestId('workflow-copilot-continue')).toBeInTheDocument();
});
it('B34: does NOT render the capped card for a normal (non-capped) turn', () => {
hookState.capped = false;
render(
<WorkflowCopilotPanel
graph={baseGraph}
onProposal={vi.fn()}
onAccept={vi.fn()}
onReject={vi.fn()}
onClose={vi.fn()}
/>
);
expect(screen.queryByTestId('workflow-copilot-capped')).not.toBeInTheDocument();
});
it('B34: does not render the capped card while a proposal is pending, even if capped is stale-true', () => {
// Defense-in-depth: the server already scopes `capped` to `proposal ===
// null`, but the panel re-checks `!proposal` itself too (see the JSX
// condition) in case a stale `capped=true` from a prior turn outlives a
// later turn's proposal.
hookState.capped = true;
hookState.proposal = proposalWith(['a', 'c']);
render(
<WorkflowCopilotPanel
graph={baseGraph}
onProposal={vi.fn()}
onAccept={vi.fn()}
onReject={vi.fn()}
onClose={vi.fn()}
/>
);
expect(screen.queryByTestId('workflow-copilot-capped')).not.toBeInTheDocument();
});
it('B34: clicking "Continue building" sends a follow-up turn', async () => {
hookState.capped = true;
render(
<WorkflowCopilotPanel
graph={baseGraph}
onProposal={vi.fn()}
onAccept={vi.fn()}
onReject={vi.fn()}
onClose={vi.fn()}
/>
);
fireEvent.click(screen.getByTestId('workflow-copilot-continue'));
await waitFor(() => expect(hookState.send).toHaveBeenCalledTimes(1));
const arg = hookState.send.mock.calls[0][0];
expect(arg.request.mode).toBe('revise');
expect(arg.request.graph).toEqual(baseGraph);
});
// Codex review on #4865: "Continue building" must resume ON the current
// draft — a `revise` turn over the EXISTING `flowId`, never a blank/`create`
// restart — since `flows_build` spins up a fresh `workflow_builder` agent
// per RPC with no server-side session/checkpoint to resume. Carrying the
// live `graph` + `flowId` is what makes "Continue" a correct, working
// continuation instead of an empty restart.
it('B34: "Continue building" carries the current flowId, not a blank restart', async () => {
hookState.capped = true;
render(
<WorkflowCopilotPanel
graph={baseGraph}
flowId="flow-123"
onProposal={vi.fn()}
onAccept={vi.fn()}
onReject={vi.fn()}
onClose={vi.fn()}
/>
);
fireEvent.click(screen.getByTestId('workflow-copilot-continue'));
await waitFor(() => expect(hookState.send).toHaveBeenCalledTimes(1));
const arg = hookState.send.mock.calls[0][0];
expect(arg.request.mode).toBe('revise');
expect(arg.request.flowId).toBe('flow-123');
expect(arg.request.graph).toEqual(baseGraph);
});
it('auto-sends a repair turn once when opened with a repair seed', () => {
render(
<WorkflowCopilotPanel
graph={baseGraph}
onProposal={vi.fn()}
onAccept={vi.fn()}
onReject={vi.fn()}
onClose={vi.fn()}
repairSeed={{ runId: 'run-7', error: 'boom', graph: baseGraph }}
/>
);
expect(hookState.send).toHaveBeenCalledTimes(1);
const arg = hookState.send.mock.calls[0][0];
expect(arg.request.mode).toBe('repair');
expect(arg.request.runId).toBe('run-7');
expect(arg.request.error).toBe('boom');
expect(arg.request.graph).toEqual(baseGraph);
});
it('auto-sends a build turn once when opened with a prompt-bar build seed', () => {
const { rerender } = render(
<WorkflowCopilotPanel
graph={baseGraph}
flowId="flow-1"
onProposal={vi.fn()}
onAccept={vi.fn()}
onReject={vi.fn()}
onClose={vi.fn()}
buildSeed={{ description: 'digest my Slack every morning' }}
/>
);
expect(hookState.send).toHaveBeenCalledTimes(1);
const arg = hookState.send.mock.calls[0][0];
// The user's description reads as their own first turn in the transcript;
// the structured build request carries the blank graph + flow id so the
// server's brief asks for a build → dry-run → propose arc (propose-only —
// see #4596; persistence still waits on Accept + Save).
expect(arg.displayText).toBe('digest my Slack every morning');
expect(arg.request.mode).toBe('build');
expect(arg.request.instruction).toBe('digest my Slack every morning');
expect(arg.request.graph).toEqual(baseGraph);
expect(arg.request.flowId).toBe('flow-1');
// A re-render (e.g. a graph edit) must not re-fire the seed turn.
rerender(
<WorkflowCopilotPanel
graph={graph(['a', 'b', 'c'])}
flowId="flow-1"
onProposal={vi.fn()}
onAccept={vi.fn()}
onReject={vi.fn()}
onClose={vi.fn()}
buildSeed={{ description: 'digest my Slack every morning' }}
/>
);
expect(hookState.send).toHaveBeenCalledTimes(1);
});
it('reports the build seed as consumed once the turn actually dispatched', async () => {
const onBuildSeedConsumed = vi.fn();
render(
<WorkflowCopilotPanel
graph={baseGraph}
flowId="flow-1"
onProposal={vi.fn()}
onAccept={vi.fn()}
onReject={vi.fn()}
onClose={vi.fn()}
buildSeed={{ description: 'digest my Slack every morning' }}
onBuildSeedConsumed={onBuildSeedConsumed}
/>
);
expect(hookState.send).toHaveBeenCalledTimes(1);
// Fires exactly once, after the dispatched build turn resolves, so the
// route seed can be stripped.
await waitFor(() => expect(onBuildSeedConsumed).toHaveBeenCalledTimes(1));
});
it('does not consume the seed when send no-ops (socket not connected) (#4597)', async () => {
// `send` resolves `outcome: 'skipped'` when the socket isn't connected —
// the turn never dispatched, so the seed must be preserved (not cleared) so
// the build can still fire once the socket connects.
hookState.send = vi.fn().mockResolvedValue({ outcome: 'skipped', proposed: false });
const onBuildSeedConsumed = vi.fn();
render(
<WorkflowCopilotPanel
graph={baseGraph}
flowId="flow-1"
onProposal={vi.fn()}
onAccept={vi.fn()}
onReject={vi.fn()}
onClose={vi.fn()}
buildSeed={{ description: 'digest my Slack every morning' }}
onBuildSeedConsumed={onBuildSeedConsumed}
/>
);
expect(hookState.send).toHaveBeenCalledTimes(1);
// Flush the actual send() promise so the effect's `.then` runs, then assert
// the no-op path never consumed the seed.
await hookState.send.mock.results[0]?.value;
await waitFor(() => expect(onBuildSeedConsumed).not.toHaveBeenCalled());
});
it('does not consume or resend the seed when send fails (#4597)', async () => {
// A dispatch error resolves 'failed' (not 'skipped'): the seed is NOT
// consumed, and — crucially — the guard stays set so the effect does not
// auto-resend the turn (which would duplicate the user message and hammer
// the backend). The error surfaces separately for the user to retry.
hookState.send = vi.fn().mockResolvedValue({ outcome: 'failed', proposed: false });
const onBuildSeedConsumed = vi.fn();
const { rerender } = render(
<WorkflowCopilotPanel
graph={baseGraph}
flowId="flow-1"
onProposal={vi.fn()}
onAccept={vi.fn()}
onReject={vi.fn()}
onClose={vi.fn()}
buildSeed={{ description: 'digest my Slack every morning' }}
onBuildSeedConsumed={onBuildSeedConsumed}
/>
);
expect(hookState.send).toHaveBeenCalledTimes(1);
await hookState.send.mock.results[0]?.value;
expect(onBuildSeedConsumed).not.toHaveBeenCalled();
// A re-render with a fresh `send` identity (as happens on any state change)
// must NOT resend — the guard remains set for a failed dispatch.
hookState.send = vi.fn().mockResolvedValue({ outcome: 'failed', proposed: false });
rerender(
<WorkflowCopilotPanel
graph={baseGraph}
flowId="flow-1"
onProposal={vi.fn()}
onAccept={vi.fn()}
onReject={vi.fn()}
onClose={vi.fn()}
buildSeed={{ description: 'digest my Slack every morning' }}
onBuildSeedConsumed={onBuildSeedConsumed}
/>
);
expect(hookState.send).not.toHaveBeenCalled();
expect(onBuildSeedConsumed).not.toHaveBeenCalled();
});
it('does not re-fire the build turn when remounted after the seed is cleared (#4597)', async () => {
const onBuildSeedConsumed = vi.fn();
// First mount with the seed present (as the prompt-bar route lands).
const { unmount } = render(
<WorkflowCopilotPanel
graph={baseGraph}
flowId="flow-1"
onProposal={vi.fn()}
onAccept={vi.fn()}
onReject={vi.fn()}
onClose={vi.fn()}
buildSeed={{ description: 'digest my Slack every morning' }}
onBuildSeedConsumed={onBuildSeedConsumed}
/>
);
expect(hookState.send).toHaveBeenCalledTimes(1);
await waitFor(() => expect(onBuildSeedConsumed).toHaveBeenCalledTimes(1));
// The host clears the route seed (buildSeed -> null) in response. Closing
// and reopening the copilot fully remounts the panel — the per-mount
// `buildSentRef` resets to false — but with no seed there is nothing to
// re-fire, so the build turn must NOT be sent a second time.
unmount();
render(
<WorkflowCopilotPanel
graph={baseGraph}
flowId="flow-1"
onProposal={vi.fn()}
onAccept={vi.fn()}
onReject={vi.fn()}
onClose={vi.fn()}
buildSeed={null}
onBuildSeedConsumed={onBuildSeedConsumed}
/>
);
expect(hookState.send).toHaveBeenCalledTimes(1);
expect(onBuildSeedConsumed).toHaveBeenCalledTimes(1);
});
it('carries the build seed description forward when the auto-sent build turn asks a clarifying question instead of proposing', async () => {
hookState.send = vi
.fn()
// The auto-sent build turn dispatches but asks a question rather than
// proposing.
.mockResolvedValueOnce({ outcome: 'dispatched', proposed: false })
// The user's free-text answer then resolves it.
.mockResolvedValueOnce({ outcome: 'dispatched', proposed: true });
render(
<WorkflowCopilotPanel
graph={baseGraph}
flowId="flow-1"
onProposal={vi.fn()}
onAccept={vi.fn()}
onReject={vi.fn()}
onClose={vi.fn()}
buildSeed={{ description: 'post a daily summary to slack' }}
/>
);
// Flush the microtasks the seed effect awaits before recording
// `pendingAskRef` from the resolved `{ proposed: false }`.
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(hookState.send).toHaveBeenCalledTimes(1);
fireEvent.change(screen.getByPlaceholderText('flows.copilot.placeholder'), {
target: { value: '#eng' },
});
fireEvent.click(screen.getByTestId('send-message-button'));
expect(hookState.send).toHaveBeenCalledTimes(2);
const secondArg = hookState.send.mock.calls[1][0];
// The follow-up must carry the build seed's original description forward,
// not just the bare "#eng" answer.
expect(secondArg.request.instruction).toContain('post a daily summary to slack');
expect(secondArg.request.instruction).toContain('#eng');
});
it('populates the composer input from a prefill seed WITHOUT sending it', () => {
render(
<WorkflowCopilotPanel
graph={baseGraph}
flowId="flow-1"
onProposal={vi.fn()}
onAccept={vi.fn()}
onReject={vi.fn()}
onClose={vi.fn()}
prefillSeed={{ text: 'Build a workflow that files receipts.' }}
/>
);
// The Suggested Workflows "Build this" prefill never auto-sends — only
// populates the input so the user can review/edit before pressing Send.
expect(hookState.send).not.toHaveBeenCalled();
expect(screen.getByPlaceholderText('flows.copilot.placeholder')).toHaveValue(
'Build a workflow that files receipts.'
);
});
it('reports the prefill seed as consumed once applied, so the host can clear the route seed', () => {
const onPrefillSeedConsumed = vi.fn();
render(
<WorkflowCopilotPanel
graph={baseGraph}
flowId="flow-1"
onProposal={vi.fn()}
onAccept={vi.fn()}
onReject={vi.fn()}
onClose={vi.fn()}
prefillSeed={{ text: 'Build a workflow that files receipts.' }}
onPrefillSeedConsumed={onPrefillSeedConsumed}
/>
);
expect(onPrefillSeedConsumed).toHaveBeenCalledTimes(1);
});
it('does not re-apply the prefill seed on a re-render (would clobber in-progress edits)', () => {
const onPrefillSeedConsumed = vi.fn();
const { rerender } = render(
<WorkflowCopilotPanel
graph={baseGraph}
flowId="flow-1"
onProposal={vi.fn()}
onAccept={vi.fn()}
onReject={vi.fn()}
onClose={vi.fn()}
prefillSeed={{ text: 'Build a workflow that files receipts.' }}
onPrefillSeedConsumed={onPrefillSeedConsumed}
/>
);
const input = screen.getByPlaceholderText('flows.copilot.placeholder');
expect(input).toHaveValue('Build a workflow that files receipts.');
// The user edits the pre-filled text.
fireEvent.change(input, { target: { value: 'Build a workflow that files receipts weekly.' } });
// A re-render (e.g. a graph edit) with the same seed must not re-apply it
// and clobber the user's in-progress edit.
rerender(
<WorkflowCopilotPanel
graph={graph(['a', 'b', 'c'])}
flowId="flow-1"
onProposal={vi.fn()}
onAccept={vi.fn()}
onReject={vi.fn()}
onClose={vi.fn()}
prefillSeed={{ text: 'Build a workflow that files receipts.' }}
onPrefillSeedConsumed={onPrefillSeedConsumed}
/>
);
expect(screen.getByPlaceholderText('flows.copilot.placeholder')).toHaveValue(
'Build a workflow that files receipts weekly.'
);
expect(onPrefillSeedConsumed).toHaveBeenCalledTimes(1);
expect(hookState.send).not.toHaveBeenCalled();
});
it('does not re-apply the prefill seed when remounted after the seed is cleared', () => {
const onPrefillSeedConsumed = vi.fn();
const { unmount } = render(
<WorkflowCopilotPanel
graph={baseGraph}
flowId="flow-1"
onProposal={vi.fn()}
onAccept={vi.fn()}
onReject={vi.fn()}
onClose={vi.fn()}
prefillSeed={{ text: 'Build a workflow that files receipts.' }}
onPrefillSeedConsumed={onPrefillSeedConsumed}
/>
);
expect(onPrefillSeedConsumed).toHaveBeenCalledTimes(1);
// The host clears the route seed (prefillSeed -> null) in response.
// Closing and reopening the copilot fully remounts it; with no seed left
// there is nothing to re-apply.
unmount();
render(
<WorkflowCopilotPanel
graph={baseGraph}
flowId="flow-1"
onProposal={vi.fn()}
onAccept={vi.fn()}
onReject={vi.fn()}
onClose={vi.fn()}
prefillSeed={null}
onPrefillSeedConsumed={onPrefillSeedConsumed}
/>
);
expect(onPrefillSeedConsumed).toHaveBeenCalledTimes(1);
expect(hookState.send).not.toHaveBeenCalled();
});
it("sends the FIRST Send after a prefill seed with the seed's builder mode, not revise", async () => {
render(
<WorkflowCopilotPanel
graph={baseGraph}
flowId="flow-1"
onProposal={vi.fn()}
onAccept={vi.fn()}
onReject={vi.fn()}
onClose={vi.fn()}
prefillSeed={{ text: 'Build a workflow that files receipts.', mode: 'build' }}
/>
);
fireEvent.click(screen.getByTestId('send-message-button'));
expect(hookState.send).toHaveBeenCalledTimes(1);
const arg = hookState.send.mock.calls[0][0];
// First Send after a Suggested Workflows prefill must run the seed's
// `build` mode (build → dry-run → propose against the just-created blank
// flow) — NOT the panel's usual `revise` turn.
expect(arg.request.mode).toBe('build');
expect(arg.request.instruction).toBe('Build a workflow that files receipts.');
expect(arg.request.flowId).toBe('flow-1');
});
it('falls back to revise for subsequent Sends after the prefill-seeded first one', async () => {
render(
<WorkflowCopilotPanel
graph={baseGraph}
flowId="flow-1"
onProposal={vi.fn()}
onAccept={vi.fn()}
onReject={vi.fn()}
onClose={vi.fn()}
prefillSeed={{ text: 'Build a workflow that files receipts.', mode: 'build' }}
/>
);
fireEvent.click(screen.getByTestId('send-message-button'));
expect(hookState.send.mock.calls[0][0].request.mode).toBe('build');
fireEvent.change(screen.getByPlaceholderText('flows.copilot.placeholder'), {
target: { value: 'also add a retry' },
});
fireEvent.click(screen.getByTestId('send-message-button'));
expect(hookState.send).toHaveBeenCalledTimes(2);
expect(hookState.send.mock.calls[1][0].request.mode).toBe('revise');
});
it('defaults an omitted prefill seed mode to build on the first Send', async () => {
render(
<WorkflowCopilotPanel
graph={baseGraph}
flowId="flow-1"
onProposal={vi.fn()}
onAccept={vi.fn()}
onReject={vi.fn()}
onClose={vi.fn()}
prefillSeed={{ text: 'Build a workflow that files receipts.' }}
/>
);
fireEvent.click(screen.getByTestId('send-message-button'));
expect(hookState.send.mock.calls[0][0].request.mode).toBe('build');
});
it('falls back to revise on the first Send when there is no flow id to build against', async () => {
render(
<WorkflowCopilotPanel
graph={baseGraph}
onProposal={vi.fn()}
onAccept={vi.fn()}
onReject={vi.fn()}
onClose={vi.fn()}
prefillSeed={{ text: 'Build a workflow that files receipts.', mode: 'build' }}
/>
);
fireEvent.click(screen.getByTestId('send-message-button'));
expect(hookState.send.mock.calls[0][0].request.mode).toBe('revise');
});
});