diff --git a/application/single_app/config.py b/application/single_app/config.py
index 910c48a63..c5e814cb4 100644
--- a/application/single_app/config.py
+++ b/application/single_app/config.py
@@ -100,7 +100,7 @@
EXECUTOR_TYPE = 'thread'
EXECUTOR_MAX_WORKERS = 30
SESSION_TYPE = 'filesystem'
-VERSION = "0.261.212"
+VERSION = "0.261.213"
IS_DEVELOPMENT = is_development_env_enabled()
# Opt-out for deployments where App Service Easy Auth is active but the platform
diff --git a/application/v2_ui/src/components/chat/AssistThread.tsx b/application/v2_ui/src/components/chat/AssistThread.tsx
index db2c8ff61..df7694d16 100644
--- a/application/v2_ui/src/components/chat/AssistThread.tsx
+++ b/application/v2_ui/src/components/chat/AssistThread.tsx
@@ -9,7 +9,7 @@ import { useEffect, useId, useRef, useState, type ReactNode } from 'react';
import { clsx } from 'clsx';
import { FileText, FolderOpen, Loader2, PenLine, RotateCcw, Send, Tag as TagIcon, X } from 'lucide-react';
import { ComposerEditor } from './ComposerEditor';
-import type { AssistExchange, AssistThreadController } from '../../lib/assistThread';
+import { CANCELLED_MESSAGE, type AssistExchange, type AssistThreadController } from '../../lib/assistThread';
/** A `#` chip a turn was sent with, shown read-only in the thread. */
export interface AssistReferenceChip {
@@ -48,6 +48,11 @@ export interface AssistThreadProps {
logFooter?: ReactNode;
/** What the assistant said in an exchange the server does not store. */
renderReply?: (exchange: AssistExchange) => ReactNode;
+ /**
+ * Shown for a request the user cancelled, in place of the default note that the change may
+ * still be applied. Only for an editor whose server writes nothing, so a cancel is safe.
+ */
+ cancelledMessage?: string;
sendLabel: ReactNode;
busyLabel?: ReactNode;
sendAriaLabel?: string;
@@ -67,6 +72,8 @@ export interface AssistThreadProps {
* the references with its request, and the server must authorise them for the acting user.
*/
allowContext?: boolean;
+ /** With `allowContext`, offer `#` documents only: no tags and no workspaces. Off by default. */
+ contextDocumentsOnly?: boolean;
counterHint?: string;
density?: 'compact' | 'comfortable';
className?: string;
@@ -125,6 +132,7 @@ export function AssistThread({
logHeader,
logFooter,
renderReply,
+ cancelledMessage,
sendLabel,
busyLabel,
sendAriaLabel,
@@ -135,6 +143,7 @@ export function AssistThread({
describedBy,
composerNote,
allowContext = false,
+ contextDocumentsOnly = false,
counterHint,
density = 'compact',
className,
@@ -208,15 +217,19 @@ export function AssistThread({
) : exchange.status === 'done' ? (
-
- {renderReply ? renderReply(exchange) : exchange.reply}
-
+ renderReply ? (
+ {renderReply(exchange)}
+ ) : (
+ {exchange.reply}
+ )
) : (
<>
{exchange.status === 'failed' ? (
{exchange.error}
) : (
- {exchange.error}
+
+ {cancelledMessage && exchange.error === CANCELLED_MESSAGE ? cancelledMessage : exchange.error}
+
)}
+
{mention && !disabled && (
diff --git a/application/v2_ui/src/components/chat/ContextMenu.tsx b/application/v2_ui/src/components/chat/ContextMenu.tsx
index d38300a0c..1daaf87be 100644
--- a/application/v2_ui/src/components/chat/ContextMenu.tsx
+++ b/application/v2_ui/src/components/chat/ContextMenu.tsx
@@ -45,6 +45,8 @@ export interface ContextSearchScope {
* composer, whose requests can only use a document or a tag.
*/
workspacesEnabled?: boolean;
+ /** Offer documents only: no tags and no workspaces. For an editor whose requests hold documents. */
+ documentsOnly?: boolean;
}
/**
@@ -87,6 +89,7 @@ export function useContextSuggestions(
groupsEnabled: scopeRef.current.groupsEnabled,
publicEnabled: scopeRef.current.publicEnabled,
includeWorkspaces: scopeRef.current.workspacesEnabled !== false,
+ documentsOnly: scopeRef.current.documentsOnly === true,
signal: controller.signal,
})
.then((found) => {
@@ -110,7 +113,7 @@ export function useContextSuggestions(
window.clearTimeout(timer);
controller.abort();
};
- }, [query, groupIds, publicIds, scope.groupsEnabled, scope.publicEnabled, scope.workspacesEnabled]);
+ }, [query, groupIds, publicIds, scope.documentsOnly, scope.groupsEnabled, scope.publicEnabled, scope.workspacesEnabled]);
return { candidates, loading };
}
diff --git a/application/v2_ui/src/components/chat/DocumentPickerPopover.tsx b/application/v2_ui/src/components/chat/DocumentPickerPopover.tsx
index e607fc3f6..d9363b993 100644
--- a/application/v2_ui/src/components/chat/DocumentPickerPopover.tsx
+++ b/application/v2_ui/src/components/chat/DocumentPickerPopover.tsx
@@ -60,6 +60,7 @@ export function DocumentPickerPopover({
const holder = useRef
(null);
const searchRef = useRef(null);
const includeWorkspaces = scope.workspacesEnabled !== false;
+ const documentsOnly = imagesOnly || scope.documentsOnly === true;
useEffect(() => {
searchRef.current?.focus();
@@ -100,7 +101,7 @@ export function DocumentPickerPopover({
signal: controller.signal,
includeUnavailable: true,
documentFilter: imagesOnly ? isReferenceImageDocument : undefined,
- documentsOnly: imagesOnly,
+ documentsOnly,
})
.then((found) => {
if (!controller.signal.aborted) {
@@ -122,7 +123,7 @@ export function DocumentPickerPopover({
};
// The scope arrays are rebuilt on every bootstrap read, so this keys on the flags and
// the query rather than on array identity.
- }, [query, scope.groupsEnabled, scope.publicEnabled, includeWorkspaces, imagesOnly]);
+ }, [query, scope.groupsEnabled, scope.publicEnabled, includeWorkspaces, imagesOnly, documentsOnly]);
// Grouped by workspace, which is how the chip row groups them too: a reader scanning for
// "the contract in Marketing" is looking for the workspace first.
@@ -164,7 +165,7 @@ export function DocumentPickerPopover({
event.stopPropagation();
}
}}
- placeholder={imagesOnly ? 'Search images…'
+ placeholder={imagesOnly ? 'Search images…' : documentsOnly ? 'Search documents…'
: includeWorkspaces ? 'Search documents, tags and workspaces…' : 'Search documents and tags…'}
aria-label={imagesOnly ? 'Search images' : 'Search documents'}
className={clsx(
diff --git a/application/v2_ui/src/components/workflows/WorkflowAskAiTab.tsx b/application/v2_ui/src/components/workflows/WorkflowAskAiTab.tsx
new file mode 100644
index 000000000..f29e4e67d
--- /dev/null
+++ b/application/v2_ui/src/components/workflows/WorkflowAskAiTab.tsx
@@ -0,0 +1,380 @@
+// WorkflowAskAiTab.tsx
+// The workflow editor's Ask AI tab: quick actions, the thread, and a card for each answer with
+// what it changed, Jump to, the documents it read, its warnings, and Undo this change.
+//
+// Every string here can come from the model, the server or an admin's rate-limit text, so all of
+// it renders as React text nodes. Nothing reaches an HTML sink and nothing is parsed as Markdown.
+
+import { useEffect, useId, useRef, useState } from 'react';
+import { clsx } from 'clsx';
+import { AlertTriangle, Loader2, Sparkles, Target, X } from 'lucide-react';
+import { AssistThread } from '../chat/AssistThread';
+import { GlassButton } from '../ui/primitives';
+import type { AssistExchange } from '../../lib/assistThread';
+import {
+ workflowAssistTurnState, type WorkflowAssistReplayState, type WorkflowAssistTurnRecord, type WorkflowAssistUndoResult,
+} from '../../lib/workflowAssist';
+import type { WorkflowAssist } from './useWorkflowAssist';
+
+/** The five quick actions. Each sends its text as a visible message of its own. */
+export const WORKFLOW_ASSIST_QUICK_ACTIONS: readonly { readonly label: string; readonly text: string }[] = [
+ { label: 'Explain this workflow', text: "Explain what this workflow does, step by step. Don't change anything." },
+ { label: 'Tighten task instructions', text: "Tighten each task's instructions so they are clear and specific, without changing what they ask for." },
+ { label: 'Add a schedule', text: "Add a schedule that fits this workflow. Ask me if the timing isn't clear." },
+ { label: 'Alert me only when urgent', text: 'Change the alerts so I am alerted only when something is urgent.' },
+ { label: "Check what's needed to run", text: "Check what this workflow still needs before it can run, such as missing fields or connections. Don't change anything." },
+];
+
+// revertTurn's reason for a key the user changed again after the turn.
+const CHANGED_LATER_REASON = 'Changed after this turn.';
+
+const actionClass = 'inline-flex items-center gap-1 rounded-lg px-2 py-1 text-[11px] font-medium text-accent '
+ + 'transition-colors hover:bg-surface-1 disabled:cursor-not-allowed disabled:opacity-50';
+
+/** Seconds since a request started. Hidden from assistive technology so it is not read out every second. */
+export function WorkflowAssistElapsed({ startedAt, testId }: { startedAt: number; testId?: string }) {
+ const [now, setNow] = useState(() => Date.now());
+ useEffect(() => {
+ const timer = window.setInterval(() => setNow(Date.now()), 1000);
+ return () => window.clearInterval(timer);
+ }, []);
+ const seconds = Math.max(0, Math.floor((now - startedAt) / 1000));
+ return {seconds} s ;
+}
+
+/** The footer button that opens the side panel on Ask AI. */
+export function WorkflowAskAiToggle({ id, open, controls, onToggle }: {
+ id: string;
+ open: boolean;
+ controls: string;
+ onToggle: () => void;
+}) {
+ return (
+
+ {/* Below sm an icon stands in for the label so Cancel and Save stay on one line. */}
+
+ Ask AI
+
+ );
+}
+
+function plural(count: number, one: string, many: string): string {
+ return `${count} ${count === 1 ? one : many}`;
+}
+
+function stateText(state: WorkflowAssistReplayState, record: WorkflowAssistTurnRecord): string {
+ const count = record.changes.length;
+ switch (state) {
+ case 'applied':
+ return `Changed ${plural(count, 'thing', 'things')} in the draft. Review before saving.`;
+ case 'partly_undone':
+ return 'Some of these changes were undone.';
+ case 'undone':
+ return 'These changes were undone.';
+ case 'pending':
+ return 'Confirm to apply these changes.';
+ case 'declined':
+ return record.apply === 'rejected' && record.applyMessage
+ ? `Nothing was applied: ${record.applyMessage}` : 'You chose not to apply these changes.';
+ case 'earlier':
+ return 'Made in an earlier editing session.';
+ default:
+ return '';
+ }
+}
+
+function undoSummary(undo: Extract): string {
+ const reverted = `Undone: ${undo.reverted} reverted`;
+ if (!undo.skipped) return `${reverted}.`;
+ const later = undo.skippedKeys.every((item) => item.reason === CHANGED_LATER_REASON);
+ return later
+ ? `${reverted}, ${undo.skipped} skipped because ${undo.skipped === 1 ? 'it' : 'they'} changed later.`
+ : `${reverted}, ${undo.skipped} skipped.`;
+}
+
+/** What the latest Undo did, worded for the card. Null when there is nothing to say. */
+function UndoResult({ undo, state, confirming }: {
+ undo: WorkflowAssistUndoResult;
+ state: WorkflowAssistReplayState;
+ confirming: boolean;
+}) {
+ if ('message' in undo) return {undo.message}
;
+ if (!('reverted' in undo)) {
+ return {undo.status === 'unavailable' ? 'This turn can no longer be undone.' : 'Nothing left to undo for this turn.'}
;
+ }
+ if (confirming) return Confirm to undo these changes.
;
+ if (state !== 'undone' && state !== 'partly_undone') return null;
+ return (
+ <>
+ {undoSummary(undo)}
+ {undo.revertedKeys.length ? (
+
+ {undo.revertedKeys.map((item) => {item.label} )}
+
+ ) : null}
+ {undo.skippedKeys.length ? (
+
+ {undo.skippedKeys.map((item) => (
+ {item.reason ? `${item.label}: ${item.reason}` : item.label}
+ ))}
+
+ ) : null}
+ >
+ );
+}
+
+/** One answer's card: the reply, then what it did to the draft. */
+function WorkflowAssistTurnCard({ turnId, record, assist, reloading }: {
+ turnId: string;
+ record: WorkflowAssistTurnRecord;
+ assist: WorkflowAssist;
+ /** The saved workflow is reloading, so the draft this card would undo in is locked. */
+ reloading: boolean;
+}) {
+ const state = workflowAssistTurnState(turnId, record, assist.historyView, assist.epoch);
+ const undoRef = useRef(null);
+ const seen = useRef({ sequence: record.undoSequence, state });
+ const pendingAction = assist.historyView.pending?.action;
+ const confirmingUndo = pendingAction?.origin === 'restore' && pendingAction.turnId === turnId;
+ const locked = Boolean(assist.pending) || !assist.available || reloading;
+ const jumpable = state === 'applied' || state === 'partly_undone';
+ const undoable = state === 'applied' || state === 'earlier';
+ const shortInstruction = record.instruction.length > 60 ? `${record.instruction.slice(0, 59).trimEnd()}…` : record.instruction;
+
+ // Undo's result replaces the button, so focus moves to it; so does a confirmed undo, whose
+ // confirmation dialog hands focus back to a button that is gone.
+ useEffect(() => {
+ const before = seen.current;
+ seen.current = { sequence: record.undoSequence, state };
+ const pressed = record.undoSequence !== before.sequence && record.undo?.status !== 'confirmation_required';
+ const confirmed = record.undo?.status === 'confirmation_required' && before.state !== state
+ && (state === 'undone' || state === 'partly_undone');
+ if (!pressed && !confirmed) return undefined;
+ const frame = requestAnimationFrame(() => {
+ const active = document.activeElement;
+ if (confirmed && active && active !== document.body) return;
+ undoRef.current?.focus();
+ });
+ return () => cancelAnimationFrame(frame);
+ }, [record.undoSequence, record.undo, state]);
+
+ const text = record.outcome === 'changed' ? stateText(state, record) : '';
+ return (
+
+ {record.outcome === 'question' ? (
+
Question
+ ) : null}
+ {record.reply ?
{record.reply}
: null}
+ {record.outcome === 'changed' && record.changes.length ? (
+
+
{text}
+
+ {record.changes.map((item) => (
+
+ {item.summary}
+ {jumpable ? (
+ assist.jump({
+ key: item.key, focusKey: item.change.target.focusKey, nodeId: item.change.target.nodeId,
+ })}>
+ Jump to
+
+ ) : null}
+
+ ))}
+
+
+ ) : null}
+ {record.contextDocuments.length ? (
+
+ {`Read as context: ${record.contextDocuments.join(', ')}`}
+
+ ) : null}
+ {record.warnings.length ? (
+
+ {record.warnings.map((warning, index) => (
+
+
+ {warning.message}
+ {warning.focusKey && state !== 'earlier' ? (
+ assist.jump({ key: warning.focusKey ?? '', focusKey: warning.focusKey ?? '', nodeId: warning.nodeId })}>
+ Jump to
+
+ ) : null}
+
+ ))}
+
+ ) : null}
+ {undoable ? (
+
assist.undo(turnId)}>
+ Undo this change
+
+ ) : null}
+ {record.undo ? (
+
+
+
+ ) : null}
+
+ );
+}
+
+export function WorkflowAskAiTab({ assist, inputId, onReload }: {
+ assist: WorkflowAssist;
+ inputId: string;
+ /** Reload the saved workflow, discarding the draft. Offered after the workflow changed elsewhere. */
+ onReload?: () => Promise;
+}) {
+ const baseId = useId();
+ const focusId = `${baseId}-focus`;
+ const reloadButtonId = `${baseId}-reload`;
+ const keepEditingId = `${baseId}-keep`;
+ const [reloadStep, setReloadStep] = useState<'idle' | 'confirm' | 'loading'>('idle');
+ const [reloadFailed, setReloadFailed] = useState(false);
+ const { thread, focus } = assist;
+ const quickDisabled = assist.busy || Boolean(assist.pending);
+
+ const focusById = (id: string) => requestAnimationFrame(() => document.getElementById(id)?.focus());
+ const reload = async () => {
+ if (!onReload) return;
+ setReloadStep('loading');
+ setReloadFailed(false);
+ try {
+ await onReload();
+ } catch {
+ setReloadFailed(true);
+ setReloadStep('idle');
+ focusById(reloadButtonId);
+ }
+ };
+
+ const renderReply = (exchange: AssistExchange) => {
+ const record = assist.turns[exchange.id];
+ if (!record || record.threadKey !== thread.key) {
+ return {exchange.reply}
;
+ }
+ return ;
+ };
+
+ return (
+
+
+ Ask AI edits this draft only. Nothing is saved until you save the workflow, and you can undo each change.
+
+ {assist.reloadOffered && onReload ? (
+
+
+ {reloadStep === 'confirm'
+ ? 'Reloading discards your unsaved changes to this workflow.'
+ : 'The saved workflow changed after you opened it. Reload it to keep using Ask AI.'}
+
+
+ {reloadStep === 'confirm' ? <>
+ void reload()}>
+ Discard and reload
+
+ {
+ setReloadStep('idle');
+ focusById(reloadButtonId);
+ }}>
+ Keep editing
+
+ > : (
+ {
+ setReloadStep('confirm');
+ focusById(keepEditingId);
+ }}>
+ {reloadStep === 'loading' ? 'Reloading…' : 'Reload workflow'}
+
+ )}
+
+ {reloadFailed ?
Couldn't reload the workflow. Try again.
: null}
+
+ ) : null}
+
+ {WORKFLOW_ASSIST_QUICK_ACTIONS.map((action) => (
+ assist.quickAction(action.text)}
+ className="rounded-full border border-edge px-2.5 py-1 text-[11px] font-medium text-text-2 transition-colors hover:bg-surface-2 hover:text-text-1 disabled:cursor-not-allowed disabled:opacity-50">
+ {action.label}
+
+ ))}
+
+
+ Ask for a change, such as “run this at 7 AM on weekdays and only alert me when something is urgent”,
+ or ask a question about this workflow. Type # to use one of your documents.
+
+ )}
+ composerClassName="mt-2"
+ composerNote={<>
+ {focus ? (
+
+
+
+ {`About: ${focus.label}`}
+
+ {
+ assist.setFocusTask(null);
+ thread.focusInput();
+ }}>
+
+
+
+ ) : null}
+ {assist.waitSeconds > 0 ? (
+
+ {`You can send again in ${assist.waitSeconds} s.`}
+
+ ) : null}
+ >}
+ />
+
+ );
+}
+
+/** The editor pane's notice while a turn runs: the editor is locked until the answer is in. */
+export function WorkflowAssistLockBanner({ pending, onCancel }: {
+ pending: AssistExchange;
+ onCancel: () => void;
+}) {
+ return (
+
+
+ Ask AI is working. The editor is locked until it answers.
+
+
+ Cancel request
+
+
+ );
+}
diff --git a/application/v2_ui/src/components/workflows/WorkflowChangeTracking.tsx b/application/v2_ui/src/components/workflows/WorkflowChangeTracking.tsx
index a1cad3273..464444655 100644
--- a/application/v2_ui/src/components/workflows/WorkflowChangeTracking.tsx
+++ b/application/v2_ui/src/components/workflows/WorkflowChangeTracking.tsx
@@ -241,9 +241,10 @@ export function WorkflowChangedField({ changeKey, className, children }: {
const result = tracking.session.revertChange([fieldChange.key]);
if (result.status === 'applied') refocusAfterRevert(rootRef);
};
+ // The field key is always there, so a jump made before the deferred highlight renders still finds the field.
return (
{children}
{stamp ? (
@@ -548,18 +549,28 @@ export interface WorkflowSidePanelTab {
readonly id: string;
readonly label: string;
readonly content: ReactNode;
+ /** Replaces the panel's default scrolling and padding, for a tab that lays out its own. */
+ readonly panelClassName?: string;
}
/**
* The editor's side panel. It is a tab list so the Changes tab can gain siblings, such as Ask AI;
- * tabs follow the APG pattern with automatic activation.
+ * tabs follow the APG pattern with automatic activation. Pass `selected` and `onSelect` to choose
+ * the tab from outside, such as a button that opens the panel on Ask AI.
*/
-export function WorkflowEditorSidePanel({ id, tabs, className }: {
+export function WorkflowEditorSidePanel({ id, tabs, className, selected: selectedTab, onSelect }: {
id: string;
tabs: readonly WorkflowSidePanelTab[];
className?: string;
+ selected?: string;
+ onSelect?: (id: string) => void;
}) {
- const [selected, setSelected] = useState(tabs[0]?.id ?? '');
+ const [ownSelected, setOwnSelected] = useState(tabs[0]?.id ?? '');
+ const selected = selectedTab ?? ownSelected;
+ const setSelected = (tabId: string) => {
+ if (selectedTab === undefined) setOwnSelected(tabId);
+ onSelect?.(tabId);
+ };
const baseId = useId();
const tabRefs = useRef(new Map());
const active = tabs.find((tab) => tab.id === selected) ?? tabs[0];
@@ -601,7 +612,7 @@ export function WorkflowEditorSidePanel({ id, tabs, className }: {
{active ? (
+ className={clsx('min-h-0 flex-1', active.panelClassName ?? 'overflow-y-auto p-3')}>
{active.content}
) : null}
diff --git a/application/v2_ui/src/components/workflows/WorkflowEditorDialog.tsx b/application/v2_ui/src/components/workflows/WorkflowEditorDialog.tsx
index c139e37b8..c2556d4c8 100644
--- a/application/v2_ui/src/components/workflows/WorkflowEditorDialog.tsx
+++ b/application/v2_ui/src/components/workflows/WorkflowEditorDialog.tsx
@@ -20,12 +20,15 @@ import { WorkflowScheduleFields } from './WorkflowScheduleFields';
import { WorkflowAlertEditor } from './WorkflowAlertEditor';
import { WorkflowAlertSummary } from './WorkflowAlertSummary';
import { useWorkflowAuthoring } from './useWorkflowAuthoring';
+import { useWorkflowAssist } from './useWorkflowAssist';
+import { WorkflowAskAiTab, WorkflowAskAiToggle, WorkflowAssistLockBanner } from './WorkflowAskAiTab';
import {
WorkflowChangedField, WorkflowChangeNotice, WorkflowChangesTab, WorkflowChangesToggle, WorkflowChangeTrackingScope,
WorkflowEditorSidePanel, WorkflowReferenceChanges, WorkflowRemovedItemRows, focusWorkflowChangeTarget,
workflowSessionSaveNeedsConfirmation,
} from './WorkflowChangeTracking';
import { ApiError } from '../../lib/apiClient';
+import { useBootstrapStore } from '../../stores/bootstrapStore';
import {
WORKFLOW_TASK_ORDER_KEY, parseWorkflowChangeKey, workflowChangeItemKey, workflowTasksInOrder, type WorkflowChange,
} from '../../lib/workflowChangeTracking';
@@ -97,6 +100,9 @@ function changeSection(root: HTMLElement, key: string): HTMLElement | null {
return root.querySelector(`section[aria-label="${label}"]`);
}
+/** Where Jump goes: a change from the Changes tab, or one Ask AI made. */
+type WorkflowJumpChange = Pick;
+
function workflowRunnerSummary(workflow: WorkflowDefinition, options: WorkflowEditorOptions): string {
if (workflow.runner_type === 'agent') {
const agent = options.agents.find((item) => workflowAgentKey(item) === workflowAgentKey(workflow.selected_agent));
@@ -118,6 +124,7 @@ export function WorkflowEditorDialog({
interactionDisabled = false,
initialDraft = null,
onSaveOverride,
+ onReload,
}: {
scope: WorkflowScope;
workflow: WorkflowDefinition | null;
@@ -134,6 +141,11 @@ export function WorkflowEditorDialog({
draft: WorkflowDefinition,
original: WorkflowDefinition | null,
) => Promise<{ success?: boolean; workflow?: WorkflowDefinition }>;
+ /**
+ * Reopen the saved workflow, discarding the draft. Ask AI offers it when the saved workflow
+ * changed after the editor opened; without it, the reader closes and reopens the editor.
+ */
+ onReload?: () => Promise;
}) {
const [original] = useState(() =>
workflow ? structuredClone(workflow) : null,
@@ -151,15 +163,18 @@ export function WorkflowEditorDialog({
const [surface, setSurface] = useState<'list' | 'flow'>('list');
const authoringRef = useRef(null);
const [changesOpen, setChangesOpen] = useState(false);
+ const [sideTab, setSideTab] = useState<'changes' | 'askai'>('changes');
const [confirmingSave, setConfirmingSave] = useState(false);
- const [panelFocus, setPanelFocus] = useState<{ target: 'tab' | 'confirm' | 'list'; sequence: number } | null>(null);
- const [jump, setJump] = useState<{ change: WorkflowChange; retry: boolean; sequence: number } | null>(null);
+ const [panelFocus, setPanelFocus] = useState<{ target: 'tab' | 'confirm' | 'list' | 'ask'; sequence: number } | null>(null);
+ const [jump, setJump] = useState<{ change: WorkflowJumpChange; retry: boolean; sequence: number } | null>(null);
const focusSequence = useRef(0);
const confirmHeadingRef = useRef(null);
const changesHeadingRef = useRef(null);
const errorRef = useRef(null);
const sidePanelBaseId = useId();
const changesToggleId = `${sidePanelBaseId}-changes-toggle`;
+ const askAiToggleId = `${sidePanelBaseId}-ask-ai-toggle`;
+ const askAiInputId = `${sidePanelBaseId}-ask-ai-input`;
const sidePanelId = `${sidePanelBaseId}-side-panel`;
const scopeKey = workflowScopeKey(scope);
const fieldDrafts = useMemo(() => ({
@@ -242,8 +257,10 @@ export function WorkflowEditorDialog({
onRollback: (before, after, targetId) => recoverAuthoring(before, after, targetId, false),
});
const panelOpen = changesOpen && !readOnly;
- const historyBlockedReason = saving ? 'Workflow history is unavailable while saving.'
- : authoring.pending || history.pending ? 'Finish or cancel the current confirmation first.' : '';
+ // The bootstrap sends this per user: on only where the setting, personal workflows and the role allow it.
+ const askAiEnabled = useBootstrapStore((state) => state.data?.features?.enable_workflow_ai_assistant === true);
+ const askAiAvailable = askAiEnabled && scope.type === 'personal' && !readOnly;
+ const panelTab = askAiAvailable ? sideTab : 'changes';
const onAccessLost = useCallback((status: number) => {
setAccessLost(true);
history.session.invalidate();
@@ -270,16 +287,31 @@ export function WorkflowEditorDialog({
}, [surface, authoring.focusRequest, accessLost]);
const openChanges = (target: 'tab' | 'confirm') => {
+ setSideTab('changes');
setChangesOpen(true);
setPanelFocus({ target, sequence: ++focusSequence.current });
};
+ const openAskAi = () => {
+ setSideTab('askai');
+ setConfirmingSave(false);
+ setChangesOpen(true);
+ setPanelFocus({ target: 'ask', sequence: ++focusSequence.current });
+ };
+ const selectSideTab = (tab: string) => {
+ // Leaving Changes ends a save review; Save starts it again.
+ if (tab === 'askai') setConfirmingSave(false);
+ setSideTab(tab === 'askai' ? 'askai' : 'changes');
+ };
const closeChanges = (focusToggle: boolean) => {
setChangesOpen(false);
setConfirmingSave(false);
- if (focusToggle) requestAnimationFrame(() => document.getElementById(changesToggleId)?.focus());
+ const toggleId = panelTab === 'askai' ? askAiToggleId : changesToggleId;
+ if (focusToggle) requestAnimationFrame(() => document.getElementById(toggleId)?.focus());
};
- const jumpToChange = (change: WorkflowChange) => {
+ const jumpToChange = (change: WorkflowJumpChange) => {
if (!sidePanelBesideEditor()) closeChanges(false);
+ // The jump places focus, so it drops the block focus that the change it follows requested, such as Draft with AI's.
+ authoring.clearFocusRequest();
if (surface === 'flow' && draft.definition_version === 3) {
const info = parseWorkflowChangeKey(change.key);
const nodeId = change.target.nodeId ?? (info.scope === 'region' ? info.id : undefined);
@@ -290,17 +322,47 @@ export function WorkflowEditorDialog({
}
setJump({ change, retry: true, sequence: ++focusSequence.current });
};
+ // Reloading the saved workflow locks the draft it is about to discard. Close stays available.
+ const [reloading, setReloading] = useState(false);
+ const assist = useWorkflowAssist({
+ available: askAiAvailable,
+ history,
+ options,
+ workflowId: original?.id || null,
+ blocked: saving || Boolean(history.pending) || Boolean(authoring.pending) || interactionDisabled || reloading,
+ onJump: jumpToChange,
+ });
+ // While Ask AI works, the editor is locked so its answer applies to the draft it was sent with.
+ const assistPending = Boolean(assist.pending);
+ const draftLocked = assistPending || reloading;
+ const historyBlockedReason = saving ? 'Workflow history is unavailable while saving.'
+ : authoring.pending || history.pending ? 'Finish or cancel the current confirmation first.'
+ : assistPending ? 'Wait for Ask AI to finish, or cancel it.'
+ : reloading ? 'Wait for the saved workflow to reload.' : '';
+ const reloadSaved = onReload ? async () => {
+ setReloading(true);
+ try {
+ await onReload();
+ } finally {
+ setReloading(false);
+ }
+ } : undefined;
useEffect(() => {
if (!panelFocus) return;
const frame = requestAnimationFrame(() => {
+ const selectedTab = () => document.getElementById(sidePanelId)?.querySelector('[role="tab"][aria-selected="true"]');
+ const askInput = () => {
+ const input = document.getElementById(askAiInputId);
+ return input instanceof HTMLTextAreaElement && !input.disabled ? input : null;
+ };
const target = panelFocus.target === 'confirm' ? confirmHeadingRef.current
: panelFocus.target === 'list' ? changesHeadingRef.current
- : document.getElementById(sidePanelId)?.querySelector('[role="tab"][aria-selected="true"]');
+ : panelFocus.target === 'ask' ? askInput() ?? selectedTab() : selectedTab();
target?.focus();
});
return () => cancelAnimationFrame(frame);
- }, [panelFocus, sidePanelId]);
+ }, [panelFocus, sidePanelId, askAiInputId]);
useEffect(() => {
if (!jump) return;
@@ -309,7 +371,11 @@ export function WorkflowEditorDialog({
if (!root) return;
const { change } = jump;
const itemKey = workflowChangeItemKey(change.key);
- const exact = root.querySelector(`[data-workflow-change-key="${CSS.escape(change.target.focusKey)}"]`)
+ const focusKey = CSS.escape(change.target.focusKey);
+ // A jump made as a change lands, such as Draft with AI's, can run before the highlight's
+ // deferred render stamps the change key, so the field's own key comes next.
+ const exact = root.querySelector(`[data-workflow-change-key="${focusKey}"]`)
+ ?? root.querySelector(`[data-workflow-field-key="${focusKey}"]`)
?? (itemKey ? root.querySelector(`[data-workflow-change-item="${CSS.escape(itemKey)}"]`) : null);
if (!exact && jump.retry && surface === 'flow') {
// The Flow surface shows one block's fields at a time; the List shows every field.
@@ -334,14 +400,44 @@ export function WorkflowEditorDialog({
const onKeyDown = (event: KeyboardEvent) => {
if (event.key !== 'Escape' || event.defaultPrevented) return;
const active = document.activeElement;
- if (!active || !(document.getElementById(sidePanelId)?.contains(active) || active.id === changesToggleId)) return;
+ const panel = document.getElementById(sidePanelId);
+ if (!active || !(panel?.contains(active) || active.id === changesToggleId || active.id === askAiToggleId)) return;
+ // An open # menu or document picker closes itself on this Escape, and the panel stays open.
+ const picker = panel?.querySelector('[data-context-picker]');
+ if (picker || active.closest('[data-composer-menu-open]')) {
+ if (picker?.contains(active)) {
+ // The picker's search box goes with it, so carry on in the Ask AI input.
+ window.setTimeout(() => {
+ if (!document.getElementById(sidePanelId)?.contains(document.activeElement)) {
+ document.getElementById(askAiInputId)?.focus();
+ }
+ }, 0);
+ }
+ return;
+ }
event.preventDefault();
event.stopPropagation();
closeChanges(true);
};
window.addEventListener('keydown', onKeyDown, true);
return () => window.removeEventListener('keydown', onKeyDown, true);
- }, [panelOpen, sidePanelId, changesToggleId]);
+ }, [panelOpen, panelTab, sidePanelId, changesToggleId, askAiToggleId, askAiInputId]);
+
+ // When the lock ends and took focus with it (its Cancel button is gone), carry on in Ask AI.
+ const wasAssistPending = useRef(false);
+ useEffect(() => {
+ const was = wasAssistPending.current;
+ wasAssistPending.current = assistPending;
+ if (!was || assistPending) return undefined;
+ const frame = requestAnimationFrame(() => {
+ const active = document.activeElement;
+ if (active && active !== document.body) return;
+ const input = document.getElementById(askAiInputId);
+ const target = input instanceof HTMLTextAreaElement && !input.disabled ? input : document.getElementById(askAiToggleId);
+ target?.focus();
+ });
+ return () => cancelAnimationFrame(frame);
+ }, [assistPending, askAiInputId, askAiToggleId]);
const shownError = useRef(error);
useEffect(() => {
@@ -410,7 +506,7 @@ export function WorkflowEditorDialog({
return;
}
history.session.closeGroup();
- if (saving || history.session.saving || readOnly || authoring.pending || history.session.getSnapshot().pending) {
+ if (saving || history.session.saving || readOnly || authoring.pending || history.session.getSnapshot().pending || draftLocked) {
return;
}
const savingDraft = history.session.draft;
@@ -486,8 +582,14 @@ export function WorkflowEditorDialog({
onChange={(value) => authoring.execute({ type: 'task', taskId: task.id, value, expected: task })}
durableExecution={draft.durable_execution === true}
onNeedsDurable={() => setWorkflow((current) => ({ ...current, durable_execution: true }))}
- structuredNode={node} onStructuredNodeChange={onNodeChange} />
+ structuredNode={node} onStructuredNodeChange={onNodeChange}
+ onAskAi={askAiAvailable ? () => askAiAboutTask(task.id) : undefined}
+ draftWithAi={askAiAvailable ? assist.draftWithAi(task.id) : undefined} />
);
+ const askAiAboutTask = (taskId: string) => {
+ assist.setFocusTask(taskId);
+ openAskAi();
+ };
return (
@@ -500,21 +602,35 @@ export function WorkflowEditorDialog({
tall
bodyClassName="flex min-h-0"
footer={
- <>
- {!readOnly ? (panelOpen ? closeChanges(false) : openChanges('tab'))} /> : null}
-
- {readOnly ? 'Close' : 'Cancel'}
-
- {!readOnly ? (
- void save()}>
- {saving ? 'Saving…' : 'Save workflow'}
+ // A narrow screen can't fit the toggles, Cancel and Save on one line once Ask AI is
+ // offered, so Cancel and Save move together to a second line instead of wrapping a label.
+
+ {askAiAvailable ?
(panelOpen && panelTab === 'askai' ? closeChanges(false) : openAskAi())} /> : null}
+ {!readOnly ? (panelOpen && panelTab === 'changes' ? closeChanges(false) : openChanges('tab'))} /> : null}
+
+
+ {readOnly ? 'Close' : 'Cancel'}
- ) : null}
- >
+ {!readOnly ? (
+ void save()}>
+ {saving ? 'Saving…' : 'Save workflow'}
+
+ ) : null}
+
+
}
>
-
+
{
+ // Workflow undo and redo wait for Ask AI too.
+ const key = event.key.toLowerCase();
+ if (draftLocked && (event.ctrlKey || event.metaKey) && (key === 'z' || key === 'y')) event.preventDefault();
+ }}>
+ {assist.pending ?
: null}
{unsupported ? (
@@ -577,7 +693,8 @@ export function WorkflowEditorDialog({
: null}
{!accessLost ?
-
+
@@ -821,7 +938,7 @@ export function WorkflowEditorDialog({
}} onEdit={authoring.execute} renderTask={renderStructuredTask}
positions={authoring.positions} setPositions={authoring.setPositions}
collapsed={authoring.collapsed} setCollapsed={authoring.setCollapsed}
- focusRequest={authoring.focusRequest} disabled={readOnly || saving || Boolean(history.pending) || Boolean(authoring.pending)} onAccessLost={onAccessLost} />
+ focusRequest={authoring.focusRequest} disabled={readOnly || saving || Boolean(history.pending) || Boolean(authoring.pending) || draftLocked} onAccessLost={onAccessLost} />
> : {
@@ -845,6 +962,8 @@ export function WorkflowEditorDialog({
onRemove={() => removeTask(task.id)}
durableExecution={draft.durable_execution === true}
onNeedsDurable={() => setWorkflow((current) => ({ ...current, durable_execution: true }))}
+ onAskAi={askAiAvailable ? () => askAiAboutTask(task.id) : undefined}
+ draftWithAi={askAiAvailable ? assist.draftWithAi(task.id) : undefined}
/>
@@ -868,17 +987,21 @@ export function WorkflowEditorDialog({
{panelOpen ? (
void save(true)}
onKeepReviewing={() => {
setConfirmingSave(false);
setPanelFocus({ target: 'list', sequence: ++focusSequence.current });
}}
onJump={jumpToChange} confirmHeadingRef={confirmHeadingRef} listHeadingRef={changesHeadingRef} />,
- }]} />
+ }, ...(askAiAvailable ? [{
+ id: 'askai', label: 'Ask AI', panelClassName: 'flex flex-col overflow-hidden',
+ content: ,
+ }] : [])]} />
) : null}
{/* Outside the editor pane, which narrow screens hide while the side panel is open. */}
{!accessLost && authoring.announcement ? {authoring.announcement}
: null}
diff --git a/application/v2_ui/src/components/workflows/WorkflowTaskFields.tsx b/application/v2_ui/src/components/workflows/WorkflowTaskFields.tsx
index 14621edd8..819ecaec7 100644
--- a/application/v2_ui/src/components/workflows/WorkflowTaskFields.tsx
+++ b/application/v2_ui/src/components/workflows/WorkflowTaskFields.tsx
@@ -2,7 +2,7 @@
// Shared task fields and runner pickers for native workflow authoring.
import { useState } from 'react';
-import { ArrowDown, ArrowUp, Trash2 } from 'lucide-react';
+import { ArrowDown, ArrowUp, Sparkles, Trash2 } from 'lucide-react';
import { GlassButton, GlassPanel, Toggle } from '../ui/primitives';
import { Pill } from '../workspace/primitives';
import { WorkflowDocumentPicker } from './WorkflowDocumentPicker';
@@ -962,6 +962,16 @@ function DocumentActionFields({
);
}
+/** Draft with AI for a task whose instructions are empty. */
+export interface WorkflowTaskDraftWithAi {
+ readonly onDraft: () => void;
+ readonly pending: boolean;
+ /** Another assistant request is running, so this one waits. */
+ readonly disabled: boolean;
+ /** What the last attempt for this task said, as plain text. */
+ readonly message?: string;
+}
+
export function WorkflowTaskFields({
scope,
task,
@@ -975,6 +985,8 @@ export function WorkflowTaskFields({
onNeedsDurable,
structuredNode,
onStructuredNodeChange,
+ onAskAi,
+ draftWithAi,
}: {
scope: WorkflowScope;
task: WorkflowTask;
@@ -988,11 +1000,17 @@ export function WorkflowTaskFields({
onNeedsDurable: () => void;
structuredNode?: WorkflowTaskNode;
onStructuredNodeChange?: (node: WorkflowTaskNode) => void;
+ /** Ask AI about this task: opens the Ask AI tab with the task in focus. */
+ onAskAi?: () => void;
+ /** Offered while the task has no instructions. */
+ draftWithAi?: WorkflowTaskDraftWithAi;
}) {
const previousTasks = workflow.tasks.slice(0, index);
const errors = taskValidationErrors(task, index, workflow.tasks, workflow);
const drafts = useWorkflowFieldDrafts();
const draftOwner: WorkflowFieldDraftOwner = ['task', task.id];
+ const taskLabel = task.name || `Task ${index + 1}`;
+ const classicControls = !structuredNode && onMove && onRemove ? { onMove, onRemove } : null;
const defaultOutputContract = (kind: WorkflowOutputKind): WorkflowOutputContract => ({
kind,
require_complete_coverage: false,
@@ -1004,22 +1022,30 @@ export function WorkflowTaskFields({
-
{task.name || `Task ${index + 1}`}
+
{taskLabel}
{runnerLabel(task.runner)} · {taskInputMode(task) === 'auto' ? 'automatic input' : `${task.inputs?.length ?? 0} bound inputs`}
- {!structuredNode && onMove && onRemove ?
-
onMove(-1)} aria-label={`Move ${task.name || `Task ${index + 1}`} up`}>
+ {onAskAi || classicControls ?
+ {onAskAi ? (
+
+ Ask AI
+
+ ) : null}
+ {classicControls ? <>
+
classicControls.onMove(-1)} aria-label={`Move ${taskLabel} up`}>
Up
-
= workflow.tasks.length - 1} onClick={() => onMove(1)} aria-label={`Move ${task.name || `Task ${index + 1}`} down`}>
+ = workflow.tasks.length - 1} onClick={() => classicControls.onMove(1)} aria-label={`Move ${taskLabel} down`}>
Down
-
+
Remove
+ > : null}
: null}
{errors.length ? (
@@ -1057,6 +1083,19 @@ export function WorkflowTaskFields({
+ {draftWithAi && (!task.instructions.trim() || draftWithAi.message) ? (
+
+ {!task.instructions.trim() ? (
+
+ {draftWithAi.pending ? 'Drafting…' : 'Draft with AI'}
+
+ ) : null}
+ {/* Always present so a new message is announced. */}
+
{draftWithAi.message ?? ''}
+
+ ) : null}
{structuredNode && onStructuredNodeChange ? (
;
+
+const epochs = new WeakMap();
+
+/**
+ * The editing session a turn belongs to: one for each version the editor opened. A turn from an
+ * earlier session can no longer be undone, because its history went with that session.
+ */
+export function workflowAssistEpoch(baseline: WorkflowDefinition): string {
+ let epoch = epochs.get(baseline);
+ if (!epoch) {
+ epoch = makeSubmissionId();
+ epochs.set(baseline, epoch);
+ }
+ return epoch;
+}
+
+/** The one task a turn is about. */
+export interface WorkflowAssistFocus {
+ readonly taskId: string;
+ /** What the request sends: the task, or its flow block in a structured draft. */
+ readonly value: string;
+ readonly label: string;
+}
+
+export interface UseWorkflowAssistOptions {
+ /** Ask AI is offered: the setting is on for this user, the workflow is personal, and the editor can write. */
+ readonly available: boolean;
+ readonly history: AuthoringHistory;
+ readonly options: WorkflowEditorOptions;
+ /** The saved workflow the editor opened, or null for a new or proposal draft. */
+ readonly workflowId: string | null;
+ /** The editor cannot take a change now: it is saving, or a confirmation or flow command is pending. */
+ readonly blocked: boolean;
+ /** Show a change in the editor. */
+ readonly onJump: (change: Pick) => void;
+}
+
+export interface WorkflowAssist {
+ readonly available: boolean;
+ readonly thread: AssistThreadController;
+ /** The turn that is running. The editor is locked until it ends. */
+ readonly pending: AssistExchange | null;
+ /** Nothing new can be sent right now. */
+ readonly busy: boolean;
+ /** Seconds before the assistant takes another request, after a 429 or a throttled 503. */
+ readonly waitSeconds: number;
+ readonly epoch: string;
+ readonly turns: Readonly>;
+ readonly historyView: WorkflowAssistHistoryView;
+ readonly focus: WorkflowAssistFocus | null;
+ readonly setFocusTask: (taskId: string | null) => void;
+ /** The saved workflow changed after the editor opened; reloading it lets Ask AI work again. */
+ readonly reloadOffered: boolean;
+ readonly quickAction: (text: string) => boolean;
+ readonly undo: (turnId: string) => void;
+ /** Show a change or warning target in the editor. A flow block is used only while it exists. */
+ readonly jump: (target: WorkflowAssistJumpTarget) => void;
+ readonly cancel: () => void;
+ readonly draftWithAi: (taskId: string) => WorkflowTaskDraftWithAi;
+}
+
+/** Where Jump to goes: an editor change key, the field to focus, and the flow block holding it. */
+export interface WorkflowAssistJumpTarget {
+ readonly key: string;
+ readonly focusKey: string;
+ readonly nodeId?: string;
+}
+
+interface LiveState {
+ session: WorkflowAuthoringSession;
+ available: boolean;
+ options: WorkflowEditorOptions;
+ key: string | null;
+ focus: string | null;
+ onJump: UseWorkflowAssistOptions['onJump'];
+}
+
+function applyStatus(status: string): WorkflowAssistApply {
+ return status === 'applied' ? 'applied' : status === 'confirmation_required' ? 'pending'
+ : status === 'noop' ? 'noop' : 'rejected';
+}
+
+export function useWorkflowAssist({
+ available, history, options, workflowId, blocked, onJump,
+}: UseWorkflowAssistOptions): WorkflowAssist {
+ const { session, draft } = history;
+ const [newDraftKey] = useState(() => `workflow:new:${makeSubmissionId()}`);
+ const key = available ? (workflowId ? `workflow:personal:${workflowId}` : newDraftKey) : null;
+ const epoch = workflowAssistEpoch(history.baseline);
+ const turns = useWorkflowAssistStore((state) => state.turns);
+ const retryUntil = useWorkflowAssistStore((state) => state.retryUntil);
+ const [waitSeconds, setWaitSeconds] = useState(0);
+ const [focusTaskId, setFocusTaskId] = useState(null);
+ const [reloadOffered, setReloadOffered] = useState(false);
+ const [drafting, setDrafting] = useState(null);
+ const [draftMessages, setDraftMessages] = useState>>({});
+ const draftController = useRef(null);
+
+ const focusIndex = focusTaskId ? draft.tasks.findIndex((task) => task.id === focusTaskId) : -1;
+ const focusTask = focusIndex >= 0 ? draft.tasks[focusIndex] : null;
+ const focus = useMemo(() => focusTask ? {
+ taskId: focusTask.id,
+ value: draft.definition_version === 3 ? flowTaskNodeId(draft, focusTask.id) || focusTask.id : focusTask.id,
+ label: focusTask.name.trim() || `Task ${focusIndex + 1}`,
+ } : null, [focusTask, focusIndex, draft]);
+
+ // A removed task takes its focus with it.
+ useEffect(() => {
+ if (focusTaskId && !focusTask) setFocusTaskId(null);
+ }, [focusTaskId, focusTask]);
+
+ const live = useRef({ session, available, options, key, focus: focus?.value ?? null, onJump });
+ live.current = { session, available, options, key, focus: focus?.value ?? null, onJump };
+
+ const send = useCallback(async (request: AssistSendRequest): Promise => {
+ const sent = live.current;
+ const sentSession = sent.session;
+ if (!sent.available || !sent.key || !sentSession.active) return { ok: false, error: UNAVAILABLE_MESSAGE };
+ sentSession.closeGroup();
+ const view = sentSession.getSnapshot();
+ const sentDraft = sentSession.draft;
+ const sentBaseline = view.baseline;
+ const records = useWorkflowAssistStore.getState().turns;
+ const thread = selectAssistThread(useAssistThreadStore.getState(), sent.key);
+ let body: WorkflowAssistRequest;
+ try {
+ body = buildWorkflowAssistRequest({
+ submissionId: request.submissionId,
+ instruction: request.text,
+ baseline: sentBaseline,
+ draft: sentDraft,
+ contextItems: request.draft.contextItems,
+ conversation: workflowAssistReplay(thread?.exchanges ?? [], (id) => records[id], view,
+ workflowAssistEpoch(sentBaseline), request.submissionId),
+ focus: sent.focus,
+ timeZone: browserTimeZone(),
+ timeZones: workflowScheduleTimezones(sent.options),
+ });
+ } catch (problem) {
+ if (problem instanceof WorkflowAssistRequestError) return { ok: false, error: problem.message };
+ throw problem;
+ }
+
+ const result = await postWorkflowAssist(body, request.signal);
+ if (request.signal.aborted) return ABORTED;
+ if (!result.ok) {
+ if (result.aborted) return ABORTED;
+ const { failure } = result;
+ if (failure.retryAfterSeconds) {
+ useWorkflowAssistStore.getState().waitUntil(Date.now() + failure.retryAfterSeconds * 1000);
+ }
+ if (failure.reload) setReloadOffered(true);
+ return { ok: false, error: failure.message };
+ }
+
+ // The editor is locked while a turn runs, but the answer is still checked against the draft
+ // it was sent with: an answer about another draft is never applied.
+ const now = live.current;
+ if (now.session !== sentSession || !sentSession.active || !now.available || now.key !== sent.key
+ || sentSession.getSnapshot().baseline !== sentBaseline) {
+ return { ok: false, error: STALE_DRAFT_MESSAGE };
+ }
+ sentSession.closeGroup();
+ const current = sentSession.draft;
+ if (!assistDraftUnchanged(sentDraft, current)) return { ok: false, error: STALE_DRAFT_MESSAGE };
+
+ const { response } = result;
+ const store = useWorkflowAssistStore.getState();
+ const turnId = request.submissionId;
+ const record = {
+ threadKey: sent.key,
+ epoch: workflowAssistEpoch(sentBaseline),
+ instruction: body.instruction,
+ reply: response.reply,
+ warnings: response.warnings,
+ contextDocuments: response.contextDocuments,
+ undoSequence: 0,
+ };
+ if (response.outcome !== 'changed' || !response.candidate) {
+ store.record(turnId, { ...record, outcome: response.outcome, changes: [], apply: 'none' });
+ return { ok: true, reply: response.reply };
+ }
+ const rebased = rebaseAssistCandidate(current, response.candidate);
+ const verified = verifyAssistChanges(current, rebased, response.changes);
+ if (!verified.changes.length) {
+ store.record(turnId, { ...record, outcome: 'explained', changes: [], apply: 'noop' });
+ return { ok: true, reply: response.reply };
+ }
+ const applied = sentSession.applyAssist(rebased, {
+ turnId, label: workflowAssistTurnLabel('Ask AI', body.instruction),
+ });
+ const apply = applyStatus(applied.status);
+ store.record(turnId, {
+ ...record,
+ outcome: apply === 'noop' ? 'explained' : 'changed',
+ changes: apply === 'noop' ? [] : verified.changes,
+ apply,
+ ...(applied.status === 'rejected' ? { applyMessage: applied.message } : {}),
+ });
+ return { ok: true, reply: response.reply };
+ }, []);
+
+ // Closing the editor, or losing Ask AI, stops waiting for its answer. The server writes nothing,
+ // so nothing is left half done. Declared before the thread, so this cleanup runs while the
+ // thread is still held: cancelling then never sweeps the chat's idle threads.
+ useEffect(() => {
+ if (!key) return undefined;
+ return () => {
+ const running = selectAssistThread(useAssistThreadStore.getState(), key)?.exchanges
+ .find((exchange) => exchange.status === 'pending');
+ if (running) cancelAssistExchange(key, running.id);
+ };
+ }, [key]);
+
+ const thread = useAssistThread({
+ key,
+ conversationId: WORKFLOW_ASSIST_CONVERSATION,
+ mode: 'local',
+ maxLength: WORKFLOW_ASSIST_INSTRUCTION_LIMIT,
+ send,
+ countCodePoints: true,
+ retain: true,
+ });
+ const pending = thread.pending;
+
+ useEffect(() => () => draftController.current?.abort(), []);
+
+ // The wait the assistant asked for, in whole seconds, ticking down.
+ useEffect(() => {
+ let timer = 0;
+ const tick = () => {
+ const left = retryUntil - Date.now();
+ setWaitSeconds(left > 0 ? Math.ceil(left / 1000) : 0);
+ if (left > 0) timer = window.setTimeout(tick, left % 1000 || 1000);
+ };
+ tick();
+ return () => window.clearTimeout(timer);
+ }, [retryUntil]);
+
+ const busy = !available || blocked || waitSeconds > 0 || drafting !== null;
+ const historyView = useMemo(() => ({
+ steps: history.steps, attribution: history.attribution, pending: history.pending,
+ }), [history.steps, history.attribution, history.pending]);
+
+ const setDraftMessage = useCallback((taskId: string, message: string) => {
+ setDraftMessages((current) => {
+ if ((current[taskId] ?? '') === message) return current;
+ const next = { ...current };
+ if (message) next[taskId] = message;
+ else delete next[taskId];
+ return next;
+ });
+ }, []);
+
+ const draftInstructions = useCallback(async (taskId: string) => {
+ const start = live.current;
+ if (!start.available || draftController.current || !start.session.active) return;
+ const workflow = start.session.draft;
+ const index = workflow.tasks.findIndex((task) => task.id === taskId);
+ const task = workflow.tasks[index];
+ if (!task || task.instructions.trim()) return;
+ const name = task.name.trim();
+ const label = name || `Task ${index + 1}`;
+ if (!workflow.name.trim() && !workflow.description.trim() && (!name || /^Task \d+$/.test(name))) {
+ setDraftMessage(taskId, NEEDS_NAME_MESSAGE);
+ return;
+ }
+ const controller = new AbortController();
+ draftController.current = controller;
+ setDrafting(taskId);
+ setDraftMessage(taskId, '');
+ let refocus = true;
+ try {
+ const instructions = await requestDraftInstructions(workflow, name, controller.signal);
+ if (controller.signal.aborted) return;
+ const now = live.current;
+ if (now.session !== start.session || !now.session.active || !now.available) {
+ setDraftMessage(taskId, EDITOR_CHANGED_MESSAGE);
+ return;
+ }
+ now.session.closeGroup();
+ const candidate = withDraftedInstructions(now.session.draft, taskId, instructions);
+ if (!candidate) {
+ setDraftMessage(taskId, TASK_CHANGED_MESSAGE);
+ return;
+ }
+ const result = now.session.applyAssist(candidate, {
+ turnId: makeSubmissionId(), label: workflowAssistTurnLabel('Draft with AI', label),
+ });
+ if (result.status === 'applied') {
+ refocus = false;
+ const focusKey = workflowTaskKey(taskId, 'instructions');
+ const nodeId = candidate.definition_version === 3 ? flowTaskNodeId(candidate, taskId) : '';
+ now.onJump({ key: focusKey, target: { focusKey, ...(nodeId ? { nodeId } : {}) } });
+ } else if (result.status === 'confirmation_required') {
+ refocus = false;
+ setDraftMessage(taskId, 'Confirm to add the drafted instructions.');
+ } else if (result.status === 'rejected') {
+ setDraftMessage(taskId, `Nothing was added: ${result.message}`);
+ }
+ } catch (problem) {
+ if (controller.signal.aborted) return;
+ setDraftMessage(taskId, problem instanceof Error && problem.message ? problem.message
+ : "Couldn't draft instructions. Try again.");
+ } finally {
+ if (draftController.current === controller) {
+ draftController.current = null;
+ setDrafting(null);
+ // The button was disabled while it waited, which drops focus; put it back.
+ if (refocus && !controller.signal.aborted) {
+ requestAnimationFrame(() => {
+ if (document.activeElement && document.activeElement !== document.body) return;
+ document.querySelector(`[data-workflow-draft-ai="${CSS.escape(taskId)}"]`)?.focus();
+ });
+ }
+ }
+ }
+ }, [setDraftMessage]);
+
+ const draftWithAi = (taskId: string): WorkflowTaskDraftWithAi => ({
+ onDraft: () => { void draftInstructions(taskId); },
+ pending: drafting === taskId,
+ disabled: !available || blocked || drafting !== null || Boolean(pending),
+ message: draftMessages[taskId] || undefined,
+ });
+
+ const quickAction = (text: string) => !busy && !pending && thread.sendText(text);
+
+ const undo = useCallback((turnId: string) => {
+ const { session: current, key: threadKey } = live.current;
+ // The editor is locked while a turn runs; its answer is checked against the draft it was sent with.
+ if (threadKey && selectAssistThread(useAssistThreadStore.getState(), threadKey)?.exchanges
+ .some((exchange) => exchange.status === 'pending')) return;
+ const result = current.revertTurn(turnId);
+ useWorkflowAssistStore.getState().update(turnId, (record) => ({
+ ...record, undo: result, undoSequence: record.undoSequence + 1,
+ }));
+ }, []);
+
+ const jump = useCallback(({ key: changeKey, focusKey, nodeId }: WorkflowAssistJumpTarget) => {
+ const { session: current, onJump: show } = live.current;
+ const node = nodeId && workflowFlowNodeIds(current.draft).has(nodeId) ? nodeId : undefined;
+ show({ key: changeKey, target: { focusKey, ...(node ? { nodeId: node } : {}) } });
+ }, []);
+
+ const cancel = () => {
+ if (pending) thread.cancel(pending.id);
+ };
+
+ return {
+ available,
+ thread,
+ pending,
+ busy,
+ waitSeconds,
+ epoch,
+ turns,
+ historyView,
+ focus,
+ setFocusTask: setFocusTaskId,
+ reloadOffered,
+ quickAction,
+ undo,
+ jump,
+ cancel,
+ draftWithAi,
+ };
+}
diff --git a/application/v2_ui/src/components/workflows/useWorkflowAuthoring.ts b/application/v2_ui/src/components/workflows/useWorkflowAuthoring.ts
index b85a9698e..dec8a4a65 100644
--- a/application/v2_ui/src/components/workflows/useWorkflowAuthoring.ts
+++ b/application/v2_ui/src/components/workflows/useWorkflowAuthoring.ts
@@ -39,6 +39,8 @@ export function useWorkflowAuthoring({
const requestFocus = useCallback((id: string, fields = false) => {
setFocusRequest({ id, sequence: ++sequence.current, fields });
}, []);
+ // For a caller that places focus itself, such as a jump to a change.
+ const clearFocusRequest = useCallback(() => setFocusRequest(null), []);
const evaluate = useCallback((command: WorkflowEditCommand, confirmed: boolean): WorkflowEditResult => {
const current = latest.current;
@@ -141,6 +143,6 @@ export function useWorkflowAuthoring({
return {
selectedId, setSelectedId, positions, setPositions, collapsed, setCollapsed,
- focusRequest, requestFocus, pending, cancel, confirm, execute, announcement, recover, reset,
+ focusRequest, requestFocus, clearFocusRequest, pending, cancel, confirm, execute, announcement, recover, reset,
};
}
diff --git a/application/v2_ui/src/lib/assistThread.ts b/application/v2_ui/src/lib/assistThread.ts
index 8eb901a84..ae06de891 100644
--- a/application/v2_ui/src/lib/assistThread.ts
+++ b/application/v2_ui/src/lib/assistThread.ts
@@ -24,6 +24,7 @@ import {
import type { ComposerDraft } from './composerDraft';
import { addContextItem, type ContextItem } from './chatContext';
import { reconcileContextItems } from './chatContextTokens';
+import { codePointLength } from './codePoints';
import {
blankDraft,
capAbandonedIds,
@@ -31,6 +32,7 @@ import {
draftHasContent,
exchangeRetryId,
exchangeSubmissionIds,
+ holdAssistThread,
selectAssistThread,
useAssistThreadStore,
withSentId,
@@ -132,14 +134,23 @@ export function planThreadKey(conversationId: string, turnId: string): string {
/** Why a message could not be sent. */
export type AssistSendProblem = 'empty' | 'too_long' | 'pending';
+/**
+ * How long the input's text is. UTF-16 units by default; `countCodePoints` counts Unicode code
+ * points instead, for a server that measures its limit that way.
+ */
+export function assistTextLength(text: string, countCodePoints = false): number {
+ return countCodePoints ? codePointLength(text) : text.length;
+}
+
export function describeDraftProblem(
draft: ComposerDraft,
maxLength: number,
+ countCodePoints = false,
): Exclude | null {
if (!draft.text.trim()) {
return 'empty';
}
- return draft.text.length > maxLength ? 'too_long' : null;
+ return assistTextLength(draft.text, countCodePoints) > maxLength ? 'too_long' : null;
}
/** Browser requests of this page's threads, by exchange id. */
@@ -332,6 +343,13 @@ export interface AssistSubmitOptions {
mode: AssistThreadMode;
maxLength: number;
send: AssistSend;
+ /** Measure `maxLength` in code points rather than UTF-16 units. Off by default. */
+ countCodePoints?: boolean;
+ /**
+ * Send this text as a message of its own, such as a quick action, and leave the input as it
+ * is. Unset by default: what the input holds is sent and the input clears.
+ */
+ text?: string;
}
/**
@@ -343,8 +361,9 @@ export interface AssistSubmitOptions {
*/
export function submitAssistDraft(options: AssistSubmitOptions): string | null {
const record = readThread(options.key);
- const draft = record?.draft ?? blankDraft();
- if (describeDraftProblem(draft, options.maxLength)
+ const ownText = options.text !== undefined;
+ const draft = ownText ? { ...blankDraft(), text: options.text ?? '' } : record?.draft ?? blankDraft();
+ if (describeDraftProblem(draft, options.maxLength, options.countCodePoints === true)
|| record?.exchanges.some((exchange) => exchange.status === 'pending')) {
return null;
}
@@ -369,7 +388,7 @@ export function submitAssistDraft(options: AssistSubmitOptions): string | null {
...current.abandonedIds,
...moving.flatMap((item) => exchangeSubmissionIds(item)),
]),
- draft: blankDraft(),
+ draft: ownText ? current.draft : blankDraft(),
notice: null,
};
});
@@ -500,6 +519,13 @@ export interface UseAssistThreadOptions {
send: AssistSend;
/** Replaces the default cancel, which only stops the browser waiting. */
cancel?: (exchange: AssistExchange) => unknown;
+ /** Measure `maxLength` in code points rather than UTF-16 units. Off by default. */
+ countCodePoints?: boolean;
+ /**
+ * Keep the thread while this editor is mounted, even when it is idle and another
+ * conversation's thread is touched. Off by default: threads are pruned as before.
+ */
+ retain?: boolean;
}
export interface AssistThreadController {
@@ -520,6 +546,8 @@ export interface AssistThreadController {
inputRef: RefObject;
focusInput: () => void;
send: () => boolean;
+ /** Send `text` as a message of its own, such as a quick action, leaving the input as it is. */
+ sendText: (text: string) => boolean;
retry: (id: string) => boolean;
cancel: (id: string) => void;
editAndResend: (id: string) => boolean;
@@ -530,6 +558,8 @@ const NO_EXCHANGES: AssistExchange[] = [];
export function useAssistThread(options: UseAssistThreadOptions): AssistThreadController {
const { key, conversationId, mode, maxLength, storedTurns } = options;
+ const countCodePoints = options.countCodePoints === true;
+ const retain = options.retain === true;
const record = useAssistThreadStore((state) => selectAssistThread(state, key));
const inputRef = useRef(null);
const sendRef = useRef(options.send);
@@ -537,6 +567,8 @@ export function useAssistThread(options: UseAssistThreadOptions): AssistThreadCo
sendRef.current = options.send;
cancelRef.current = options.cancel;
+ useEffect(() => (retain && key ? holdAssistThread(key) : undefined), [retain, key]);
+
const storedIds = useMemo(() => {
const ids = new Set();
for (const turn of storedTurns ?? []) {
@@ -606,12 +638,23 @@ export function useAssistThread(options: UseAssistThreadOptions): AssistThreadCo
if (!key) {
return false;
}
- const id = submitAssistDraft({ key, conversationId, mode, maxLength, send: sendRef.current });
+ const id = submitAssistDraft({ key, conversationId, mode, maxLength, send: sendRef.current, countCodePoints });
+ if (id) {
+ focusInput();
+ }
+ return Boolean(id);
+ }, [key, conversationId, mode, maxLength, countCodePoints, focusInput]);
+
+ const sendText = useCallback((text: string) => {
+ if (!key) {
+ return false;
+ }
+ const id = submitAssistDraft({ key, conversationId, mode, maxLength, send: sendRef.current, countCodePoints, text });
if (id) {
focusInput();
}
return Boolean(id);
- }, [key, conversationId, mode, maxLength, focusInput]);
+ }, [key, conversationId, mode, maxLength, countCodePoints, focusInput]);
const retry = useCallback((id: string) => {
const retried = key ? retryAssistExchange(key, id, mode, sendRef.current) : false;
@@ -632,6 +675,8 @@ export function useAssistThread(options: UseAssistThreadOptions): AssistThreadCo
return edited;
}, [key, focusInput]);
+ const length = useMemo(() => assistTextLength(draft.text, countCodePoints), [draft.text, countCodePoints]);
+
return {
key,
mode,
@@ -644,12 +689,13 @@ export function useAssistThread(options: UseAssistThreadOptions): AssistThreadCo
notice: record?.notice ?? null,
dismissNotice,
maxLength,
- length: draft.text.length,
- overLimit: draft.text.length > maxLength,
+ length,
+ overLimit: length > maxLength,
ownSubmissionIds,
inputRef,
focusInput,
send,
+ sendText,
retry,
cancel,
editAndResend,
diff --git a/application/v2_ui/src/lib/codePoints.ts b/application/v2_ui/src/lib/codePoints.ts
new file mode 100644
index 000000000..d869ba195
--- /dev/null
+++ b/application/v2_ui/src/lib/codePoints.ts
@@ -0,0 +1,43 @@
+// codePoints.ts
+// Measure and cut text in Unicode code points, the way the server counts it.
+//
+// JavaScript strings count UTF-16 code units, so an emoji outside the Basic Multilingual Plane
+// counts twice. Python counts code points, and a surrogate pair cut in half is a lone surrogate
+// the server refuses outright. Both helpers keep a pair together.
+
+function isHighSurrogate(unit: number): boolean {
+ return unit >= 0xd800 && unit <= 0xdbff;
+}
+
+function isLowSurrogate(unit: number): boolean {
+ return unit >= 0xdc00 && unit <= 0xdfff;
+}
+
+/** How many UTF-16 units the code point starting at `index` takes: 2 for a surrogate pair. */
+function codePointWidth(text: string, index: number): number {
+ return isHighSurrogate(text.charCodeAt(index)) && index + 1 < text.length
+ && isLowSurrogate(text.charCodeAt(index + 1)) ? 2 : 1;
+}
+
+/** The number of code points in `text`. A lone surrogate counts as one, as it does in Python. */
+export function codePointLength(text: string): number {
+ let count = 0;
+ for (let index = 0; index < text.length; index += codePointWidth(text, index)) {
+ count += 1;
+ }
+ return count;
+}
+
+/** The first `limit` code points of `text`, never splitting a surrogate pair. */
+export function codePointPrefix(text: string, limit: number): string {
+ if (limit <= 0) {
+ return '';
+ }
+ let count = 0;
+ let index = 0;
+ while (index < text.length && count < limit) {
+ index += codePointWidth(text, index);
+ count += 1;
+ }
+ return index >= text.length ? text : text.slice(0, index);
+}
diff --git a/application/v2_ui/src/lib/workflowAssist.ts b/application/v2_ui/src/lib/workflowAssist.ts
new file mode 100644
index 000000000..be4a37347
--- /dev/null
+++ b/application/v2_ui/src/lib/workflowAssist.ts
@@ -0,0 +1,801 @@
+// workflowAssist.ts
+// The Ask AI tab's side of POST /api/user/workflows/assist: the request it sends, the answer it
+// accepts, and what a failure means for the reader.
+//
+// Everything the server sends back is untrusted. The answer is checked field by field, the
+// candidate is diffed here rather than trusting the change list that came with it, and every
+// string is plain text for the UI to render as a text node.
+
+import { api, apiUrl, ApiError, CREDENTIALS_MODE } from './apiClient';
+import type { ContextItem } from './chatContext';
+import { codePointLength, codePointPrefix } from './codePoints';
+import { canonicalPlanReferences, planDraftReferences, PlanReferenceError, type PlanReference } from './planReferences';
+import { diffWorkflowChanges, type WorkflowChange, type WorkflowChangeTarget } from './workflowChangeTracking';
+import { WORKFLOW_TASK_INSTRUCTIONS_LIMIT, type WorkflowDefinition } from './workflowEditor';
+import { isFlowRegion } from './workflowFlow';
+import { isRecord, sameEditorValue } from './workspaceAuthoring';
+
+export const WORKFLOW_ASSIST_PATH = '/api/user/workflows/assist';
+export const WORKFLOW_DRAFT_INSTRUCTIONS_PATH = '/api/workflows/draft-instructions';
+/** The server counts the instruction in code points, after its raw-text check. */
+export const WORKFLOW_ASSIST_INSTRUCTION_LIMIT = 2000;
+export const WORKFLOW_ASSIST_TURN_TEXT_LIMIT = 4000;
+/** Conversation items: ten whole exchanges, so a replay never starts with an orphan reply. */
+export const WORKFLOW_ASSIST_MAX_TURNS = 20;
+export const WORKFLOW_ASSIST_REFERENCE_LIMIT = 20;
+export const WORKFLOW_ASSIST_REPLY_LIMIT = 1500;
+/** The server stops at about 150 seconds; the browser waits a little longer for its answer. */
+export const WORKFLOW_ASSIST_DEADLINE_MS = 170_000;
+
+const IDENTIFIER_LIMIT = 256;
+const TIME_ZONE_LIMIT = 64;
+const MAX_DEPTH = 200;
+const REPLAY_LABELS = 12;
+const REPLAY_LABEL_LIMIT = 120;
+const SERVER_TEXT_LIMIT = 1000;
+const CHANGE_TEXT_LIMIT = 300;
+const CONTEXT_DOCUMENT_LIMIT = 200;
+const MAX_REPORTED_CHANGES = 500;
+const MAX_WARNINGS = 50;
+const MAX_RETRY_AFTER_SECONDS = 3600;
+
+// The characters the server refuses in text. Tab, LF and CR are allowed.
+const CONTROL_CHARACTERS = /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g;
+const LONE_SURROGATE = /[\ud800-\udbff](?![\udc00-\udfff])|(? limit ? `${codePointPrefix(text, limit - 1).trimEnd()}…` : text;
+}
+
+function replaySuffix(turn: WorkflowAssistReplayTurn): string {
+ const labels = turn.changes.map((label) => clipped(workflowAssistText(label), REPLAY_LABEL_LIMIT)).filter(Boolean);
+ const shown = labels.slice(0, REPLAY_LABELS);
+ const more = labels.length - shown.length;
+ const list = shown.length ? `: ${shown.join('; ')}${more > 0 ? `; and ${more} more` : ''}.` : '.';
+ switch (turn.state) {
+ case 'applied':
+ return `Changes applied${list}`;
+ case 'partly_undone':
+ return `Changes applied${list} The user later undid some of them.`;
+ case 'undone':
+ return `These changes were applied and later undone${list}`;
+ case 'pending':
+ return `These changes are waiting for the user's confirmation and are not applied yet${list}`;
+ case 'declined':
+ return 'No changes were applied.';
+ case 'earlier':
+ return `These changes were made in an earlier editing session and may not have been saved${list}`;
+ default:
+ return '';
+ }
+}
+
+function replayItem(role: 'user' | 'assistant', text: string, suffix = ''): WorkflowAssistConversationItem | null {
+ const body = workflowAssistText(text).trim();
+ const combined = suffix ? (body ? `${body}\n\n${suffix}` : suffix) : body;
+ const capped = codePointPrefix(combined, WORKFLOW_ASSIST_TURN_TEXT_LIMIT);
+ return capped.trim() ? { role, text: capped } : null;
+}
+
+/**
+ * The conversation a request replays: the last ten completed exchanges, oldest first.
+ *
+ * Only completed turns belong here; a failed or cancelled one never reaches this function. An
+ * assistant item is its reply plus what became of its changes, so the model knows what the draft
+ * it is sent already holds.
+ */
+export function workflowAssistConversation(
+ turns: readonly WorkflowAssistReplayTurn[],
+): WorkflowAssistConversationItem[] {
+ const items: WorkflowAssistConversationItem[] = [];
+ for (const turn of turns.slice(-(WORKFLOW_ASSIST_MAX_TURNS / 2))) {
+ const user = replayItem('user', turn.instruction);
+ const assistant = replayItem('assistant', turn.reply, replaySuffix(turn));
+ if (user) items.push(user);
+ if (assistant) items.push(assistant);
+ }
+ return items.slice(-WORKFLOW_ASSIST_MAX_TURNS);
+}
+
+/**
+ * The `#` documents a request carries. Nothing is dropped silently: a tag, a whole workspace or
+ * too many documents is refused with a message.
+ */
+export function workflowAssistReferences(draft: { contextItems: readonly ContextItem[] }): PlanReference[] {
+ if (draft.contextItems.some((item) => item.kind === 'tag')) {
+ throw new WorkflowAssistRequestError(TAGS_UNSUPPORTED_MESSAGE, 'tags_unsupported');
+ }
+ if (draft.contextItems.some((item) => item.kind !== 'document')) {
+ throw new WorkflowAssistRequestError(WORKSPACE_REFERENCE_MESSAGE);
+ }
+ try {
+ return canonicalPlanReferences(planDraftReferences(draft), WORKFLOW_ASSIST_REFERENCE_LIMIT);
+ } catch (problem) {
+ if (problem instanceof PlanReferenceError) {
+ throw new WorkflowAssistRequestError(
+ problem.code === 'reference_limit' ? REFERENCE_LIMIT_MESSAGE : REFERENCE_PICKER_MESSAGE,
+ problem.code,
+ );
+ }
+ throw problem;
+ }
+}
+
+/** Every flow block id in a structured draft, nested regions included. */
+export function workflowFlowNodeIds(draft: WorkflowDefinition): Set {
+ const ids = new Set();
+ const walk = (region: unknown, depth: number) => {
+ if (!isRecord(region) || !Array.isArray(region.nodes) || depth > 32) return;
+ for (const node of region.nodes) {
+ if (!isRecord(node)) continue;
+ if (typeof node.id === 'string' && node.id) ids.add(node.id);
+ for (const child of [node.then, node.else, node.body]) walk(child, depth + 1);
+ }
+ };
+ if (isFlowRegion(draft.flow)) walk(draft.flow, 0);
+ return ids;
+}
+
+/** Whether `focus` still names a task, or a flow block of a structured draft. */
+export function workflowAssistFocusValid(draft: WorkflowDefinition, focus: string | null): focus is string {
+ if (!focus) return false;
+ if (draft.tasks.some((task) => task.id === focus)) return true;
+ return draft.definition_version === 3 && workflowFlowNodeIds(draft).has(focus);
+}
+
+function validIdentifier(value: unknown): value is string {
+ return typeof value === 'string' && Boolean(value.trim()) && value.length <= IDENTIFIER_LIMIT
+ && !/[\x00-\x1f\x7f]/.test(value);
+}
+
+/** The browser's IANA time zone, or null when it has none. */
+export function browserTimeZone(): string | null {
+ try {
+ const zone = Intl.DateTimeFormat().resolvedOptions().timeZone;
+ return typeof zone === 'string' && zone ? zone : null;
+ } catch {
+ return null;
+ }
+}
+
+export interface WorkflowAssistRequestInput {
+ readonly submissionId: string;
+ readonly instruction: string;
+ /** The version the editor opened or last saved; its id decides whether there is a base. */
+ readonly baseline: WorkflowDefinition;
+ /** The session's current draft, the same object the answer is later compared with. */
+ readonly draft: WorkflowDefinition;
+ readonly contextItems: readonly ContextItem[];
+ readonly conversation: readonly WorkflowAssistReplayTurn[];
+ readonly focus: string | null;
+ readonly timeZone: string | null;
+ /** The schedule editor's time zone names; the zone is sent only when it is one of them. */
+ readonly timeZones: ReadonlySet | null;
+}
+
+/** Build 3b's request exactly. Throws `WorkflowAssistRequestError` for a request it would refuse. */
+export function buildWorkflowAssistRequest(input: WorkflowAssistRequestInput): WorkflowAssistRequest {
+ // The server refuses control characters rather than dropping them, so they go here.
+ const instruction = workflowAssistText(input.instruction).trim();
+ if (!instruction) {
+ throw new WorkflowAssistRequestError('Type what you want the assistant to do, then send it.');
+ }
+ if (codePointLength(instruction) > WORKFLOW_ASSIST_INSTRUCTION_LIMIT) {
+ throw new WorkflowAssistRequestError(
+ `Shorten the message to ${WORKFLOW_ASSIST_INSTRUCTION_LIMIT.toLocaleString()} characters or fewer.`,
+ );
+ }
+ const references = workflowAssistReferences({ contextItems: input.contextItems });
+ const workflowId = input.baseline.id;
+ let base: WorkflowAssistBase | null = null;
+ if (workflowId) {
+ const revision = input.baseline.definition_revision;
+ if (!validIdentifier(workflowId) || !validIdentifier(revision)) {
+ throw new WorkflowAssistRequestError(MISSING_REVISION_MESSAGE);
+ }
+ base = { workflow_id: workflowId, definition_revision: revision };
+ } else if (input.draft.id) {
+ // A new or proposal draft has no saved workflow behind it, so it must not claim one.
+ throw new WorkflowAssistRequestError(MISSING_REVISION_MESSAGE);
+ }
+ const zone = input.timeZone;
+ const sendZone = Boolean(zone && zone.length <= TIME_ZONE_LIMIT && input.timeZones?.has(zone));
+ return {
+ submission_id: input.submissionId,
+ base,
+ instruction,
+ conversation: workflowAssistConversation(input.conversation),
+ focus: workflowAssistFocusValid(input.draft, input.focus) ? input.focus : null,
+ ...(sendZone && zone ? { time_zone: zone } : {}),
+ draft: input.draft,
+ references,
+ };
+}
+
+// ---------------------------------------------------------------------------------------------
+// The answer
+// ---------------------------------------------------------------------------------------------
+
+export interface WorkflowAssistReportedChange {
+ readonly key: string;
+ readonly label: string;
+ readonly ownerLabel: string;
+ readonly summary: string;
+ readonly focusKey: string;
+ readonly nodeId?: string;
+}
+
+export interface WorkflowAssistWarning {
+ readonly code: string;
+ readonly message: string;
+ readonly focusKey?: string;
+ readonly nodeId?: string;
+}
+
+export interface WorkflowAssistResponse {
+ readonly submissionId: string;
+ readonly outcome: WorkflowAssistOutcome;
+ readonly reply: string;
+ readonly candidate: WorkflowDefinition | null;
+ readonly changes: readonly WorkflowAssistReportedChange[];
+ readonly warnings: readonly WorkflowAssistWarning[];
+ readonly contextDocuments: readonly string[];
+}
+
+function serverText(value: unknown, limit: number): string | null {
+ return typeof value === 'string' ? clipped(workflowAssistText(value), limit) : null;
+}
+
+function reportedTarget(value: unknown): { focusKey: string; nodeId?: string } | null {
+ if (!isRecord(value) || typeof value.focus_key !== 'string' || !value.focus_key
+ || value.focus_key.length > 1024) return null;
+ const nodeId = typeof value.node_id === 'string' && value.node_id && value.node_id.length <= IDENTIFIER_LIMIT
+ ? value.node_id : undefined;
+ return { focusKey: value.focus_key, ...(nodeId ? { nodeId } : {}) };
+}
+
+function reportedChange(value: unknown): WorkflowAssistReportedChange | null {
+ if (!isRecord(value) || typeof value.key !== 'string' || !value.key || value.key.length > 1024) return null;
+ const target = reportedTarget(value.target);
+ const label = serverText(value.label, CHANGE_TEXT_LIMIT);
+ const ownerLabel = serverText(value.owner_label, CHANGE_TEXT_LIMIT);
+ const summary = serverText(value.summary, CHANGE_TEXT_LIMIT);
+ if (!target || label === null || ownerLabel === null || summary === null) return null;
+ return { key: value.key, label, ownerLabel, summary, ...target };
+}
+
+function reportedWarning(value: unknown): WorkflowAssistWarning | null {
+ if (!isRecord(value) || typeof value.code !== 'string' || !/^[a-z0-9_]{1,64}$/.test(value.code)) return null;
+ const message = serverText(value.message, SERVER_TEXT_LIMIT);
+ if (!message) return null;
+ const target = value.target === undefined || value.target === null ? null : reportedTarget(value.target);
+ return { code: value.code, message, ...(target ?? {}) };
+}
+
+/**
+ * Check a 200 answer. Returns null for anything that is not exactly the contract: the wrong
+ * submission id, an unknown outcome, or a candidate present when the outcome is not `changed` (or
+ * missing when it is). Malformed change, warning and document entries are dropped, because they
+ * are display hints; the change list shown is re-derived from the candidate anyway.
+ */
+export function parseWorkflowAssistResponse(value: unknown, submissionId: string): WorkflowAssistResponse | null {
+ if (!isRecord(value) || value.submission_id !== submissionId) return null;
+ const outcome = value.outcome;
+ if (outcome !== 'changed' && outcome !== 'explained' && outcome !== 'question') return null;
+ if (typeof value.reply !== 'string') return null;
+ const candidate = value.candidate;
+ if (outcome === 'changed') {
+ if (!isRecord(candidate) || !Array.isArray(candidate.tasks)) return null;
+ } else if (candidate !== null && candidate !== undefined) {
+ return null;
+ }
+ if (!Array.isArray(value.changes) || !Array.isArray(value.warnings) || !Array.isArray(value.context_documents)) {
+ return null;
+ }
+ const reply = workflowAssistText(value.reply).trim();
+ return {
+ submissionId,
+ outcome,
+ reply: codePointLength(reply) > WORKFLOW_ASSIST_REPLY_LIMIT
+ ? `${codePointPrefix(reply, WORKFLOW_ASSIST_REPLY_LIMIT - 1)}…` : reply,
+ candidate: outcome === 'changed' ? candidate as WorkflowDefinition : null,
+ changes: value.changes.slice(0, MAX_REPORTED_CHANGES).map(reportedChange)
+ .filter((change): change is WorkflowAssistReportedChange => change !== null),
+ warnings: value.warnings.slice(0, MAX_WARNINGS).map(reportedWarning)
+ .filter((warning): warning is WorkflowAssistWarning => warning !== null),
+ contextDocuments: value.context_documents.slice(0, WORKFLOW_ASSIST_REFERENCE_LIMIT)
+ .map((label) => serverText(label, CONTEXT_DOCUMENT_LIMIT))
+ .filter((label): label is string => Boolean(label)),
+ };
+}
+
+// ---------------------------------------------------------------------------------------------
+// Failures
+// ---------------------------------------------------------------------------------------------
+
+export interface WorkflowAssistFailure {
+ readonly status: number;
+ readonly code: string;
+ readonly message: string;
+ /** Wait this long before sending again: a 429, or a throttled 503. */
+ readonly retryAfterSeconds: number | null;
+ /** The saved workflow changed after the editor opened; reloading it lets Ask AI work again. */
+ readonly reload: boolean;
+}
+
+const STATUS_FALLBACKS: Readonly> = {
+ 400: "The assistant couldn't use this request. Nothing was changed.",
+ 401: 'Your session expired. Sign in again to use Ask AI.',
+ 403: "Ask AI isn't available to you right now.",
+ 404: "This workflow couldn't be found. It may have been deleted.",
+ 409: 'The saved workflow changed after the editor opened. Your draft was kept. Reload the workflow to use the assistant.',
+ 413: 'This request is too large for the assistant. Ask for a smaller change, or start a new thread.',
+ 429: 'The assistant is busy. Wait a moment, then try again.',
+ 500: 'The assistant failed. Nothing was changed. Try again.',
+ 502: "The assistant's answer couldn't be used. Nothing was changed. Try again.",
+ 503: 'The assistant is unavailable right now. Nothing was changed. Try again later.',
+};
+
+function clampRetryAfter(seconds: number): number {
+ return Math.min(MAX_RETRY_AFTER_SECONDS, Math.max(1, Math.ceil(seconds)));
+}
+
+/** Seconds to wait, from a `Retry-After` header (seconds or an HTTP date), or else the body. */
+export function workflowAssistRetryAfter(header: string | null, body: unknown, now = Date.now()): number | null {
+ const text = (header ?? '').trim();
+ if (/^\d{1,10}$/.test(text)) return clampRetryAfter(Number(text));
+ if (text) {
+ const date = Date.parse(text);
+ if (!Number.isNaN(date)) return clampRetryAfter((date - now) / 1000);
+ }
+ const seconds = isRecord(body) ? body.retry_after_seconds : undefined;
+ return typeof seconds === 'number' && Number.isFinite(seconds) && seconds > 0 ? clampRetryAfter(seconds) : null;
+}
+
+/** A one-word `error`, such as the role check's "Forbidden", whose sentence is in `message`. */
+const ONE_WORD_ERROR = /^[A-Za-z][A-Za-z0-9_]*$/;
+
+/**
+ * What a failed request means for the reader. The server's `error` is a safe template (the rate
+ * limit's is an admin's text, possibly Markdown); it is shown as plain text either way.
+ */
+export function describeWorkflowAssistError(status: number, body: unknown, retryAfterHeader: string | null = null): WorkflowAssistFailure {
+ const code = isRecord(body) && typeof body.code === 'string' && /^[a-z0-9_]{1,64}$/.test(body.code) ? body.code : '';
+ const fallback = STATUS_FALLBACKS[status] ?? `The assistant request failed (status ${status}). Nothing was changed.`;
+ const error = isRecord(body) ? serverText(body.error, SERVER_TEXT_LIMIT) : null;
+ const sentence = isRecord(body) ? serverText(body.message, SERVER_TEXT_LIMIT) : null;
+ let message = (error && ONE_WORD_ERROR.test(error) && sentence ? sentence : error) || fallback;
+ if (code === 'workflow_deleted') message = `${message} Your draft is still in the editor.`;
+ const retryAfterSeconds = status === 429 || status === 503 ? workflowAssistRetryAfter(retryAfterHeader, body) : null;
+ return {
+ status,
+ code,
+ message,
+ retryAfterSeconds: status === 429 && retryAfterSeconds === null ? 1 : retryAfterSeconds,
+ reload: status === 409 && code === 'workflow_definition_conflict',
+ };
+}
+
+export type WorkflowAssistPostResult =
+ | { readonly ok: true; readonly response: WorkflowAssistResponse }
+ | { readonly ok: false; readonly aborted: true }
+ | { readonly ok: false; readonly aborted?: false; readonly failure: WorkflowAssistFailure };
+
+/**
+ * POST one turn. A cancel through `signal` is `aborted`; the browser deadline and a lost
+ * connection are failures the reader can retry.
+ */
+export async function postWorkflowAssist(
+ body: WorkflowAssistRequest,
+ signal: AbortSignal,
+ deadlineMs = WORKFLOW_ASSIST_DEADLINE_MS,
+): Promise {
+ const controller = new AbortController();
+ let timedOut = false;
+ const timer = setTimeout(() => {
+ timedOut = true;
+ controller.abort();
+ }, deadlineMs);
+ const stop = () => controller.abort();
+ if (signal.aborted) controller.abort();
+ else signal.addEventListener('abort', stop, { once: true });
+ try {
+ // A raw fetch rather than apiClient: ApiError drops the headers, and a throttled 503 carries
+ // its wait only in Retry-After.
+ const response = await fetch(apiUrl(WORKFLOW_ASSIST_PATH), {
+ method: 'POST',
+ credentials: CREDENTIALS_MODE,
+ signal: controller.signal,
+ headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ });
+ const text = await response.text();
+ let payload: unknown = null;
+ try {
+ payload = text ? JSON.parse(text) : null;
+ } catch {
+ payload = null;
+ }
+ if (signal.aborted) return { ok: false, aborted: true };
+ if (!response.ok) {
+ return { ok: false, failure: describeWorkflowAssistError(response.status, payload, response.headers.get('Retry-After')) };
+ }
+ const parsed = parseWorkflowAssistResponse(payload, body.submission_id);
+ return parsed ? { ok: true, response: parsed } : {
+ ok: false,
+ failure: { status: response.status, code: 'unreadable', message: UNREADABLE_ANSWER_MESSAGE, retryAfterSeconds: null, reload: false },
+ };
+ } catch {
+ if (signal.aborted) return { ok: false, aborted: true };
+ return {
+ ok: false,
+ failure: timedOut ? {
+ status: 0, code: 'browser_timeout', retryAfterSeconds: null, reload: false,
+ message: 'The assistant took too long to answer. Nothing was changed. Try again, or ask for a smaller change.',
+ } : {
+ status: 0, code: 'network_error', retryAfterSeconds: null, reload: false,
+ message: "Couldn't reach the assistant. Nothing was changed. Check your connection and try again.",
+ },
+ };
+ } finally {
+ clearTimeout(timer);
+ signal.removeEventListener('abort', stop);
+ }
+}
+
+// ---------------------------------------------------------------------------------------------
+// Applying the candidate
+// ---------------------------------------------------------------------------------------------
+
+/**
+ * Whether a live value and a value that went through JSON hold the same JSON. JSON drops
+ * `undefined` object keys and turns `undefined` array items and non-finite numbers into null.
+ */
+export function jsonEquivalent(live: unknown, json: unknown, depth = 0): boolean {
+ if (Object.is(live, json)) return true;
+ if (depth > MAX_DEPTH) return false;
+ if (live === undefined || typeof live === 'function' || typeof live === 'symbol') return json === null || json === undefined;
+ if (typeof live === 'number') return Number.isFinite(live) ? live === json : json === null;
+ if (Array.isArray(live)) {
+ return Array.isArray(json) && live.length === json.length
+ && live.every((item, index) => jsonEquivalent(item, json[index], depth + 1));
+ }
+ if (isRecord(live)) {
+ if (!isRecord(json)) return false;
+ const liveKeys = Object.keys(live).filter((key) => live[key] !== undefined && typeof live[key] !== 'function');
+ return liveKeys.length === Object.keys(json).length
+ && liveKeys.every((key) => Object.hasOwn(json, key) && jsonEquivalent(live[key], json[key], depth + 1));
+ }
+ return false;
+}
+
+function uniqueIds(items: readonly unknown[]): Map | null {
+ const byId = new Map();
+ for (const item of items) {
+ if (!isRecord(item) || typeof item.id !== 'string' || !item.id || byId.has(item.id)) return null;
+ byId.set(item.id, item);
+ }
+ return byId;
+}
+
+function setOwn(record: Record, key: string, value: unknown) {
+ Object.defineProperty(record, key, { value, enumerable: true, writable: true, configurable: true });
+}
+
+function rebase(live: unknown, candidate: unknown, depth: number): unknown {
+ if (jsonEquivalent(live, candidate)) return live;
+ if (depth > MAX_DEPTH) return candidate;
+ if (Array.isArray(live) && Array.isArray(candidate)) {
+ const liveById = uniqueIds(live);
+ if (liveById && uniqueIds(candidate)) {
+ return candidate.map((item) => {
+ const match = isRecord(item) ? liveById.get(item.id as string) : undefined;
+ return match === undefined ? item : rebase(match, item, depth + 1);
+ });
+ }
+ return candidate.map((item, index) => index < live.length ? rebase(live[index], item, depth + 1) : item);
+ }
+ if (isRecord(live) && isRecord(candidate)) {
+ const result: Record = {};
+ for (const key of Object.keys(live)) {
+ if (Object.hasOwn(candidate, key)) setOwn(result, key, rebase(live[key], candidate[key], depth + 1));
+ // JSON dropped an undefined key on the way out; it was not removed.
+ else if (live[key] === undefined) setOwn(result, key, undefined);
+ }
+ for (const key of Object.keys(candidate)) {
+ if (!Object.hasOwn(live, key)) setOwn(result, key, candidate[key]);
+ }
+ return result;
+ }
+ return candidate;
+}
+
+/**
+ * The candidate laid over the live draft: wherever the candidate holds the JSON the editor sent,
+ * the live value (and its identity) is kept, so `undefined` keys and untouched subtrees survive the
+ * round trip. Array items are matched by a unique `id`, otherwise by position.
+ */
+export function rebaseAssistCandidate(live: WorkflowDefinition, candidate: WorkflowDefinition): WorkflowDefinition {
+ return rebase(live, candidate, 0) as WorkflowDefinition;
+}
+
+/** Whether the live draft is still the one a turn was sent with. */
+export function assistDraftUnchanged(sent: WorkflowDefinition, live: WorkflowDefinition): boolean {
+ return sent === live || sameEditorValue(sent, live);
+}
+
+export interface WorkflowAssistVerifiedChange {
+ readonly key: string;
+ readonly summary: string;
+ /** The server reported this key too. */
+ readonly reported: boolean;
+ /** The editor's own change, with the target Jump to uses. */
+ readonly change: WorkflowChange;
+}
+
+export interface WorkflowAssistVerification {
+ readonly changes: readonly WorkflowAssistVerifiedChange[];
+ /** Keys the server reported that the editor's diff did not produce; never shown. */
+ readonly unverified: readonly string[];
+}
+
+/** A change worded the way the server words it: ": ", or ": ". */
+export function workflowAssistChangeSummary(change: WorkflowChange): string {
+ if (change.kind === 'added' || change.kind === 'removed') return `${change.label}: ${change.ownerLabel}`;
+ return `${change.ownerLabel}: ${change.label}`;
+}
+
+/**
+ * What a turn changed, decided by the editor's own diff of the draft it sent against the rebased
+ * candidate. The server's wording and flow block are used only for keys the diff also produced.
+ */
+export function verifyAssistChanges(
+ sent: WorkflowDefinition,
+ rebased: WorkflowDefinition,
+ reported: readonly WorkflowAssistReportedChange[],
+): WorkflowAssistVerification {
+ const diff = diffWorkflowChanges(sent, rebased);
+ const byKey = new Map(reported.map((change) => [change.key, change]));
+ const nodes = workflowFlowNodeIds(rebased);
+ const changes = diff.changes.map((change) => {
+ const report = byKey.get(change.key);
+ const nodeId = report?.nodeId && nodes.has(report.nodeId) ? report.nodeId : change.target.nodeId;
+ const target: WorkflowChangeTarget = { focusKey: change.target.focusKey, ...(nodeId ? { nodeId } : {}) };
+ return {
+ key: change.key,
+ summary: report?.summary || workflowAssistChangeSummary(change),
+ reported: Boolean(report),
+ change: { ...change, target },
+ };
+ });
+ return { changes, unverified: reported.filter((change) => !diff.byKey.has(change.key)).map((change) => change.key) };
+}
+
+// ---------------------------------------------------------------------------------------------
+// Turns
+// ---------------------------------------------------------------------------------------------
+
+const TURN_LABEL_LIMIT = 80;
+
+/** A turn's history label, such as "Ask AI: add a schedule", short enough for the Changes tab. */
+export function workflowAssistTurnLabel(prefix: string, text: string): string {
+ const body = clipped(workflowAssistText(text), TURN_LABEL_LIMIT);
+ return body ? `${prefix}: ${body}` : prefix;
+}
+
+/** What the editor did with a turn's candidate. `none`: the turn had nothing to apply. */
+export type WorkflowAssistApply = 'applied' | 'pending' | 'rejected' | 'noop' | 'none';
+
+/** The authoring session's `revertTurn` answer, as a turn's card reports it. */
+export type WorkflowAssistUndoResult =
+ | { readonly status: 'unavailable' | 'noop' }
+ | { readonly status: 'rejected'; readonly message: string }
+ | {
+ readonly status: 'applied' | 'confirmation_required';
+ readonly reverted: number;
+ readonly skipped: number;
+ readonly revertedKeys: readonly { key: string; label: string }[];
+ readonly skippedKeys: readonly { key: string; label: string; reason?: string }[];
+ };
+
+/** What one completed turn's card shows. The thread keeps the messages; this keeps the rest. */
+export interface WorkflowAssistTurnRecord {
+ readonly threadKey: string;
+ /** The editing session the turn was answered in: the identity of the version the editor opened. */
+ readonly epoch: string;
+ readonly instruction: string;
+ readonly outcome: WorkflowAssistOutcome;
+ readonly reply: string;
+ /** The editor's own diff of the draft it sent against the candidate. */
+ readonly changes: readonly WorkflowAssistVerifiedChange[];
+ readonly warnings: readonly WorkflowAssistWarning[];
+ readonly contextDocuments: readonly string[];
+ readonly apply: WorkflowAssistApply;
+ /** Why the editor refused the candidate, with `apply: 'rejected'`. */
+ readonly applyMessage?: string;
+ /** What the latest Undo did. */
+ readonly undo?: WorkflowAssistUndoResult;
+ /** Counts Undo presses, so the card can move focus to each new result. */
+ readonly undoSequence: number;
+}
+
+/** The parts of the authoring session a turn's state is read from. */
+export interface WorkflowAssistHistoryView {
+ readonly steps: readonly { readonly origin: string; readonly turnId?: string; readonly applied: boolean }[];
+ readonly attribution: ReadonlyMap;
+ readonly pending: { readonly kind: string; readonly action?: { readonly origin?: string; readonly turnId?: string } } | null;
+}
+
+/**
+ * Where a completed turn's changes stand now, read from the authoring history rather than
+ * remembered, so Undo and Redo are reflected. A per-field Revert is a step of its own, without
+ * the turn's id, so it leaves the turn applied.
+ */
+export function workflowAssistTurnState(
+ turnId: string,
+ record: WorkflowAssistTurnRecord | undefined,
+ view: WorkflowAssistHistoryView,
+ epoch: string,
+): WorkflowAssistReplayState {
+ if (!record || record.outcome !== 'changed' || !record.changes.length
+ || record.apply === 'none' || record.apply === 'noop') {
+ return 'none';
+ }
+ if (record.epoch !== epoch) return 'earlier';
+ const action = view.pending?.action;
+ if (action?.origin === 'ai' && action.turnId === turnId) return 'pending';
+ if (record.apply === 'rejected') return 'declined';
+ let seen = false;
+ let latest: { readonly origin: string } | null = null;
+ for (const step of view.steps) {
+ if (step.turnId !== turnId) continue;
+ seen = true;
+ if (step.applied) latest = step;
+ }
+ if (latest) {
+ if (latest.origin === 'ai') return 'applied';
+ const undo = record.undo;
+ return undo && (undo.status === 'applied' || undo.status === 'confirmation_required') && undo.skipped > 0
+ ? 'partly_undone' : 'undone';
+ }
+ if (seen) return 'undone';
+ // The history dropped the turn's step; the attribution still knows what it wrote.
+ for (const stamp of view.attribution.values()) {
+ if (stamp.turnId === turnId) return 'applied';
+ }
+ return record.apply === 'applied' ? 'undone' : 'declined';
+}
+
+/** A thread exchange, as much of it as a replay reads. */
+export interface WorkflowAssistReplayExchange {
+ readonly id: string;
+ readonly status: string;
+ readonly text: string;
+ readonly reply?: string;
+}
+
+/**
+ * The completed turns a request replays, oldest first. Failed, cancelled and pending exchanges
+ * never reach the server, and neither does the exchange being sent.
+ */
+export function workflowAssistReplay(
+ exchanges: readonly WorkflowAssistReplayExchange[],
+ records: (turnId: string) => WorkflowAssistTurnRecord | undefined,
+ view: WorkflowAssistHistoryView,
+ epoch: string,
+ excludeId?: string,
+): WorkflowAssistReplayTurn[] {
+ const turns: WorkflowAssistReplayTurn[] = [];
+ for (const exchange of exchanges) {
+ if (exchange.status !== 'done' || exchange.id === excludeId) continue;
+ const record = records(exchange.id);
+ const state = workflowAssistTurnState(exchange.id, record, view, epoch);
+ turns.push({
+ instruction: record?.instruction ?? exchange.text,
+ reply: record?.reply ?? exchange.reply ?? '',
+ state,
+ changes: state === 'none' || state === 'declined' || !record ? [] : record.changes.map((change) => change.summary),
+ });
+ }
+ return turns;
+}
+
+// ---------------------------------------------------------------------------------------------
+// Draft with AI
+// ---------------------------------------------------------------------------------------------
+
+/** The draft with one empty task's instructions filled in, or null when the task is gone or no longer empty. */
+export function withDraftedInstructions(draft: WorkflowDefinition, taskId: string, instructions: string): WorkflowDefinition | null {
+ const task = draft.tasks.find((item) => item.id === taskId);
+ if (!task || task.instructions.trim()) return null;
+ return { ...draft, tasks: draft.tasks.map((item) => item.id === taskId ? { ...item, instructions } : item) };
+}
+
+/** Ask the draft-instructions route for one task's instructions. Throws an Error with a plain-text message. */
+export async function requestDraftInstructions(
+ workflow: WorkflowDefinition,
+ taskName: string,
+ signal: AbortSignal,
+): Promise {
+ let data: unknown;
+ try {
+ data = await api.post(WORKFLOW_DRAFT_INSTRUCTIONS_PATH, {
+ workflow_scope: 'personal',
+ name: workflow.name,
+ description: workflow.description,
+ brief: taskName,
+ }, signal);
+ } catch (problem) {
+ if (signal.aborted) throw problem;
+ const message = problem instanceof ApiError && problem.message ? clipped(problem.message, SERVER_TEXT_LIMIT) : '';
+ throw new Error(message || "Couldn't draft instructions. Try again.");
+ }
+ const instructions = isRecord(data) && typeof data.instructions === 'string' ? data.instructions.trim() : '';
+ if (!instructions) throw new Error("The assistant didn't return instructions. Try again.");
+ if (instructions.length > WORKFLOW_TASK_INSTRUCTIONS_LIMIT) {
+ throw new Error(`The drafted instructions are longer than ${WORKFLOW_TASK_INSTRUCTIONS_LIMIT.toLocaleString()} characters, so they weren't added. Try again, or write them yourself.`);
+ }
+ return instructions;
+}
diff --git a/application/v2_ui/src/pages/workspace/WorkflowsSection.tsx b/application/v2_ui/src/pages/workspace/WorkflowsSection.tsx
index 7af9f28f4..e17d604a7 100644
--- a/application/v2_ui/src/pages/workspace/WorkflowsSection.tsx
+++ b/application/v2_ui/src/pages/workspace/WorkflowsSection.tsx
@@ -87,6 +87,11 @@ export function WorkflowsSection({
const [expandedId, setExpandedId] = useState(null);
const [historyRefreshToken, setHistoryRefreshToken] = useState(0);
const [editing, setEditing] = useState(null);
+ // Reloading the workflow opens a fresh editor on the saved version.
+ const [editorInstance, setEditorInstance] = useState(0);
+ // Opening, closing or saving an editor supersedes a reload still loading, so it cannot
+ // reopen a closed editor or replace another workflow's.
+ const editorRequest = useRef(0);
const [viewingFlow, setViewingFlow] = useState<{ workflowId: string; scopeKey: string } | null>(null);
const [options, setOptions] = useState(null);
const [optionsLoading, setOptionsLoading] = useState(false);
@@ -128,6 +133,7 @@ export function WorkflowsSection({
}, [items, query]);
const openEditor = useCallback(async (workflow: WorkflowDefinition | 'new') => {
+ editorRequest.current += 1;
if (interactionDisabled || (workflow === 'new' && !canCreate)) {
setOptionsError('You cannot create workflows with the current workspace access.');
return;
@@ -148,6 +154,33 @@ export function WorkflowsSection({
}
}, [scopeKey, interactionDisabled, canCreate]);
+ // Ask AI offers this after the saved workflow changed while the editor was open. It discards
+ // the draft and opens the saved version; a failure leaves the editor as it was.
+ const reloadEditing = async () => {
+ if (!editing || editing === 'new' || !editing.id) return;
+ const workflowId = editing.id;
+ const request = ++editorRequest.current;
+ const [workflows, freshOptions] = await Promise.all([
+ fetchScopedWorkflows(scope),
+ fetchWorkflowEditorOptions(scope),
+ ]);
+ if (request !== editorRequest.current) return;
+ setItems(workflows);
+ setOptions(freshOptions);
+ const fresh = workflows.find((item) => item.id === workflowId);
+ if (!fresh || fresh.active_run_id) {
+ setEditing(null);
+ setEditorDirty(false);
+ setOptionsError(fresh
+ ? 'This workflow has an active run. Cancel it or wait for it to finish before editing.'
+ : 'This workflow was deleted, so it could not be reloaded.');
+ return;
+ }
+ setEditorDirty(false);
+ setEditing(fresh);
+ setEditorInstance((count) => count + 1);
+ };
+
// Each navigation that names a workflow is acted on once: a document's provenance link, or a
// workflow alert's Open workflow while this list is already open. The router gives every
// navigation its own key, so following the same link again acts again, and nothing else that
@@ -420,18 +453,21 @@ export function WorkflowsSection({
onClose={() => setViewingFlow(null)} /> : null}
{editing && options ? (
{
+ editorRequest.current += 1;
setEditing(null);
setEditorDirty(false);
}}
onSaved={(workflow) => {
+ editorRequest.current += 1;
const index = items.findIndex((item) => item.id === workflow.id);
setItems(index < 0
? [workflow, ...items]
diff --git a/application/v2_ui/src/stores/assistThreadStore.ts b/application/v2_ui/src/stores/assistThreadStore.ts
index e86efb872..0a29926f2 100644
--- a/application/v2_ui/src/stores/assistThreadStore.ts
+++ b/application/v2_ui/src/stores/assistThreadStore.ts
@@ -141,16 +141,26 @@ export function capAbandonedIds(ids: string[]): string[] {
* Idle threads of other conversations go first: they hold nothing but an image editor's local
* transcript. Past the cap, the least recently touched threads with nothing in flight go too.
* A thread with a pending request is never dropped, because its answer still has to land.
+ *
+ * `held` threads belong to a mounted editor that asked to keep its thread, and are never dropped.
+ * Touching a held thread does not sweep other conversations either: an editor outside the chat,
+ * such as the workflow editor, must not clear the chat's transcripts behind it. `quiet` threads
+ * were held once, and touching one does not sweep either, because an answer can still land after
+ * its editor closed.
*/
export function pruneThreads(
threads: Record,
keepKey: string,
conversationId: string | null,
+ held?: ReadonlySet,
+ quiet?: ReadonlySet,
): Record {
+ const sweep = !held?.has(keepKey) && !quiet?.has(keepKey);
const kept: Record = {};
let removed = false;
for (const [key, record] of Object.entries(threads)) {
- if (key !== keepKey && record.conversationId !== conversationId && isThreadIdle(record)) {
+ if (sweep && key !== keepKey && !held?.has(key) && record.conversationId !== conversationId
+ && isThreadIdle(record)) {
removed = true;
} else {
kept[key] = record;
@@ -159,7 +169,7 @@ export function pruneThreads(
const keys = Object.keys(kept);
if (keys.length > MAX_THREADS) {
const evictable = keys
- .filter((key) => key !== keepKey && !hasPendingExchange(kept[key]))
+ .filter((key) => key !== keepKey && !held?.has(key) && !hasPendingExchange(kept[key]))
.sort((left, right) => kept[left].touchedAt - kept[right].touchedAt);
for (const key of evictable.slice(0, keys.length - MAX_THREADS)) {
delete kept[key];
@@ -169,6 +179,49 @@ export function pruneThreads(
return removed ? kept : threads;
}
+/** Mounted editors holding their thread, by key, with how many hold each. */
+const heldThreads = new Map();
+
+/** Threads an editor held, until they leave the store. Touching one never sweeps other conversations. */
+const quietThreads = new Set();
+
+/**
+ * Keep a thread while an editor is mounted. Returns the release, which is safe to call twice.
+ */
+export function holdAssistThread(key: string): () => void {
+ heldThreads.set(key, (heldThreads.get(key) ?? 0) + 1);
+ quietThreads.add(key);
+ let released = false;
+ return () => {
+ if (released) {
+ return;
+ }
+ released = true;
+ const count = (heldThreads.get(key) ?? 1) - 1;
+ if (count > 0) {
+ heldThreads.set(key, count);
+ return;
+ }
+ heldThreads.delete(key);
+ if (!(key in useAssistThreadStore.getState().threads)) {
+ quietThreads.delete(key);
+ }
+ };
+}
+
+function heldThreadKeys(): ReadonlySet | undefined {
+ return heldThreads.size ? new Set(heldThreads.keys()) : undefined;
+}
+
+/** Forget quiet threads that left the store and that no editor holds. */
+function forgetQuietThreads(threads: Record): void {
+ for (const key of quietThreads) {
+ if (!(key in threads) && !heldThreads.has(key)) {
+ quietThreads.delete(key);
+ }
+ }
+}
+
interface AssistThreadState {
threads: Record;
/**
@@ -202,11 +255,19 @@ export const useAssistThreadStore = create((set) => ({
return {};
}
const touched = { ...next, touchedAt: now };
- return { threads: pruneThreads({ ...state.threads, [key]: touched }, key, touched.conversationId) };
+ const threads = pruneThreads({ ...state.threads, [key]: touched }, key, touched.conversationId,
+ heldThreadKeys(), quietThreads.size ? quietThreads : undefined);
+ if (quietThreads.size) {
+ forgetQuietThreads(threads);
+ }
+ return { threads };
});
},
- resetThreads: () => set({ threads: {} }),
+ resetThreads: () => {
+ set({ threads: {} });
+ forgetQuietThreads({});
+ },
}));
export function selectAssistThread(
diff --git a/application/v2_ui/src/stores/workflowAssistStore.ts b/application/v2_ui/src/stores/workflowAssistStore.ts
new file mode 100644
index 000000000..a0915d264
--- /dev/null
+++ b/application/v2_ui/src/stores/workflowAssistStore.ts
@@ -0,0 +1,55 @@
+// workflowAssistStore.ts
+//
+// What the workflow editor's Ask AI tab remembers about its answers, beside the shared assist
+// thread that holds the messages: each completed turn's changes, warnings and Undo result, and
+// how long the assistant asked the browser to wait before sending again.
+//
+// Nothing is persisted. The v1 conversation is ephemeral, so a page reload forgets it all, like
+// the thread itself. Turn records are kept per submission id and capped; the wait is per user,
+// because the server's limits are, so it holds across every workflow the user edits.
+
+import { create } from 'zustand';
+import type { WorkflowAssistTurnRecord } from '../lib/workflowAssist';
+
+/** Completed turns remembered across every workflow editor on the page, oldest dropped first. */
+export const MAX_WORKFLOW_ASSIST_TURNS = 200;
+
+interface WorkflowAssistState {
+ turns: Readonly>;
+ /** Submission ids, oldest first. */
+ order: readonly string[];
+ /** Epoch milliseconds before which the assistant asked not to be sent another request. */
+ retryUntil: number;
+ record: (turnId: string, record: WorkflowAssistTurnRecord) => void;
+ update: (turnId: string, change: (record: WorkflowAssistTurnRecord) => WorkflowAssistTurnRecord) => void;
+ waitUntil: (time: number) => void;
+ /** Forget everything, for tests and sign-out. */
+ reset: () => void;
+}
+
+export const useWorkflowAssistStore = create((set) => ({
+ turns: {},
+ order: [],
+ retryUntil: 0,
+
+ record: (turnId, record) => set((state) => {
+ const order = [...state.order.filter((id) => id !== turnId), turnId];
+ const turns: Record = { ...state.turns, [turnId]: record };
+ while (order.length > MAX_WORKFLOW_ASSIST_TURNS) {
+ const dropped = order.shift();
+ if (dropped !== undefined) delete turns[dropped];
+ }
+ return { turns, order };
+ }),
+
+ update: (turnId, change) => set((state) => {
+ const current = state.turns[turnId];
+ if (!current) return {};
+ const next = change(current);
+ return next === current ? {} : { turns: { ...state.turns, [turnId]: next } };
+ }),
+
+ waitUntil: (time) => set((state) => (time > state.retryUntil ? { retryUntil: time } : {})),
+
+ reset: () => set({ turns: {}, order: [], retryUntil: 0 }),
+}));
diff --git a/docs/_data/features.yml b/docs/_data/features.yml
index 90bbe6817..1ce7f419d 100644
--- a/docs/_data/features.yml
+++ b/docs/_data/features.yml
@@ -431,7 +431,7 @@ workflow-ai-assistant:
name: "AI workflow assistant"
area: "Agents and actions"
audience: "End users"
- summary: "Proposes an edit to a personal workflow from a plain-language request, such as a new schedule, alert rules, tasks or attached documents, for the author to review before saving. The V2 editor's Ask AI tab that uses it arrives in a later release."
+ summary: "Changes a personal workflow from a plain-language request, such as a new schedule, alert rules, tasks or attached documents, through the V2 workflow editor's Ask AI tab. The changes are highlighted for the author to review before saving, and empty task instructions can be drafted with Draft with AI."
why: "Use this when authors know what a workflow should do but not which fields to set, for example \"run this at 7 AM on weekdays and only alert me when something is urgent\". It only proposes: the author reviews each change and saves through the normal checks, and it can't turn a workflow on, change Run as, sharing or approvals, or use documents, agents or models the author can't use."
default_state: "Enabled"
admin_tab: workflow
diff --git a/docs/admin/workflow.md b/docs/admin/workflow.md
index cc28bef61..58978621b 100644
--- a/docs/admin/workflow.md
+++ b/docs/admin/workflow.md
@@ -54,7 +54,7 @@ visible to its members. Turning one on does not turn on the other.
| --- | --- | --- | --- |
| Enable Personal Workflows | Lets users build workflows in their personal workspace and run them manually or on a schedule, either at a fixed interval or at a local time on a calendar schedule. | Off | `allow_user_workflows` |
| Require WorkflowUser App Role | Restricts personal workflows to holders of the `WorkflowUser` Enterprise App role. Covers opening, creating, editing, running and inspecting them, so assign the role before turning this on or every user loses access at once. | Off | `require_member_of_workflow_user` |
-| Enable AI Workflow Assistant | Lets people who can edit personal workflows describe a change in plain language, such as "run this at 7 AM on weekdays", and review the assistant's proposed edit in the V2 workflow editor before they save it. The assistant never saves, runs or shares a workflow, and it only uses documents the person can already open. | On | `enable_workflow_ai_assistant`; personal workflows only; has no effect while Enable Personal Workflows is off |
+| Enable AI Workflow Assistant | Lets people who can edit personal workflows use the V2 workflow editor's **Ask AI** tab: describe a change in plain language, such as "run this at 7 AM on weekdays", and review the assistant's edit before they save it. It also shows each task's **Draft with AI** button. The assistant never saves, runs or shares a workflow, and it only uses documents the person can already open. | On | `enable_workflow_ai_assistant`; personal workflows only; has no effect while Enable Personal Workflows is off |
| Enable Group Workflows | Lets permitted members create, manage and run workflows from group workspaces. Owners and Admins may author them unless Workspaces restricts group agent, action and workflow management to Owners. | Off | `allow_group_workflows` |
| Require Group Assignment to Use Workflow | Narrows group workflows to an explicit allow list instead of every group. Groups outside the list lose the capability. | Off | `require_group_assignment_for_group_workflows` |
| Assigned Groups | The groups that may use group workflows while assignment is required. Ignored when it is not. | Empty list | `group_workflow_allowed_group_ids` |
@@ -93,14 +93,18 @@ for the daylight saving rules and the stored format.
### AI workflow assistant {#workflow-ai-assistant}
-Version **0.261.208** adds the server side of the AI workflow assistant. A person
+Version **0.261.208** adds the server side of the AI workflow assistant, and
+version **0.261.213** adds the V2 workflow editor's **Ask AI** tab. A person
editing a personal workflow can ask for a change in plain language, such as "run
-this at 7 AM on weekdays and only alert me when something is urgent", and gets a
-proposed edit that the V2 editor shows as highlighted, revertible changes. The
-editor's **Ask AI** tab arrives in a later release.
+this at 7 AM on weekdays and only alert me when something is urgent", and the
+editor applies the proposed edit as highlighted changes they can revert one by
+one or take back as a whole.
The assistant is on by default wherever personal workflows are on, and it
-follows the same `WorkflowUser` role rule. Things to know before relying on it:
+follows the same `WorkflowUser` role rule. Turning it off also hides each task's
+**Draft with AI** button in the V2 editor. The classic workspace's **Draft
+Workflow Instructions** button doesn't follow this setting. Things to know
+before relying on it:
- **It proposes; it never saves.** Nothing changes until the person reviews the
edit and saves it through the normal save, with the usual checks and the Run
@@ -118,6 +122,9 @@ follows the same `WorkflowUser` role rule. Things to know before relying on it:
it's temporarily unavailable rather than running without a limit.
- **Its logs hold no content.** Telemetry records counts, outcomes and error
categories, never instructions, workflows, replies or document text.
+- **The conversation isn't kept.** The **Ask AI** conversation lives only in
+ the browser page. The server doesn't store it, and reloading the page starts
+ it over.
See [AI workflow assistant](../explanation/features/WORKFLOW_AI_ASSISTANT.md)
for the request contract, the changes it can propose, and its security model.
diff --git a/docs/explanation/features/V2_SHARED_ASSIST_THREAD.md b/docs/explanation/features/V2_SHARED_ASSIST_THREAD.md
index 18091824f..e1cd5a4e7 100644
--- a/docs/explanation/features/V2_SHARED_ASSIST_THREAD.md
+++ b/docs/explanation/features/V2_SHARED_ASSIST_THREAD.md
@@ -4,9 +4,12 @@ Implemented in version: **0.261.200**.
Document and tag references in the plan editor added in version: **0.261.201**.
+Options for the workflow editor's **Ask AI** tab added in version: **0.261.213**.
+
Application version tracking: `application\single_app\config.py`.
-Related issues: #1552 (the shared thread) and #1556 (`#` document references), part of #1543.
+Related issues: #1552 (the shared thread), #1556 (`#` document references) and #1548 (the
+workflow editor's **Ask AI** tab), part of #1543.
## Overview and dependencies
@@ -44,6 +47,12 @@ them for the person asking as the request arrives, adds them to that revision's
exactly once, and stores them on the user turn so the thread can show them. The other three
editors don't offer them. See **Document and tag references in the plan editor** below.
+From version **0.261.213**, the V2 workflow editor's **Ask AI** tab runs on the same thread. It
+needed a few things the four editors above don't, such as counting in code points and keeping its
+thread while the editor is open. Each one is an option that's off by default, so the four editors
+are unchanged. See **Options for the workflow editor** below, and
+[Workflow AI assistant](WORKFLOW_AI_ASSISTANT.md) for the tab itself.
+
Dependencies:
- The V2 interface in `application/v2_ui`. The classic interface has none of these editors and is
@@ -75,7 +84,11 @@ A thread is keyed by what it edits:
- `block::::` for a diagram or chart;
- `image::` for an image;
-- `plan::` for a plan.
+- `plan::` for a plan;
+- from 0.261.213, `workflow:personal:` for a saved personal workflow, or
+ `workflow:new:` for a new or proposal draft, with an id made when the workflow editor opens.
+ These threads use the conversation id `workflow-editor`, because the workflow editor isn't part
+ of a chat.
Closing an editor and opening it again returns to the same thread, including a request that is
still running, and any text you hadn't sent. Nothing in the store is saved: reloading the page
@@ -84,7 +97,8 @@ starts every thread empty.
The store drops threads nobody needs. When you use an editor, threads from other conversations
with nothing running, no failed or cancelled turn and no unsent text are dropped. Past 50 threads,
the least recently used go too. A thread with a request in flight is never dropped, because its
-answer still has to land.
+answer still has to land. From 0.261.213, a thread an open editor holds is never dropped either,
+and using it doesn't drop other conversations' threads. See **Held threads** below.
### Restricted input
@@ -102,6 +116,10 @@ all the plan route accepts: its search scope sets `workspacesEnabled: false`, wh
`searchContextCandidates` (as `includeWorkspaces`) and `DocumentPickerPopover` honour. The main
composer and the orchestration question card don't pass `restricted`, so they're unchanged.
+From 0.261.213 the workflow editor's **Ask AI** tab passes `allowContext` with
+`contextDocumentsOnly`, so its input offers documents only, without tags, because the workflow
+assist route refuses tags.
+
### Stored and local threads
The diagram, chart and plan editors already show the transcript the server stored. Their threads
@@ -229,9 +247,12 @@ Every editor accepts up to 2,000 characters, which matches the server:
| Diagram and chart | `MAX_INSTRUCTION_LENGTH` in `functions_block_revision_assist.py` |
| Image | `MAX_INSTRUCTION_LENGTH` in `functions_message_image_revisions.py` |
| Plan | `EDIT_INSTRUCTION_LIMIT` in `functions_orchestration_plan_revisions.py` |
+| Workflow (**Ask AI**, from 0.261.213) | `ASSIST_INSTRUCTION_MAX_LENGTH` in `functions_workflow_assist.py`, counted in code points |
The counter turns red past the limit and says how many characters to remove. The send button stays
-off until the message fits. A functional test fails if either side changes without the other.
+off until the message fits. A functional test fails if either side changes without the other; for
+the workflow editor that's `test_the_instruction_limit_matches_the_server` in
+`functional_tests/test_v2_workflow_ask_ai.py`.
### Accessibility
@@ -399,6 +420,69 @@ messages identical.
Document provenance, the origin ids and `workflow` tag added in 0.261.194, is metadata. It never
makes a document readable, and a document a workflow created is referenced like any other.
+### Options for the workflow editor
+
+Added in **0.261.213** (#1548). The V2 workflow editor's **Ask AI** tab runs on this thread. Its
+route counts characters differently, its server writes nothing, and the editor can open from a
+chat, so it needed a few options the other editors don't use. Each one is off by default, and the
+diagram, chart, image and plan editors don't pass any of them.
+
+The tab calls the hook like this (`application/v2_ui/src/components/workflows/useWorkflowAssist.ts`):
+
+```ts
+useAssistThread({
+ key, // workflow:personal: or workflow:new:
+ conversationId: 'workflow-editor',
+ mode: 'local',
+ maxLength: WORKFLOW_ASSIST_INSTRUCTION_LIMIT, // 2,000
+ send,
+ countCodePoints: true,
+ retain: true,
+});
+```
+
+| Option | Where | Default | What it does |
+|---|---|---|---|
+| `countCodePoints` | `useAssistThread` and `AssistSubmitOptions` | Off | Measures `maxLength` in Unicode code points rather than UTF-16 units, the way the workflow assist route measures `ASSIST_INSTRUCTION_MAX_LENGTH`, so an emoji counts once and the counter agrees with the server. `assistTextLength` and `describeDraftProblem` take the same flag. The helpers are in `lib/codePoints.ts`, which counts a lone surrogate as one, as Python does. |
+| `retain` | `useAssistThread` | Off | Holds the thread while the editor is mounted, through `holdAssistThread(key)`. See **Held threads** below. |
+| `text` | `AssistSubmitOptions` | Unset | Sends that text as a message of its own and leaves the input as it is. The controller's new `sendText(text)` uses it for the tab's quick actions. |
+| `cancelledMessage` | `AssistThread` | The default note | Replaces "Cancelled. The change may still be applied if the server had already started it." on a cancelled turn. It's only for an editor whose server writes nothing, so a cancel is safe. The workflow tab passes "Cancelled. Nothing was changed." |
+| `contextDocumentsOnly` | `AssistThread` and `ComposerEditor` | Off | With `allowContext`, offers `#` documents only: no tags and no workspaces, because the workflow assist route refuses tags. It sets `documentsOnly` on the search scope, which `useContextSuggestions` and `DocumentPickerPopover` honour, and the picker's search box reads "Search documents…". |
+
+Two smaller changes go with them:
+
+- `renderReply` already existed, and no editor passed it. With it, the reply is now wrapped in a
+ `div` with `break-words` instead of the default paragraph with `whitespace-pre-wrap`, so an
+ editor can render a card with blocks of its own. Without it, the default paragraph is unchanged.
+ The workflow tab's card puts the model's reply, warnings and errors in as text, never as HTML or
+ Markdown.
+- `ComposerEditor` sets `data-composer-menu-open` on its holder while its `#` list or another menu
+ is open. In the workflow editor, Escape inside the side panel normally closes the panel. The
+ dialog reads this attribute, and the **Add context** picker, so that Escape closes only the open
+ list or picker and focus stays in the **Ask AI** input.
+
+#### Held threads
+
+Using a thread sweeps idle threads of other conversations. The chat editors all belong to a chat,
+so the sweep only clears what you left behind in other chats. The workflow editor belongs to none:
+its threads use the conversation id `workflow-editor`. Without a hold, using **Ask AI** would drop
+the idle threads of the chat you were in, such as an image editor's local transcript. The workflow
+proposal card's **Edit** opens the workflow editor from a chat, so this is a real case.
+
+`holdAssistThread(key)` in `assistThreadStore.ts` keeps a thread while its editor is mounted:
+
+- A held thread is never dropped, by the sweep or by the 50-thread cap.
+- Using a held thread doesn't sweep other conversations' threads.
+- Holds are counted per key. The release function it returns is safe to call twice.
+- A key that was held stays *quiet* after it's released, until its thread leaves the store. Using
+ a quiet thread doesn't sweep either, so an answer that lands after the editor closed leaves the
+ chat's threads alone.
+- With nothing held, `pruneThreads` behaves exactly as before.
+
+Closing the workflow editor stops waiting for a request that's still running. The workflow assist
+route writes nothing, so nothing is left half done. The hook cancels the request before it releases
+its hold, so the cancel doesn't sweep either.
+
### Files
Server:
@@ -444,6 +528,20 @@ Added for document and tag references in 0.261.201:
`application/v2_ui/src/lib/contextMentions.ts`, `assistThread.ts`, `orchestration.ts`,
`orchestrationController.ts` and `planSubmissionIds.ts`.
+Added for the workflow editor's **Ask AI** tab in 0.261.213:
+
+- `application/v2_ui/src/lib/codePoints.ts` (new): code point length and a prefix that never
+ splits a surrogate pair.
+- `application/v2_ui/src/lib/assistThread.ts`: `countCodePoints`, `retain`, `text` and `sendText`.
+- `application/v2_ui/src/stores/assistThreadStore.ts`: held and quiet threads, and
+ `holdAssistThread`.
+- `application/v2_ui/src/components/chat/AssistThread.tsx`: `cancelledMessage`,
+ `contextDocumentsOnly` and the `renderReply` wrapper.
+- `application/v2_ui/src/components/chat/ComposerEditor.tsx`, `ContextMenu.tsx` and
+ `DocumentPickerPopover.tsx`: documents-only search and `data-composer-menu-open`.
+
+The tab itself, and its files, are described in [Workflow AI assistant](WORKFLOW_AI_ASSISTANT.md).
+
## Usage
### Enable or configure
@@ -526,6 +624,8 @@ In **Ask planner**, while the `#` list is open:
| `functional_tests/test_assist_reference_canonicalization.py` | The server's canonical form against `fixtures/plan_reference_canonicalization.json`, the request identity it gives, `references` allowed only on `ask`, and the bounded chips and notice on stored turns. |
| `functional_tests/test_v2_plan_editor_references.py` | Only the plan editor offers references, documents and tags only, canonical references on the request, the browser's limits, chips as text, chips never lost, Escape, and no remote assets. It runs `test_v2_plan_references_logic.ts`, which checks the browser's canonical form against the same fixture, the submission ids a changed selection goes out under, and the notice text. |
| `ui_tests/test_v2_plan_editor_references.py` | In a browser with stubbed routes: `#` by keyboard to a chip, a chip in the thread and a plan that reads it; **Add context** with group documents and tags, and Escape keeping the editor open; a removed chip not sent; the refusal with **Edit and resend**; chips coming back; titles rendered as text; a lost reply retried under the same id; the main composer and question card keeping their tools; and no `#` in the diagram, chart and image editors. |
+| `functional_tests/test_v2_workflow_ask_ai.py` | The workflow editor's options, from 0.261.213: each defaults to off, and the diagram, chart, image and plan editors pass none of them. The tab's limit matches `ASSIST_INSTRUCTION_MAX_LENGTH` and is counted in code points. It runs `test_v2_workflow_ask_ai_logic.ts`, which checks the limit in code points against the default count in UTF-16 units, a quick action sent as its own message with the input left alone, and held and quiet threads: a held thread, a released one whose answer lands late, one held twice, and pruning as before once nothing holds it. |
+| `ui_tests/test_v2_workflow_ask_ai.py` and `ui_tests/test_v2_workflow_ask_ai_proposal.py` | The workflow **Ask AI** tab in a browser, including the limit in code points, a `#` list with documents only, Escape closing only that list or the picker, the cancelled wording and quick actions. The proposal file opens the workflow editor from a chat with an idle image thread through the proposal card's **Edit**, sends a turn and closes the editor. The image thread is still there, and once the editor has let go the chat prunes as before. |
### Known limitations
@@ -539,8 +639,9 @@ In **Ask planner**, while the `#` list is open:
- Threads belong to one browser tab. Another tab, or a reload, doesn't see a request in flight.
Its stored result appears there when the conversation is reloaded, or through the live update in
a shared conversation.
-- `#` references are offered only in **Ask planner**. The diagram, chart and image editors don't
- offer them.
+- `#` references are offered only in **Ask planner** and, from 0.261.213, the workflow editor's
+ **Ask AI** tab, which offers documents only. The diagram, chart and image editors don't offer
+ them.
- A planner reply that changes nothing doesn't keep the documents and tags you sent. The chips go
back in the input, and sending again uses them.
- A revision can add documents and tags to a plan but not take them away. To search everything
diff --git a/docs/explanation/features/WORKFLOW_AI_ASSISTANT.md b/docs/explanation/features/WORKFLOW_AI_ASSISTANT.md
index fcdacfd70..538d5eb79 100644
--- a/docs/explanation/features/WORKFLOW_AI_ASSISTANT.md
+++ b/docs/explanation/features/WORKFLOW_AI_ASSISTANT.md
@@ -1,4 +1,4 @@
-# Workflow AI Assistant (v0.261.208)
+# Workflow AI Assistant (v0.261.213)
## Overview
@@ -8,13 +8,19 @@ plain language: "run this at 7 AM on weekdays and only alert me when something i
workflow editor shows it as highlighted, attributed, revertible changes that the person reviews
before saving. The assistant never saves anything.
-This release adds the server half: `POST /api/user/workflows/assist`. It takes one instruction and
-the editor's current draft, asks the model for a small set of allowlisted operations, applies them
-to a copy of the draft, checks the result the way a save would, and returns a candidate draft for
-the editor's `applyAssist` seam. The **Ask AI** tab that calls it comes in the next release.
+The server half is `POST /api/user/workflows/assist`, added in 0.261.208. It takes one
+instruction and the editor's current draft, asks the model for a small set of allowlisted
+operations, applies them to a copy of the draft, checks the result the way a save would, and
+returns a candidate draft. It writes nothing.
-Implemented in version: **0.261.208**, tracked in `application/single_app/config.py`.
-Phase 3b of the [chat orchestration workflows roadmap](CHAT_ORCHESTRATION_WORKFLOWS_ROADMAP.md)
+The editor half is the **Ask AI** tab, added in 0.261.213. It sends the instruction, checks the
+answer against the draft it was sent with, and applies the candidate through the editor's
+`applyAssist` seam, so every change is highlighted, attributed to the turn and undoable. The same
+release adds **Draft with AI**, which writes instructions for an empty task.
+
+Implemented in version: **0.261.208** (the endpoint) and **0.261.213** (the **Ask AI** tab and
+**Draft with AI**), tracked in `application/single_app/config.py`.
+Phases 3b and 3c of the [chat orchestration workflows roadmap](CHAT_ORCHESTRATION_WORKFLOWS_ROADMAP.md)
(#1548, part of #1543).
The assistant ships in three layers:
@@ -22,8 +28,8 @@ The assistant ships in three layers:
| Layer | What it adds | Status |
| --- | --- | --- |
| 3a | Change tracking in the V2 editor, including `WorkflowAuthoringSession.applyAssist(candidate, {turnId, label})` | Shipped in 0.261.203 ([Workflow editor change tracking](WORKFLOW_EDITOR_CHANGE_TRACKING.md)) |
-| 3b | This endpoint: instruction in, validated candidate out | This release |
-| 3c | The **Ask AI** tab on the shared assist thread, which calls this endpoint | Next |
+| 3b | The endpoint: instruction in, validated candidate out | Shipped in 0.261.208 |
+| 3c | The **Ask AI** tab on the shared assist thread, which calls the endpoint, and **Draft with AI** | Shipped in 0.261.213 |
Dependencies:
@@ -35,6 +41,10 @@ Dependencies:
(`load_workflow_reference`).
- The draft-instructions model deployment that `/api/workflows/draft-instructions` already uses.
No new model setting is added.
+- For the tab: Track A1's shared assist thread (`AssistThread`, `useAssistThread` and
+ `assistThreadStore`, see [V2 shared assist thread](V2_SHARED_ASSIST_THREAD.md)), Track A2's
+ `#` references in `lib/planReferences.ts`, and 3a's `applyAssist`, `revertTurn` and **Changes**
+ tab.
## What it does and doesn't do
@@ -89,8 +99,8 @@ setting on top:
| `enable_workflow_ai_assistant` | On | Users who can edit personal workflows can use the AI assistant in the V2 workflow editor. It has no effect while **Enable Personal Workflows** is off. |
The V2 bootstrap route (`/api/v2/bootstrap`) reports the per-user result as
-`features.enable_workflow_ai_assistant`, so 3c can hide the **Ask AI** tab when the assistant isn't
-available.
+`features.enable_workflow_ai_assistant`, and the editor hides the **Ask AI** tab and **Draft with
+AI** when it's false. See [When the tab is offered](#when-the-tab-is-offered).
### Request
@@ -460,6 +470,261 @@ only these fields: `user_id`, `submission_id`, `status`, `outcome`, `code`, `sta
`warning_codes`, `fault_location` (file and line, on a 500 only) and `duration_ms`. A body that
can't be read logs `[WorkflowAssist] Assist request refused` with its code, status and stage.
+### The Ask AI tab
+
+**Ask AI** is the second tab in the editor's side panel, after **Changes**. It runs on Track A1's
+shared assist thread, so it has the same turn list, **Cancel**, **Retry**, **Edit and resend** and
+`#` composer as the chat's image and plan editors. A turn that changes the draft goes through 3a's
+`applyAssist`, so its changes get the same highlights, **Previously:** values, per-field
+**Revert** and **Changes** list as your own edits, and saving asks you to review them first.
+
+| File | Role |
+| --- | --- |
+| `components/workflows/WorkflowAskAiTab.tsx` | The tab, its turn cards, the footer toggle and the lock banner |
+| `components/workflows/useWorkflowAssist.ts` | The tab's state: sending a turn, applying the answer, **Undo this change**, **Jump to** and **Draft with AI** |
+| `lib/workflowAssist.ts` | Pure logic: the request, replay, the response guard, error messages, the rebase and the change check |
+| `lib/codePoints.ts` | Counting and cutting text by code points, as the server counts |
+| `stores/workflowAssistStore.ts` | Turn cards and the rate-limit wait, in memory only |
+| `components/workflows/WorkflowEditorDialog.tsx` | Wiring: the gate, the tab, the toggle, the lock, Escape and focus |
+| `components/workflows/WorkflowTaskFields.tsx` | Each task's **Ask AI** and **Draft with AI** buttons |
+| `components/workflows/WorkflowChangeTracking.tsx` | A side panel whose selected tab the dialog controls, a class for each tab's panel, and a field key on every tracked field, there from its first render, before any highlight |
+| `components/workflows/useWorkflowAuthoring.ts` | `clearFocusRequest`, so a jump that places focus itself drops a block focus that a change requested |
+| `pages/workspace/WorkflowsSection.tsx` | Reopening a saved workflow after a conflict |
+
+Paths are under `application/v2_ui/src/`. The shared thread gained a few options for this tab, all
+off by default. See [V2 shared assist thread](V2_SHARED_ASSIST_THREAD.md#options-for-the-workflow-editor).
+
+### When the tab is offered
+
+The editor offers Ask AI only when all three of these hold:
+
+1. The bootstrap reports `features.enable_workflow_ai_assistant` as true for this user. That
+ follows the admin setting, personal workflows and the `WorkflowUser` role.
+2. The workflow is personal. Group workflows never offer it (roadmap decision 3).
+3. The editor can change the workflow. It can't when you don't have edit rights, when the workflow
+ has an active run, when access was lost while the editor was open, or when the workflow uses
+ something this editor can't change, such as an unsupported flow feature or a stored schedule it
+ can't show exactly.
+
+When it's offered, the footer shows an **Ask AI** toggle before **Changes**, each task has an
+**Ask AI** button, and a task with empty instructions has **Draft with AI**. When it isn't, the
+editor is as it was: the side panel has only **Changes**, and a read-only editor has no side panel.
+
+It also works on a workflow proposal opened from chat with **Edit** (Phase 4). That draft is a new
+personal workflow, so the tab sends it without a base, and there is no saved workflow to reload.
+
+### The thread
+
+A saved workflow has one thread, keyed `workflow:personal:`. A new workflow or a
+proposal draft gets a new key, `workflow:new:`, each time the editor opens, so it starts empty.
+Every thread uses the conversation ID `workflow-editor` and the thread's local mode, which keeps
+the last 20 finished exchanges.
+
+While the editor is open it holds its thread, so A1's pruning never drops it, and using it never
+sweeps away the chat's idle image and plan threads. Closing the editor releases the thread but
+leaves it in memory, so reopening a saved workflow in the same page shows the conversation. After
+that, A1's usual pruning applies: using an image or plan editor in chat drops idle threads of other
+conversations, and the store keeps at most 50 threads.
+
+The conversation is ephemeral in version 1 (roadmap §9). Nothing is stored on the server or in the
+browser's storage, so reloading the page starts over. The turn cards, which record what each turn
+changed and what its Undo did, live in `workflowAssistStore`, up to 200 across every editor in the
+page, oldest dropped first.
+
+### Sending a turn
+
+**Send**, a quick action, **Retry** and **Edit and resend** each post one request:
+
+| Field | What the tab sends |
+| --- | --- |
+| `submission_id` | A new ID for the turn |
+| `instruction` | The text, trimmed, without the control characters the server refuses (`[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]`). Tab, line feed and carriage return are kept. |
+| `draft` | The editor's draft as it stands |
+| `base` | `{workflow_id, definition_revision}` for a saved workflow. A new or proposal draft sends `null`, and must have no `id`. |
+| `conversation` | At most 20 `{role, text}` items: the last 10 completed exchanges, whole |
+| `references` | The `#` documents in the message, deduplicated and ordered by A2's `canonicalPlanReferences` |
+| `focus` | The task you asked about, as its task ID, or on a structured (v3) workflow as its flow block's ID. Sent only while that task still exists. |
+| `time_zone` | The browser's IANA time zone, when the schedule editor offers it, so "7 AM" means your 7 AM |
+
+The instruction is limited to 2,000 characters counted as code points, the way the server counts,
+so an emoji counts as one. The counter and the over-limit message use the same count.
+
+Only completed turns are replayed. Failed and cancelled turns never are, because the draft doesn't
+reflect them. A user item is the instruction as it was sent. An assistant item is the reply,
+followed by what became of its changes, read from the editor's history when the request is built:
+
+| The turn's changes | What the assistant item adds |
+| --- | --- |
+| Applied | "Changes applied: …" |
+| Partly undone | "Changes applied: … The user later undid some of them." |
+| Undone | "These changes were applied and later undone: …" |
+| Waiting for the impact confirmation | "These changes are waiting for the user's confirmation and are not applied yet: …" |
+| Not applied | "No changes were applied." |
+| From an earlier editing session | "These changes were made in an earlier editing session and may not have been saved: …" |
+
+The list names at most 12 changes, each cut to 120 characters, then "and N more". Each item is
+capped at 4,000 code points without splitting a surrogate pair, and an item that is blank after
+trimming is left out. This lets the model build on what the draft actually holds, rather than on
+what it proposed.
+
+References are checked before anything is sent, and nothing is dropped silently. More than 20
+documents is refused with "Attach at most 20 documents." The picker offers documents only, but a
+tag or a whole workspace can still arrive through **Edit and resend**, and is refused with a
+message, because a workflow reference is a document.
+
+While a turn runs, the editor is locked, because the answer is a whole draft built from the one
+that was sent. A banner reads "Ask AI is working. The editor is locked until it answers.", with the
+seconds elapsed and **Cancel request**. The fields, the flow canvas, Undo and Redo (including
+Ctrl+Z and Ctrl+Y) and **Save workflow** are disabled. Closing the editor cancels the turn. The
+browser gives up after 170 seconds; the server's own deadline is 150.
+
+### Applying the answer
+
+The tab first checks that nothing moved while it was working: the same editor, the same opened
+version, and a draft equal to the one it sent. If anything differs, nothing is applied, and the
+turn fails with "Nothing was applied because the draft changed while Ask AI was working. Send it
+again to use the current draft." **Retry** sends it against the current draft.
+
+On `changed`, three steps follow:
+
+1. **Rebase.** The candidate arrives as JSON, which has no `undefined` and no non-finite numbers.
+ The editor's draft can hold both; a proposal or saved draft, for example, has `id: undefined`
+ once the editor normalizes it. So the candidate is rebased on the live draft. Wherever it
+ equals the live value as JSON, the live value is kept, object identity included. Arrays are
+ matched by `id` when both sides have unique IDs, and by position otherwise. Keys the model
+ removed stay removed. Without this step, `applyAssist` would refuse every answer for a new
+ draft as a change to `id`, and unchanged objects would look edited.
+2. **Check.** The card lists only what the editor's own `diffWorkflowChanges` finds between the
+ draft that was sent and the rebased candidate. The server's summaries are used for keys the
+ diff also found, and its flow block only when that block exists in the result. A change the
+ server lists that the diff doesn't find isn't shown. When the diff finds nothing, the turn is
+ treated as an explanation.
+3. **Apply.** `applyAssist(candidate, {turnId, label: "Ask AI: "})` applies it as one
+ history step with origin `ai`. When the change removes blocks or affects other references, 3a's
+ impact confirmation asks first, and Send stays busy until you answer it. A candidate that
+ `applyAssist` refuses, such as one that changes a forbidden field, applies nothing, and the card
+ says why.
+
+On `explained` or `question`, the reply is shown and nothing changes. A `question` reply is
+labeled **Question**.
+
+### The turn card
+
+Each answered turn shows the reply as text. A turn that changed the draft adds a card, whose state
+is read from the editor's history rather than remembered, so the editor's Undo and Redo are
+reflected:
+
+| State | What the card says |
+| --- | --- |
+| Applied | "Changed N things in the draft. Review before saving." |
+| Partly undone | "Some of these changes were undone." |
+| Undone | "These changes were undone." |
+| Waiting for the impact confirmation | "Confirm to apply these changes." |
+| Not applied | "Nothing was applied: ", or "You chose not to apply these changes." |
+| From an earlier editing session | "Made in an earlier editing session." |
+
+A per-field **Revert** is a step of its own, so it leaves the card applied.
+
+- **Changes in this turn** lists each change. While they're applied or partly undone, **Jump to**
+ moves focus to the change, or to its field once a later Undo or Revert has put that field back,
+ and on the Flow surface it selects the changed block.
+- **Read as context** names the documents the assistant read.
+- **Warnings** lists the server's warnings, with **Jump to** when a warning names a field.
+- **Undo this change** calls 3a's `revertTurn`. It reverts each key the turn changed that still
+ holds what the turn left; a key changed afterwards is skipped. The result is shown and focused,
+ for example "Undone: 1 reverted, 1 skipped because it changed later.", "Nothing left to undo for
+ this turn." or "This turn can no longer be undone.", which is what a turn from an earlier
+ editing session gets. Undo is disabled while a turn runs.
+
+Saving closes the editor, so reopening a saved workflow starts a new editing session, and its
+earlier cards are marked as such. Their changes may have been saved, or discarded with Cancel.
+
+### When a turn fails
+
+A failed turn changes nothing. The tab shows the server's `error` as plain text. When `error` is a
+single code word and the body has a `message`, it shows the message. When there's neither, it
+shows a message for the status:
+
+| Status | Message |
+| --- | --- |
+| 400 | The assistant couldn't use this request. Nothing was changed. |
+| 401 | Your session expired. Sign in again to use Ask AI. |
+| 403 | Ask AI isn't available to you right now. |
+| 404 | This workflow couldn't be found. It may have been deleted. |
+| 409 | The saved workflow changed after the editor opened. Your draft was kept. Reload the workflow to use the assistant. |
+| 413 | This request is too large for the assistant. Ask for a smaller change, or start a new thread. |
+| 429 | The assistant is busy. Wait a moment, then try again. |
+| 500 | The assistant failed. Nothing was changed. Try again. |
+| 502 | The assistant's answer couldn't be used. Nothing was changed. Try again. |
+| 503 | The assistant is unavailable right now. Nothing was changed. Try again later. |
+
+- **A conflict.** A 409 `workflow_definition_conflict` means the saved workflow changed after the
+ editor opened. The draft is kept, and the tab offers **Reload workflow**. It asks first,
+ "Reloading discards your unsaved changes to this workflow.", and **Discard and reload** reopens
+ the editor on the saved version. When the workflow was deleted, the message adds "Your draft is
+ still in the editor."
+- **While a reload loads.** The draft it's about to discard is locked: the fields, **Save
+ workflow**, undo and redo (including Ctrl+Z and Ctrl+Y), the **Changes** tab, Send, the quick
+ actions and each card's **Undo this change**. **Cancel** and Escape still close the editor, so a
+ slow reload can't trap the user. A reload that finishes after its editor was closed, saved, or
+ replaced by another workflow's editor is ignored, so it can't reopen a closed editor or replace
+ one with unsaved changes. If the reload fails, the tab says "Couldn't reload the workflow. Try
+ again." and the draft unlocks as it was.
+- **A wait.** On 429 and 503, `Retry-After` is honored, as seconds or an HTTP date, or the body's
+ `retry_after_seconds`, clamped to an hour. Send, the quick actions and Retry wait, and the tab
+ shows "You can send again in N s." A 429 without either waits one second. The wait belongs to the
+ user, so every editor in the page shares it.
+- **The rate-limit message** is the admin's Markdown, and is shown as plain text.
+- **No answer in time.** After 170 seconds: "The assistant took too long to answer. Nothing was
+ changed. Try again, or ask for a smaller change."
+- **No connection.** "Couldn't reach the assistant. Nothing was changed. Check your connection and
+ try again."
+- **An unreadable answer**, such as a sign-in page after the session expired: "The assistant's
+ answer couldn't be read. Nothing was changed."
+- **Cancel** shows "Cancelled. Nothing was changed."
+
+The tab calls the route with `fetch` rather than the app's API client, because the client's errors
+don't carry `Retry-After`, and a throttled 503 has nothing else. It keeps the client's base URL,
+credentials mode and JSON headers.
+
+### Draft with AI
+
+**Draft with AI** appears on a task whose instructions are empty. It asks
+`/api/workflows/draft-instructions`, the route behind the classic editor's draft button, for
+instructions based on the workflow's name and description and the task's name. The answer is added
+as one undoable AI change labeled "Draft with AI: ", and focus moves to the instructions.
+
+- Focus goes straight to the drafted instructions, even when a slow device hasn't drawn the AI
+ highlight yet. On the Flow surface the block stays selected, and focus goes to its instructions
+ rather than to the block on the canvas.
+- It needs something to go on: a workflow name or description, or a task name other than the
+ default "Task N". Otherwise it asks you to name the workflow or the task first.
+- It doesn't lock the editor. When the task gained instructions or was removed while it worked,
+ nothing is added, and the message says so.
+- Instructions longer than the editor's 12,000-character limit aren't added; the message says so.
+- It's offered only where Ask AI is. The route checks personal workflow access but not
+ `enable_workflow_ai_assistant`, so hiding it with Ask AI keeps the setting's meaning simple.
+
+### Accessibility and plain text
+
+- **Plain text.** Every string from the model or the server, including replies, errors, warnings,
+ change summaries, document names and the rate-limit message, renders as React text, never as
+ HTML or Markdown. Line breaks are kept.
+- **Tabs.** The side panel is an ARIA tab list with automatic activation. The **Ask AI** toggle
+ opens the tab and focuses its input.
+- **Escape.** In an open `#` menu or document picker, Escape closes just that. Otherwise it closes
+ the panel and returns focus to the toggle that matches the tab.
+- **Announcements.** The thread is a polite live log, and a failed turn is an alert. The lock
+ banner isn't announced separately, because the log already announces the pending turn. Undo's
+ result takes focus, so it is read out.
+- **Focus.** When the lock ends and focus went with it, focus moves to the Ask AI input. Draft with
+ AI moves focus to the instructions it drafted, or back to its button when it adds nothing.
+- **Names.** Buttons name what they act on, such as "Ask AI about this task: Collect evidence",
+ "Jump to Workflow: Description" and "Undo this change: ". States are text, not
+ only color.
+- **Narrow screens.** The panel takes the editor's place, as **Changes** does, and the footer wraps
+ to two rows. Below the small breakpoint the toggle shows an icon with its name kept.
+
## Usage
### Enable or disable the assistant
@@ -468,15 +733,31 @@ The assistant is on by default wherever personal workflows are on. An administra
off under **Admin Settings > Workflow > Enable AI Workflow Assistant**. See the
[workflow admin settings](../../admin/workflow.md).
-### How 3c calls it
-
-1. The **Ask AI** tab posts the instruction, the draft as it stands, the base the editor opened,
- the tab's recent completed turns, any `#` documents and the focused task.
-2. On `changed`, it passes `candidate` to `applyAssist(candidate, {turnId, label})` and shows
- `reply`, `changes` (with **Jump to**) and `warnings` on the turn's card.
-3. On `explained` or `question`, it shows `reply` and changes nothing.
-4. On 409, it keeps the draft and offers to reload the workflow. On 429, it waits for
- `Retry-After`. On any other error, it shows `error` as text, including the rate-limit message.
+### Use the Ask AI tab
+
+1. Open a personal workflow in the V2 editor, or choose **Edit** on a workflow proposal in chat.
+2. Choose **Ask AI** in the footer. To ask about one task, choose **Ask AI** on that task instead.
+ The tab shows **About:** with the task's name, and each turn is about that task until you clear
+ it.
+3. Type a change or a question, or choose a quick action. Type `#` to pick one of your documents;
+ the assistant can read it as context or add it to the workflow.
+4. Wait for the answer. The editor is locked meanwhile, and **Cancel request** stops it.
+5. Review the card and the highlighted fields. **Jump to** takes you to each change.
+6. Keep what you want. **Revert** on a field puts back one change, **Undo this change** on the card
+ takes back the turn, and the editor's Undo works as usual.
+7. Save. Because the draft has AI changes, the first **Save workflow** opens **Review before
+ saving**, and **Confirm and save** saves.
+
+The quick actions send a fixed instruction, with no `#` documents, and leave what you've typed in
+the input alone:
+
+| Quick action | What it sends |
+| --- | --- |
+| Explain this workflow | "Explain what this workflow does, step by step. Don't change anything." |
+| Tighten task instructions | "Tighten each task's instructions so they are clear and specific, without changing what they ask for." |
+| Add a schedule | "Add a schedule that fits this workflow. Ask me if the timing isn't clear." |
+| Alert me only when urgent | "Change the alerts so I am alerted only when something is urgent." |
+| Check what's needed to run | "Check what this workflow still needs before it can run, such as missing fields or connections. Don't change anything." |
## Testing and validation
@@ -503,10 +784,54 @@ $env:PYTHONPATH = "application/single_app;functional_tests"
python -u -m pytest functional_tests/test_workflow_assist_scenarios.py -q
```
+### Ask AI tab tests
+
+The tab's tests connect the browser code to 3b's real code rather than to hand-written answers,
+so a change on either side that breaks the contract fails a test.
+
+| Test | Covers |
+| --- | --- |
+| `test_v2_workflow_ask_ai.py` | The tab renders model text as text and never as HTML, imports only local modules, is gated on the bootstrap flag, a personal workflow and a writable editor, and leaves the shared thread's new options off by default. Its instruction limit matches `ASSIST_INSTRUCTION_MAX_LENGTH` and is counted in code points. It runs `test_v2_workflow_ask_ai_logic.ts`. |
+| `test_v2_workflow_ask_ai_logic.ts` | Code-point counting and cutting, the request each kind of draft builds, replay (whole exchanges, the control characters, the cap after the suffix, earlier sessions and pending confirmations), each turn's state read from a real authoring session, the response guard and every failure message, the browser request with `Retry-After` and the deadline, the rebase and the change check, and the stores: the card cap, the rate-limit wait, the thread options and held-thread pruning, including a held thread that another conversation's sweep or the 50-thread cap would otherwise drop |
+| `test_v2_workflow_ask_ai_parity.py` | Requests the tab builds from editor-normalized drafts (new, proposal, saved version 2 with its revision, saved version 3, focus, time zone, references, a 20-item conversation with emoji, tabs and new lines, and a 2,000-code-point instruction) are each posted as their exact text through 3b's real Flask route with a scripted model. Every candidate-parity scenario is answered the same way. `test_v2_workflow_ask_ai_parity_logic.ts` then replays each real 200 body through the tab: the guard reads it, the rebased candidate passes `workflowAssistViolation`, and the changes it finds are exactly the server's `changes[].key`. |
+| `test_v2_assist_thread.py` | A1's and A2's checks, updated for the shared thread's new options |
+| `ui_tests/test_v2_workflow_ask_ai.py` | 32 browser cases: the "Done when" instruction from Send to Confirm and save, Run as warnings, each `#` placement and a document read as context, the lock with Cancel and Retry, Ctrl+Z, Ctrl+Y and Ctrl+Shift+Z waiting while a turn or a reload holds the draft, the stale-draft guard, the request a new, saved, focused or flow-focused draft sends, the code-point limit, Undo skipping a field changed later, **Jump to** a task field (including one the turn no longer changes, so it has no highlight) and a flow block, every failure and the 409 reload, a reload that's closed, superseded by another workflow's editor, or overtaken by a save while it loads, or that fails, hostile model text, where the tab is hidden (setting off, group, reader, unsupported, schedule), the keyboard and Escape, a narrow screen, Draft with AI on a List task and on a Flow block (each under 6x CPU throttling, with focus checked on the drafted instructions, again after a settle, and in a log showing nothing else took focus on the way), the quick actions, and a card from an earlier editing session |
+| `ui_tests/test_v2_workflow_ask_ai_proposal.py` | A proposal opened with **Edit** sends no base and saves through review, the tab is hidden when the assistant is off, and the editor's thread leaves the chat's idle image thread alone |
+
+The browser tests answer `POST /api/user/workflows/assist` inside the page with 3b's real
+`run_workflow_assist` and a scripted model, from `ui_tests/fixtures/workflow_ask_ai.py`. Build the
+V2 bundle first, then run each file on its own:
+
+```powershell
+npm --prefix application/v2_ui run build
+$env:PYTHONPATH = "application/single_app;functional_tests;ui_tests/fixtures"
+$env:PLAYWRIGHT_SERVICE_URL = ""
+python -u -m pytest ui_tests/test_v2_workflow_ask_ai.py -q
+```
+
## Known limitations and follow-ups
-- **Tags.** Workflow references hold documents, so a `#` tag is refused with `tags_unsupported`.
- 3c's picker should offer documents only.
+- **Personal workflows only.** Group workflows never offer Ask AI (roadmap decision 3).
+- **Tags and workspaces.** Workflow references hold documents, so the server refuses a `#` tag with
+ `tags_unsupported`. The tab's picker offers documents only. A tag or a whole workspace that still
+ reaches the input, for example through **Edit and resend**, fails the turn with a message before
+ anything is sent.
+- **Quick actions send no documents.** They send fixed text only. To use a document, type the
+ request and add it with `#`.
+- **A field Revert leaves the card applied.** Reverting one field is your own step, so the turn's
+ card still reads as applied, even when you've reverted every field it changed. **Undo this
+ change** then reverts what's left and leaves the reverted field alone. With nothing left, it says
+ "Nothing left to undo for this turn."
+- **Earlier editing sessions.** Change tracking starts fresh when the editor reopens, so a turn
+ from an earlier session can't be undone. Its card says **Made in an earlier editing session.**
+ and **Undo this change** reports "This turn can no longer be undone." Replay tells the assistant
+ those changes may not have been saved.
+- **Draft with AI isn't gated by the assistant setting on the server.**
+ `POST /api/workflows/draft-instructions` checks workflow access, not
+ `enable_workflow_ai_assistant`. The V2 editor hides **Draft with AI** whenever Ask AI is hidden,
+ but the classic personal and group workspace pages still offer **Draft Workflow Instructions**.
+- **Hidden by an active run or lost access.** These hide the tab through the same read-only check
+ as a reader's editor, which the browser tests cover, but neither has a browser case of its own.
- **File Sync operations.** Phase 4 (#1547) made personal File Sync editable, and the Python
`workflowForSave` port now saves an edited File Sync configuration as the editor does. The
assistant still has no File Sync operations and won't change a File Sync trigger. Adding them is
@@ -529,5 +854,6 @@ python -u -m pytest functional_tests/test_workflow_assist_scenarios.py -q
- **A global cap.** The limits are per user. A global in-flight cap, like Score's cap of 8, is a
possible follow-up; provider throttling already returns 503.
- **The rate-limit message.** `assistant_rate_limited` carries the app's shared rate-limit message,
- which may be Markdown. 3c should render it as plain text.
-- **The conversation isn't stored.** It's ephemeral in v1, as the roadmap decided.
+ which may be Markdown. The tab shows it as plain text, so any Markdown appears as written.
+- **The conversation isn't stored.** It's ephemeral in v1, as the roadmap decided. The server
+ keeps nothing, and reloading the page starts the thread and its cards over.
diff --git a/docs/explanation/features/WORKFLOW_EDITOR_CHANGE_TRACKING.md b/docs/explanation/features/WORKFLOW_EDITOR_CHANGE_TRACKING.md
index ca00530e7..4a5b4ebd6 100644
--- a/docs/explanation/features/WORKFLOW_EDITOR_CHANGE_TRACKING.md
+++ b/docs/explanation/features/WORKFLOW_EDITOR_CHANGE_TRACKING.md
@@ -39,8 +39,8 @@ A changed field is framed in its author's color and gets a text badge, so color
only cue:
- **Edited**, in violet, for your own changes.
-- **AI assist**, in blue, for changes an AI assist turn made. Nothing in this release produces
- one; the Ask AI tab in a later phase will.
+- **AI assist**, in blue, for changes an AI assist turn made. From 0.261.213 the **Ask AI** tab
+ and **Draft with AI** make them; see [Workflow AI assistant](WORKFLOW_AI_ASSISTANT.md).
Beside the badge, **Previously** opens the value the field had when the editor opened, as plain
text. Values longer than 280 characters are cut short behind **Show more**. **Revert** puts that
@@ -73,7 +73,9 @@ saved order; a task you added keeps its slot.
### The Changes panel
**Changes** in the editor footer opens a side panel and shows how many unsaved changes there are.
-The panel is built as a tab list, so later phases can add tabs beside **Changes**.
+The panel is built as a tab list, so later phases can add tabs beside **Changes**. From 0.261.213
+**Ask AI** is the second tab, on a personal workflow you can edit when the admin has turned the
+assistant on.
The Changes tab has two lists:
@@ -81,7 +83,9 @@ The Changes tab has two lists:
text, its author, **Jump**, and **Revert** (**Restore** for a removed item). Jump moves focus to
the changed field, opening collapsed sections as needed. On the Flow surface it selects the
changed block, and switches to List when the field isn't shown on Flow. On narrow screens it
- closes the panel first, so the field is visible.
+ closes the panel first, so the field is visible. A jump places focus itself, so it clears any
+ block focus that the step it follows requested; otherwise, on Flow, the canvas could take focus
+ back from the field after a **Draft with AI** change.
- **This session**: the steps in this editing session, newest first, each marked **Edited**,
**AI assist** or **Restore**, with its turn when it has one. **Restore to here** applies the
workflow as it was after that step, as a new step. Nothing is deleted: the steps after it stay
@@ -222,7 +226,8 @@ candidate as one history entry with origin `ai`. It rejects a candidate that:
- changes what an ID it keeps refers to
An accepted candidate takes the normal edit path: eligibility, impact confirmation, and the
-history budget's oversized-edit prompt. Nothing in this release calls it; the unit tests do.
+history budget's oversized-edit prompt. From 0.261.213 the **Ask AI** tab calls it with each
+answer, and **Draft with AI** calls it with drafted task instructions.
### The Run as consequence
@@ -237,18 +242,22 @@ fields. `test_workflow_run_as_fingerprint_parity.py` fails when the two lists dr
The diff never runs on the whole definition per keystroke. Indexes are cached per task list, flow
and definition object, and the diff and key deltas are memoized per pair of definitions. The
editor keeps every task object it did not change, so typing into one task compares only that
-task. The panel reads the diff one deferred render later, so a keystroke never waits for it. With
-100 tasks, a keystroke through the session measured 0.17 ms in the logic tests.
+task. The panel reads the diff one deferred render later, so a keystroke never waits for it. The
+highlights render in that deferred pass too, so every tracked field also carries its field key
+(`data-workflow-field-key`) from its first render, changed or not. Jump looks for the highlighted
+field, then the field key, then the changed item, so a jump made as a change lands, such as Draft
+with AI's, still reaches the field on a slow device. With 100 tasks, a keystroke through the
+session measured 0.17 ms in the logic tests.
## Seams for later phases
- **The assist endpoint (3b)** returns candidates; `applyAssist` is where they enter the editor.
It already enforces the forbidden fields and approvals client-side.
-- **The Ask AI tab (3c)** is a second entry in `WorkflowEditorSidePanel`'s tab list, beside
- Changes. Its per-turn "Revert this turn" calls `revertTurn` and reports the reverted and skipped
- counts it returns. Ask AI is personal-only; change tracking itself also covers group workflows.
-- The Confirm and save step appears once 3c produces AI-authored changes. Until then only the
- unit tests reach it.
+- **The Ask AI tab (3c)** shipped in 0.261.213 as the second entry in `WorkflowEditorSidePanel`'s
+ tab list, beside Changes. Each turn's **Undo this change** calls `revertTurn` and reports the
+ reverted and skipped counts it returns. Ask AI is personal-only; change tracking itself also
+ covers group workflows.
+- The Confirm and save step now appears when a save includes changes from Ask AI or Draft with AI.
## Files
@@ -273,8 +282,6 @@ task. The panel reads the diff one deferred render later, so a keystroke never w
strip and the Changes tab.
- A new workflow points out only AI assist changes, so your own edits are highlighted only after
the first save.
-- Nothing produces AI-authored changes until the Ask AI tab ships, so the Confirm and save step is
- covered by unit tests only.
- Change tracking lasts for one editing session. Saving, cancelling or reopening starts fresh.
## Testing and validation
@@ -297,6 +304,8 @@ task. The panel reads the diff one deferred render later, so a keystroke never w
can't show; a group workflow; light and dark themes; a narrow viewport;
a new workflow; and keyboard focus staying in a schedule, Run when, output contract or final
outputs control, with every typed character kept, while its highlight appears or clears.
+- From 0.261.213, `ui_tests/test_v2_workflow_ask_ai.py` reaches the Review before saving step
+ in a browser: an **Ask AI** answer is applied, and the save goes through **Confirm and save**.
## Related
diff --git a/docs/explanation/release_notes.md b/docs/explanation/release_notes.md
index 76d2ae6fd..554549239 100644
--- a/docs/explanation/release_notes.md
+++ b/docs/explanation/release_notes.md
@@ -2,6 +2,26 @@
For feature-focused and fix-focused drill-downs by version, see [Features by Version](https://github.com/microsoft/simplechat/tree/main/docs/explanation/features) and [Fixes by Version](https://github.com/microsoft/simplechat/tree/main/docs/explanation/fixes).
+### **(v0.261.213)**
+
+#### New Features
+
+* **Ask AI Tab In The V2 Workflow Editor**
+ * The V2 workflow editor's side panel has a new **Ask AI** tab, after **Changes**, that edits a personal workflow from a plain-language request, such as "run this at 7 AM on weekdays and only alert me when something is urgent". It calls the AI workflow assistant endpoint added in 0.261.208 and never saves. Each answer is applied to the draft as one AI change, with the same highlights, **Previously:** values, per-field **Revert** and **Changes** list as your own edits, and the first save asks you to review the AI changes.
+ * Open it with the footer's **Ask AI** toggle, or with a task's **Ask AI** button to ask about that task. Quick actions explain the workflow, tighten task instructions, add a schedule, alert only when urgent, or check what's needed to run. Type `#` to attach up to 20 documents; tags and whole workspaces aren't offered, because a workflow refers to documents. The browser's time zone is sent when the schedule editor offers it, and the 2,000-character limit is counted the way the server counts it, so an emoji counts as one.
+ * The editor is locked while a request runs, and **Cancel request** stops it with nothing changed. An answer that changes the draft gets a card listing what changed, taken from the editor's own comparison of the draft that was sent with the answer, with **Jump to**, the documents read as context, any warnings, and **Undo this change**, which reverts the turn except fields edited since. Follow-up requests include the earlier completed turns and what became of their changes.
+ * When the saved workflow changed after the editor opened, the draft is kept and **Reload workflow** is offered. `Retry-After` is honored with "You can send again in N s.", and a failed, cancelled or timed-out request changes nothing and can be retried against the current draft.
+ * The tab appears only when **Enable AI Workflow Assistant** is on, for a personal workflow the editor can change. Group workflows, read-only editors and workflows with an active run don't offer it. It also works on a workflow proposal opened from chat with **Edit**.
+ * Replies, errors, warnings, document names and the admin's rate-limit message are shown as plain text, never as HTML or Markdown. The side panel is an ARIA tab list, the thread is a live log, and Escape closes the panel and returns focus to its toggle.
+ * The conversation lives only in the page. Nothing is stored on the server or in the browser, so reloading starts over. Reopening a saved workflow in the same page shows its earlier turns, marked as coming from an earlier editing session.
+ * The shared assist thread gained opt-in options for the tab: counting by code points, holding its thread while the editor is open, a custom cancelled message, documents-only `#` references and a reply wrapper. All are off by default, so the chat's image, chart, diagram and plan editors are unchanged. Because the editor holds its thread, using it never clears a chat's idle image or plan editor conversation.
+ * (Ref: #1548, #1543, `WorkflowAskAiTab.tsx`, `useWorkflowAssist.ts`, `workflowAssist.ts`, `codePoints.ts`, `workflowAssistStore.ts`, `assistThreadStore.ts`, `WorkflowEditorDialog.tsx`, [AI Workflow Assistant](features/WORKFLOW_AI_ASSISTANT.md#the-ask-ai-tab), [V2 Shared Assist Thread](features/V2_SHARED_ASSIST_THREAD.md#options-for-the-workflow-editor), [Create a workflow](../guides/create-a-workflow.md#ask-ai-to-change-a-workflow))
+
+* **Draft With AI For Workflow Task Instructions**
+ * A task with empty instructions in the V2 workflow editor has **Draft with AI**. It drafts instructions from the workflow's name and description and the task's name, adds them as one undoable AI change, and moves focus to them. Instructions longer than the editor's 12,000-character limit aren't added, and the message says so.
+ * It uses the route behind the classic editor's **Draft Workflow Instructions** button, and it's offered only where **Ask AI** is. The classic button is unchanged.
+ * (Ref: #1548, `useWorkflowAssist.ts`, `WorkflowTaskFields.tsx`, `/api/workflows/draft-instructions`, [AI Workflow Assistant](features/WORKFLOW_AI_ASSISTANT.md#draft-with-ai))
+
### **(v0.261.212)**
#### New Features
diff --git a/docs/guides/create-a-workflow.md b/docs/guides/create-a-workflow.md
index 844f2c421..c801c0edd 100644
--- a/docs/guides/create-a-workflow.md
+++ b/docs/guides/create-a-workflow.md
@@ -411,8 +411,8 @@ personal and group workflows, in every format.
### See what changed
Each changed field is framed and labeled **Edited**. Changes made by AI assist
-are framed in blue and labeled **AI assist** instead; nothing in the editor
-makes them yet.
+are framed in blue and labeled **AI assist** instead. From **0.261.213**,
+[**Ask AI**](#ask-ai-to-change-a-workflow) and **Draft with AI** make them.
- **Previously** shows the value the field had when you opened the editor.
Long values are cut short, with **Show more**.
@@ -463,6 +463,89 @@ everything is new, so only AI assist changes are pointed out. After you convert
an ordered workflow to the structured format, changes are still listed, but
they can only be discarded together, with **Cancel**.
+## Ask AI to change a workflow
+
+Starting in **0.261.213**, the V2 editor's **Ask AI** tab changes a personal
+workflow from a plain-language request, such as "run this at 7 AM on weekdays
+and only alert me when something is urgent". Use it when you know what you want
+the workflow to do but not which fields hold it, or to make the same kind of
+change across many tasks. It never saves. Its changes are highlighted like your
+own, and you review them before you save.
+
+**Ask AI** appears when your administrator has turned the assistant on, the
+workflow is personal, and you can edit it. Group workflows don't offer it.
+
+### Ask for a change
+
+1. Open a personal workflow in the V2 editor, or choose **Edit** on a workflow
+ proposal in chat.
+2. Choose **Ask AI** in the editor footer. To ask about one task, choose
+ **Ask AI** on that task instead. The tab shows **About:** with the task's
+ name until you clear it.
+3. Type what you want and send it, or choose a quick action such as **Explain
+ this workflow** or **Add a schedule**.
+4. Wait for the answer. The editor is locked while Ask AI works, so the answer
+ fits the draft you sent. **Cancel request** stops it, and nothing changes.
+
+A message can be up to 2,000 characters, and an emoji counts as one. When your
+browser's time zone is one the schedule editor offers, Ask AI sends it, so
+"7 AM" means 7 AM where you are.
+
+{% include media.html src="guides/create-a-workflow-ask-ai.png"
+ alt="The V2 workflow editor with the Ask AI tab open beside it, showing a request to run the workflow at 7 AM on weekdays, the assistant's reply, and a card listing the schedule changes with Jump to and Undo this change, while the changed schedule fields are framed in blue and labeled AI assist."
+ title="Asking AI to change a workflow"
+ capture="Open a saved personal workflow in the V2 editor at a wide window size, choose Ask AI, and send 'Run this at 7 AM on weekdays'. Capture the answered turn with its card and the highlighted schedule fields. Use sample data." %}
+
+### Point it at a document
+
+Type `#` in your message to pick a document from a personal, group or public
+workspace you can use. Depending on what you ask, Ask AI reads it as
+background, adds it as a reference shared by every task or used by particular
+tasks, or makes it the document a task works on. For example, "Use #X to
+design the steps" only reads it, and "Summarize #X every Friday" adds it to
+that task. When it can't tell, it asks.
+
+You can attach up to 20 documents in one message. Tags and whole workspaces
+aren't offered here, because a workflow refers to documents.
+
+### Review what it changed
+
+An answer that changes the draft shows a card listing the changes, and the
+changed fields are framed in blue and labeled **AI assist**.
+
+- **Jump to** moves to a change. On the Flow surface it selects the block.
+- **Read as context** names the documents Ask AI read.
+- **Warnings** point out anything to check, such as a change that means saving
+ requires re-approving [Run as](#microsoft-365-run-as).
+- **Undo this change** takes back the whole answer. A field you changed again
+ afterwards keeps your value, and the card says it was skipped.
+- **Revert** on one field, and the editor's Undo, work as they do for your own
+ edits.
+
+An answer that explains or asks you something changes nothing. Ask your
+follow-up in the same thread: Ask AI sees the earlier turns, and which of their
+changes you kept or undid.
+
+Because the draft now has AI changes, the first **Save workflow** opens
+[**Review before saving**](#save-after-reviewing). Choose **Confirm and save**.
+
+### Draft task instructions
+
+A task with empty instructions has **Draft with AI**. It writes instructions
+from the workflow's name and description and the task's name, adds them as one
+change you can undo, and moves you to them. Give the workflow or the task a
+name first, so it has something to go on.
+
+### When Ask AI can't help
+
+- If someone saved the workflow after you opened it, Ask AI keeps your draft
+ and offers **Reload workflow**. Reloading discards your unsaved changes.
+- If you've sent a lot of requests, Ask AI tells you when you can send again.
+- A failed or cancelled request changes nothing. **Retry** sends it again with
+ the draft as it is now.
+- The conversation isn't saved with the workflow. Reopening a saved workflow
+ in the same page shows it again; reloading the page starts over.
+
## Preview the structure without changing execution
In **0.261.121**, choose **View Flow for ...** beside a saved structured
@@ -748,6 +831,8 @@ by a workflow**. See
| Save is refused with "This schedule runs more often than the administrator allows." | The new or changed interval is shorter than the administrator's minimum | Choose the interval the message names or longer, or use a calendar schedule. |
| A calendar schedule's time zone isn't accepted | The name isn't an exact IANA time zone that the server offers; names are case-sensitive | Choose a zone from the **Time zone** list, such as `America/New_York`. |
| The classic editor won't open a workflow | It uses a calendar schedule or advanced data flow, which the classic editor can't represent | Edit it in V2. Run and Cancel still work in the classic workspace. |
+| The V2 editor has no **Ask AI** tab | The assistant is turned off, the workflow belongs to a group, or the editor can't change the workflow, for example because it's running or you can only view it | Ask an admin to turn on **Enable AI Workflow Assistant**, or open a personal workflow you can edit. |
+| Ask AI says the saved workflow changed | Someone saved the workflow after you opened it | Choose **Reload workflow**. Reloading discards your unsaved changes, so copy anything you need first. |
## Related
diff --git a/functional_tests/test_v2_assist_thread.py b/functional_tests/test_v2_assist_thread.py
index 96f2370fe..74e0e7b32 100644
--- a/functional_tests/test_v2_assist_thread.py
+++ b/functional_tests/test_v2_assist_thread.py
@@ -132,7 +132,9 @@ def test_sending_does_not_wait_for_the_server():
submit = submit[: submit.find("\n}\n")]
assert "status: 'pending'" in submit
- assert "draft: blankDraft()" in submit
+ # A typed message clears the input. Ask AI's quick actions (#1548) send their own text and
+ # leave what the reader is typing in place.
+ assert "draft: ownText ? current.draft : blankDraft()" in submit
# The thread is updated first; the request only starts afterwards.
assert submit.find("updateThread(") < submit.find("runExchange("), (
"the input must clear before the request is sent"
@@ -202,8 +204,12 @@ def test_over_limit_input_is_refused_not_cut_short():
)
thread = _read(THREAD_TS)
- assert "draft.text.length > maxLength ? 'too_long'" in thread
- assert "overLimit: draft.text.length > maxLength" in thread
+ assert "assistTextLength(draft.text, countCodePoints) > maxLength ? 'too_long'" in thread
+ assert "overLimit: length > maxLength" in thread
+ # The limit counts UTF-16 units unless an editor opts in to code points, as Ask AI (#1548)
+ # does because its server counts that way.
+ assert "return countCodePoints ? codePointLength(text) : text.length;" in thread
+ assert "const countCodePoints = options.countCodePoints === true;" in thread
component = _read(THREAD_TSX)
assert "!thread.overLimit" in component, "Send must be refused while the input is over the limit"
diff --git a/functional_tests/test_v2_workflow_ask_ai.py b/functional_tests/test_v2_workflow_ask_ai.py
new file mode 100644
index 000000000..4b3e01591
--- /dev/null
+++ b/functional_tests/test_v2_workflow_ask_ai.py
@@ -0,0 +1,230 @@
+#!/usr/bin/env python3
+# test_v2_workflow_ask_ai.py
+"""
+Functional test for the Ask AI tab in the V2 workflow editor.
+Version: 0.261.213
+Implemented in: 0.261.213
+
+This test ensures the Ask AI tab builds exactly the request POST /api/user/workflows/assist
+accepts: limits counted in code points, base null and no id for a new or proposal draft, only
+completed turns replayed, documents only as references, and the focus and time zone only when
+they are valid. It also ensures nothing the server returns is trusted: an answer is checked
+field by field, the change list shown is the editor's own diff of the draft it sent against the
+candidate, every string stays plain text, and every status maps to a message and a next step.
+The candidate is laid over the live draft before it is applied, so untouched values keep their
+identity, and a turn's card reads its state from the authoring history. The shared assist
+thread's new options default to today's behaviour, and a thread the workflow editor held never
+sweeps the chat's idle threads.
+
+The behaviour lives in TypeScript, so the checks are bundled with esbuild and run under node by
+test_v2_workflow_ask_ai_logic.ts, following test_v2_workflow_change_tracking.py.
+"""
+
+import re
+import subprocess
+import sys
+from pathlib import Path
+
+REPO_ROOT = Path(__file__).resolve().parents[1]
+V2_DIR = REPO_ROOT / "application" / "v2_ui"
+V2_SRC = V2_DIR / "src"
+
+sys.path.insert(0, str(REPO_ROOT / "functional_tests"))
+
+from test_support.versioning import assert_app_version_at_least # noqa: E402
+
+IMPLEMENTED_IN = "0.261.213"
+
+CODE_POINTS_TS = V2_SRC / "lib" / "codePoints.ts"
+ASSIST_TS = V2_SRC / "lib" / "workflowAssist.ts"
+STORE_TS = V2_SRC / "stores" / "workflowAssistStore.ts"
+HOOK_TS = V2_SRC / "components" / "workflows" / "useWorkflowAssist.ts"
+TAB_TSX = V2_SRC / "components" / "workflows" / "WorkflowAskAiTab.tsx"
+DIALOG_TSX = V2_SRC / "components" / "workflows" / "WorkflowEditorDialog.tsx"
+THREAD_TSX = V2_SRC / "components" / "chat" / "AssistThread.tsx"
+THREAD_TS = V2_SRC / "lib" / "assistThread.ts"
+COMPOSER_TSX = V2_SRC / "components" / "chat" / "ComposerEditor.tsx"
+SERVER_ASSIST_PY = REPO_ROOT / "application" / "single_app" / "functions_workflow_assist.py"
+LOGIC_CHECK = Path(__file__).with_name("test_v2_workflow_ask_ai_logic.ts")
+
+# The four editors that used the shared thread before Ask AI. They must keep today's behaviour.
+CHAT_EDITORS = tuple(
+ V2_SRC / "components" / "chat" / name
+ for name in ("DiagramEditor.tsx", "ChartEditor.tsx", "ImageEditor.tsx", "OrchestrationPlanEditor.tsx")
+)
+THREAD_OPTIONS = (
+ "countCodePoints", "retain", "sendText", "cancelledMessage", "contextDocumentsOnly",
+ "documentsOnly", "renderReply", "holdAssistThread",
+)
+
+NEW_FILES = (CODE_POINTS_TS, ASSIST_TS, STORE_TS, HOOK_TS, TAB_TSX)
+
+IMPORT_RE = re.compile(r"^\s*import\s[^;]*?from\s+'([^']+)'", re.MULTILINE | re.DOTALL)
+
+ALLOWED_PACKAGES = {"react", "clsx", "lucide-react", "zustand"}
+
+# Ways model or server text could become markup. The reply, the error, the rate-limit message,
+# change labels, warnings and document labels are all rendered as React text nodes.
+HTML_SINKS = ("dangerouslySetInnerHTML", "innerHTML", "outerHTML", "insertAdjacentHTML", "document.write")
+MARKDOWN_RENDERERS = ("AssistantMarkdown", "ReactMarkdown", "react-markdown", "marked", "markdown-it", "DOMPurify")
+
+
+def test_version_is_at_least_the_implementing_release():
+ """The release that introduced the Ask AI tab, or a later one, is configured."""
+ assert_app_version_at_least(IMPLEMENTED_IN)
+ print(" ok the configured version includes the Ask AI tab")
+
+
+def test_ask_ai_imports_only_local_modules():
+ """The tab is local code: no remote asset and no package the app does not already ship."""
+ for path in NEW_FILES:
+ source = path.read_text(encoding="utf-8")
+ assert source.startswith(f"// {path.name}"), f"{path.name} must start with its filename comment"
+ for module in IMPORT_RE.findall(source):
+ assert module.startswith(".") or module in ALLOWED_PACKAGES, (
+ f"{path.name} imports {module}; Ask AI may only use local modules and the packages "
+ f"the app already ships ({', '.join(sorted(ALLOWED_PACKAGES))})"
+ )
+ assert "http://" not in source and "https://" not in source, f"{path.name} references a remote URL"
+ assert "import(" not in source, f"{path.name} must not load code dynamically"
+ print(" ok Ask AI imports only local modules and packages the app already ships")
+
+
+def test_model_and_server_text_render_as_plain_text():
+ """Nothing in the Ask AI tab turns model or server text into HTML or rendered Markdown."""
+ for path in NEW_FILES:
+ source = path.read_text(encoding="utf-8")
+ for sink in HTML_SINKS:
+ assert sink not in source, f"{path.name} uses {sink}; model and server text must stay plain text"
+ for renderer in MARKDOWN_RENDERERS:
+ assert not re.search(rf"\b{re.escape(renderer)}\b", source), (
+ f"{path.name} mentions {renderer}; the assistant's reply is plain text, not Markdown"
+ )
+ tab = TAB_TSX.read_text(encoding="utf-8")
+ assert "renderReply" in tab, "the tab must render the reply itself, through the thread's plain-text hook"
+ print(" ok replies, errors and labels render as plain text")
+
+
+def test_the_dialog_gates_ask_ai_on_the_bootstrap_flag():
+ """The tab reads the per-user flag the bootstrap sends, and is only for writable personal workflows."""
+ dialog = DIALOG_TSX.read_text(encoding="utf-8")
+ assert re.search(r"features\??\.enable_workflow_ai_assistant === true", dialog), (
+ "Ask AI must be gated on features.enable_workflow_ai_assistant alone"
+ )
+ gate = re.search(r"const askAiAvailable = ([^;]+);", dialog)
+ assert gate, "the dialog must decide once whether Ask AI is available"
+ condition = gate.group(1)
+ for part in ("askAiEnabled", "personal", "!readOnly"):
+ assert part in condition, f"Ask AI availability must include {part}"
+ print(" ok the dialog gates Ask AI on the bootstrap flag, a personal workflow and a writable editor")
+
+
+def test_the_shared_thread_options_default_off():
+ """Every option Ask AI added to the shared thread defaults to what the other editors do today."""
+ thread = THREAD_TSX.read_text(encoding="utf-8")
+ assert re.search(r"contextDocumentsOnly\s*=\s*false", thread), "contextDocumentsOnly must default to false"
+ assert "cancelledMessage" in thread, "the thread must accept the editor's own cancel wording"
+ composer = COMPOSER_TSX.read_text(encoding="utf-8")
+ assert re.search(r"contextDocumentsOnly\s*=\s*false", composer), (
+ "the composer must offer tags and documents unless an editor opts in to documents only"
+ )
+ logic = THREAD_TS.read_text(encoding="utf-8")
+ for option in ("retain", "countCodePoints"):
+ assert f"const {option} = options.{option} === true;" in logic, f"{option} must default to off"
+
+ for editor in CHAT_EDITORS:
+ source = editor.read_text(encoding="utf-8")
+ used = [option for option in THREAD_OPTIONS if re.search(rf"\b{option}\b", source)]
+ assert not used, f"{editor.name} must keep today's thread behaviour, but uses {used}"
+ print(" ok the shared thread's new options default to today's behaviour, and the chat editors pass none")
+
+
+def test_the_instruction_limit_matches_the_server():
+ """The tab refuses exactly what the route refuses: the same limit, counted in code points."""
+ server = re.search(r"^ASSIST_INSTRUCTION_MAX_LENGTH = (\d+)$",
+ SERVER_ASSIST_PY.read_text(encoding="utf-8"), re.MULTILINE)
+ client = re.search(r"^export const WORKFLOW_ASSIST_INSTRUCTION_LIMIT = (\d+);$",
+ ASSIST_TS.read_text(encoding="utf-8"), re.MULTILINE)
+ assert server and client, "both instruction limits must stay plain constants"
+ assert server.group(1) == client.group(1), (
+ f"the tab allows {client.group(1)} code points but the server allows {server.group(1)}"
+ )
+ hook = HOOK_TS.read_text(encoding="utf-8")
+ assert "maxLength: WORKFLOW_ASSIST_INSTRUCTION_LIMIT," in hook, "the thread must use the shared limit"
+ assert "countCodePoints: true," in hook, "the thread must count code points, as the server does"
+ print(f" ok the tab and the server both allow {server.group(1)} code points")
+
+
+def test_the_typescript_logic_checks_pass():
+ """Run the bundled behaviour checks, when the front-end toolchain is installed."""
+ assert LOGIC_CHECK.exists(), "the logic check file is missing"
+
+ if not (V2_DIR / "node_modules").exists():
+ print(" -- skipped the TypeScript checks: run npm ci in application/v2_ui")
+ return
+
+ bundle = V2_DIR / "node_modules" / ".cache-workflow-ask-ai-check.mjs"
+ try:
+ subprocess.run(
+ [
+ "npx",
+ "esbuild",
+ str(LOGIC_CHECK),
+ "--bundle",
+ "--platform=node",
+ "--format=esm",
+ "--packages=external",
+ f"--outfile={bundle}",
+ "--log-level=error",
+ ],
+ cwd=str(V2_DIR),
+ check=True,
+ shell=(sys.platform == "win32"),
+ timeout=300,
+ )
+ result = subprocess.run(
+ ["node", str(bundle)],
+ cwd=str(V2_DIR),
+ capture_output=True,
+ text=True,
+ encoding="utf-8",
+ errors="replace",
+ shell=(sys.platform == "win32"),
+ timeout=300,
+ )
+ finally:
+ if bundle.exists():
+ bundle.unlink()
+
+ if result.returncode != 0 or "FAIL " in result.stdout:
+ print(result.stdout)
+ print(result.stderr)
+ raise AssertionError("the TypeScript logic checks failed")
+
+ passed = result.stdout.count(" ok ")
+ assert passed >= 150, f"expected the full check suite, saw {passed} checks"
+ print(f" ok {passed} TypeScript logic checks passed")
+
+
+TESTS = [
+ test_version_is_at_least_the_implementing_release,
+ test_ask_ai_imports_only_local_modules,
+ test_model_and_server_text_render_as_plain_text,
+ test_the_dialog_gates_ask_ai_on_the_bootstrap_flag,
+ test_the_shared_thread_options_default_off,
+ test_the_instruction_limit_matches_the_server,
+ test_the_typescript_logic_checks_pass,
+]
+
+
+if __name__ == "__main__":
+ passed = 0
+ for test in TESTS:
+ try:
+ test()
+ passed += 1
+ except Exception as error: # noqa: BLE001 - report and continue to the next check
+ print(f"FAIL {test.__name__}: {error}")
+
+ print(f"\n{passed}/{len(TESTS)} checks passed")
+ sys.exit(0 if passed == len(TESTS) else 1)
diff --git a/functional_tests/test_v2_workflow_ask_ai_logic.ts b/functional_tests/test_v2_workflow_ask_ai_logic.ts
new file mode 100644
index 000000000..8285eb4b8
--- /dev/null
+++ b/functional_tests/test_v2_workflow_ask_ai_logic.ts
@@ -0,0 +1,912 @@
+// test_v2_workflow_ask_ai_logic.ts
+// Behavioural checks for the workflow editor's Ask AI tab: the request it builds, the answer it
+// accepts, the failures it explains, and how an answer is laid over the draft before it applies.
+//
+// Version: 0.261.213
+// Implemented in: 0.261.213
+//
+// The V2 interface has no unit test runner, so this follows test_v2_workflow_change_tracking_logic.ts:
+// bundled with the esbuild Vite already brings in, run under node by test_v2_workflow_ask_ai.py,
+// and skipped when the front-end toolchain is not installed.
+//
+// The properties worth most here: the request is exactly 3b's contract (strict keys, limits in
+// code points, no id on a new draft, only completed turns replayed); nothing the server says is
+// trusted (the change list is the editor's own diff, and every string stays plain text); and the
+// candidate is laid over the live draft, so untouched values keep their identity.
+
+import fixture from './fixtures/workflow_flow_authoring.json';
+import { codePointLength, codePointPrefix } from '../application/v2_ui/src/lib/codePoints';
+import {
+ assistTextLength,
+ describeDraftProblem,
+ makeSubmissionId,
+ submitAssistDraft,
+ type AssistSendRequest,
+ type AssistSendResult,
+} from '../application/v2_ui/src/lib/assistThread';
+import {
+ blankDraft,
+ holdAssistThread,
+ MAX_THREADS,
+ newThread,
+ pruneThreads,
+ useAssistThreadStore,
+ type AssistExchange,
+ type AssistThreadRecord,
+} from '../application/v2_ui/src/stores/assistThreadStore';
+import { MAX_WORKFLOW_ASSIST_TURNS, useWorkflowAssistStore } from '../application/v2_ui/src/stores/workflowAssistStore';
+import {
+ MISSING_REVISION_MESSAGE,
+ REFERENCE_LIMIT_MESSAGE,
+ TAGS_UNSUPPORTED_MESSAGE,
+ UNREADABLE_ANSWER_MESSAGE,
+ WORKFLOW_ASSIST_DEADLINE_MS,
+ WORKFLOW_ASSIST_PATH,
+ WORKFLOW_DRAFT_INSTRUCTIONS_PATH,
+ WORKSPACE_REFERENCE_MESSAGE,
+ WorkflowAssistRequestError,
+ assistDraftUnchanged,
+ buildWorkflowAssistRequest,
+ describeWorkflowAssistError,
+ jsonEquivalent,
+ parseWorkflowAssistResponse,
+ postWorkflowAssist,
+ rebaseAssistCandidate,
+ requestDraftInstructions,
+ verifyAssistChanges,
+ withDraftedInstructions,
+ workflowAssistChangeSummary,
+ workflowAssistConversation,
+ workflowAssistFocusValid,
+ workflowAssistReplay,
+ workflowAssistRetryAfter,
+ workflowAssistText,
+ workflowAssistTurnLabel,
+ workflowAssistTurnState,
+ type WorkflowAssistHistoryView,
+ type WorkflowAssistReplayState,
+ type WorkflowAssistReportedChange,
+ type WorkflowAssistRequestInput,
+ type WorkflowAssistTurnRecord,
+} from '../application/v2_ui/src/lib/workflowAssist';
+import { WorkflowAuthoringSession, workflowAssistViolation } from '../application/v2_ui/src/components/workflows/WorkflowAuthoringHistory';
+import { isWorkflowHistoryTurnId } from '../application/v2_ui/src/lib/workflowAuthoringHistory';
+import { diffWorkflowChanges, workflowTaskKey } from '../application/v2_ui/src/lib/workflowChangeTracking';
+import {
+ WORKFLOW_TASK_INSTRUCTIONS_LIMIT,
+ newWorkflowDefinition,
+ normalizeWorkflowDefinition,
+ type WorkflowDefinition,
+ type WorkflowEditorOptions,
+ type WorkflowScope,
+} from '../application/v2_ui/src/lib/workflowEditor';
+import type { ContextItem } from '../application/v2_ui/src/lib/chatContext';
+
+let failures = 0;
+function check(name: string, condition: boolean, detail?: unknown) {
+ if (condition) {
+ console.log(` ok ${name}`);
+ } else {
+ failures += 1;
+ console.log(`FAIL ${name}`, detail === undefined ? '' : JSON.stringify(detail, null, 1)?.slice(0, 2000));
+ }
+}
+
+type Row = Record;
+
+const PERSONAL: WorkflowScope = { type: 'personal' };
+const CONVERSATION = 'workflow-editor';
+const EMOJI = '\u{1F600}';
+const emoji = (count: number) => EMOJI.repeat(count);
+const LONE_SURROGATE = /[\ud800-\udbff](?![\udc00-\udfff])|(? ({
+ id: `t${index + 1}`, type: 'instructions', name: `Step ${index + 1}`, instructions: `Do step ${index + 1}.`,
+ order: index + 1, runner: { type: 'inherit' },
+ })),
+ }, PERSONAL);
+}
+
+/** A proposal draft as Phase 4 serves it: the server's own fields, the id among them, stripped. */
+function proposalDraft(): WorkflowDefinition {
+ const workflow: Row = structuredClone(fixture.initial);
+ for (const field of ['id', 'user_id', 'definition_revision']) delete workflow[field];
+ return normalizeWorkflowDefinition(workflow, PERSONAL);
+}
+
+const wire = (value: T): T => JSON.parse(JSON.stringify(value)) as T;
+
+function withTask(definition: WorkflowDefinition, id: string, patch: Row): WorkflowDefinition {
+ return { ...definition, tasks: definition.tasks.map((task) => task.id === id ? { ...task, ...patch } : task) };
+}
+
+function draftOf(text: string) {
+ return { ...blankDraft(), text };
+}
+
+function documentItem(id: string, label = `Document ${id}`, scope: Row = { kind: 'personal', id: null, name: 'Personal' }): ContextItem {
+ return {
+ key: `document:${scope.kind}:${scope.id ?? ''}:${id}`, kind: 'document', id, label, token: `#${label}`,
+ attachment: 'mention', scope, origin: 'picker',
+ } as unknown as ContextItem;
+}
+
+function tagItem(name: string): ContextItem {
+ return {
+ key: `tag:personal::${name}`, kind: 'tag', id: name, label: name, token: `#${name}`,
+ attachment: 'mention', scope: { kind: 'personal', id: null, name: 'Personal' }, origin: 'picker',
+ } as unknown as ContextItem;
+}
+
+function scopeItem(): ContextItem {
+ return {
+ key: 'scope:group:group-1', kind: 'scope', id: 'group-1', label: 'Team', token: '#Team',
+ attachment: 'mention', scope: { kind: 'group', id: 'group-1', name: 'Team' }, origin: 'picker',
+ } as unknown as ContextItem;
+}
+
+const ZONES: ReadonlySet = new Set(['America/New_York', 'UTC']);
+
+function requestInput(overrides: Partial = {}): WorkflowAssistRequestInput {
+ const draft = overrides.draft ?? classic();
+ return {
+ submissionId: 'submission-1',
+ instruction: 'Run this at 7 AM on weekdays and only alert me when something is urgent',
+ baseline: overrides.baseline ?? draft,
+ draft,
+ contextItems: [],
+ conversation: [],
+ focus: null,
+ timeZone: 'America/New_York',
+ timeZones: ZONES,
+ ...overrides,
+ };
+}
+
+function refusalOf(input: WorkflowAssistRequestInput): unknown {
+ try {
+ buildWorkflowAssistRequest(input);
+ return null;
+ } catch (problem) {
+ return problem;
+ }
+}
+
+function sameKeys(value: object, keys: readonly string[]): boolean {
+ return Object.keys(value).sort().join() === [...keys].sort().join();
+}
+
+// ---------------------------------------------------------------------------------------------
+// Code points
+// ---------------------------------------------------------------------------------------------
+
+function codePointChecks() {
+ check('an emoji is one code point and two UTF-16 units', codePointLength(EMOJI) === 1 && EMOJI.length === 2);
+ check('2,000 emoji are 2,000 code points and 4,000 units', codePointLength(emoji(2000)) === 2000 && emoji(2000).length === 4000);
+ check('a lone surrogate counts once, as the server counts it', codePointLength('\ud83d') === 1 && codePointLength('a\ude00b') === 3);
+ check('a code point prefix never splits a pair',
+ codePointPrefix(emoji(3), 2) === emoji(2) && codePointPrefix(`a${EMOJI}b`, 2) === `a${EMOJI}`);
+ check('the thread counts UTF-16 units unless asked for code points',
+ assistTextLength(emoji(2)) === 4 && assistTextLength(emoji(2), true) === 2);
+ check('by default the thread still refuses 1,001 emoji against 2,000',
+ describeDraftProblem(draftOf(emoji(1001)), 2000) === 'too_long');
+ check('counting code points, 2,000 emoji fit and 2,001 do not',
+ describeDraftProblem(draftOf(emoji(2000)), 2000, true) === null
+ && describeDraftProblem(draftOf(emoji(2001)), 2000, true) === 'too_long');
+ check('blank text is empty whichever way it is counted', describeDraftProblem(draftOf(' \n\t'), 2000, true) === 'empty');
+}
+
+// ---------------------------------------------------------------------------------------------
+// The request
+// ---------------------------------------------------------------------------------------------
+
+function requestChecks() {
+ const saved = classic();
+ const body = buildWorkflowAssistRequest(requestInput({ draft: saved, baseline: saved }));
+ check('a saved workflow sends its id and the revision the editor opened',
+ body.base?.workflow_id === 'classic-workflow' && body.base.definition_revision === 'rev-classic-1', body.base);
+ check('the draft sent is the session draft itself, so the answer is compared with it', body.draft === saved);
+ check('the request carries exactly the contract keys', sameKeys(body,
+ ['base', 'conversation', 'draft', 'focus', 'instruction', 'references', 'submission_id', 'time_zone']), Object.keys(body));
+ check('the submission id is sent as given', body.submission_id === 'submission-1');
+ check('the instruction is sent trimmed', body.instruction === requestInput().instruction);
+
+ const fresh = newWorkflowDefinition(PERSONAL);
+ const freshWire = wire(buildWorkflowAssistRequest(requestInput({ draft: fresh, baseline: fresh })));
+ check('a new draft sends base null and a draft without an id',
+ freshWire.base === null && !Object.hasOwn(freshWire.draft, 'id'), freshWire.draft);
+
+ const proposal = proposalDraft();
+ const proposalWire = wire(buildWorkflowAssistRequest(requestInput({ draft: proposal, baseline: proposal })));
+ check('a proposal draft sends base null and a draft without an id',
+ proposalWire.base === null && !Object.hasOwn(proposalWire.draft, 'id') && proposalWire.draft.definition_version === 3,
+ proposalWire.base);
+
+ const edited = { ...saved, name: 'Edited' };
+ const editedBody = buildWorkflowAssistRequest(requestInput({ draft: edited, baseline: saved }));
+ check('an edited saved workflow sends the current draft against the opened base',
+ editedBody.draft === edited && editedBody.base?.workflow_id === 'classic-workflow');
+
+ const noRevision = { ...saved, definition_revision: undefined };
+ const missing = refusalOf(requestInput({ draft: noRevision, baseline: noRevision }));
+ check('a saved workflow without a revision is refused before sending',
+ missing instanceof WorkflowAssistRequestError && missing.message === MISSING_REVISION_MESSAGE, String(missing));
+ const claimed = refusalOf(requestInput({ draft: saved, baseline: fresh }));
+ check('a draft with an id and no saved base is refused before sending',
+ claimed instanceof WorkflowAssistRequestError && claimed.message === MISSING_REVISION_MESSAGE, String(claimed));
+
+ // The time zone
+ const zone = (timeZone: string | null, timeZones: ReadonlySet | null) =>
+ buildWorkflowAssistRequest(requestInput({ timeZone, timeZones })).time_zone;
+ check('the browser time zone is sent when the schedule editor offers it', zone('America/New_York', ZONES) === 'America/New_York');
+ check('a zone the schedule editor does not offer is not sent',
+ zone('Mars/Olympus_Mons', ZONES) === undefined && !Object.hasOwn(buildWorkflowAssistRequest(requestInput({ timeZone: 'Mars/Base' })), 'time_zone'));
+ check('no zone is sent when the editor has no zone list or the browser has no zone',
+ zone('UTC', null) === undefined && zone(null, ZONES) === undefined);
+
+ // The focus
+ const focus = (draft: WorkflowDefinition, value: string | null) =>
+ buildWorkflowAssistRequest(requestInput({ draft, baseline: draft, focus: value })).focus;
+ const flow = structured();
+ check('a task focus is sent as the task id', focus(saved, 't2') === 't2');
+ check('a focus the draft no longer has is sent as null', focus(saved, 'gone-task') === null && focus(saved, null) === null);
+ check('a structured draft can focus a flow block, nested ones too',
+ focus(flow, 'seed-node') === 'seed-node' && focus(flow, 'yes-node') === 'yes-node' && focus(flow, 'report-task') === 'report-task');
+ check('a classic draft cannot focus a flow block', !workflowAssistFocusValid(saved, 'seed-node'));
+
+ // The instruction
+ const cleaned = buildWorkflowAssistRequest(requestInput({ instruction: ' Run\u0000 at 7\tAM\nplease\u0007\u007f ' }));
+ check('control characters are stripped and tab and newline kept', cleaned.instruction === 'Run at 7\tAM\nplease', cleaned.instruction);
+ const blank = refusalOf(requestInput({ instruction: ' \u0001\u0002 ' }));
+ check('an instruction that is blank once cleaned is refused', blank instanceof WorkflowAssistRequestError);
+ check('an instruction of exactly 2,000 emoji is sent whole',
+ buildWorkflowAssistRequest(requestInput({ instruction: emoji(2000) })).instruction === emoji(2000));
+ const tooLong = refusalOf(requestInput({ instruction: emoji(2001) }));
+ check('an instruction of 2,001 emoji is refused', tooLong instanceof WorkflowAssistRequestError
+ && tooLong.message.startsWith('Shorten the message'), String(tooLong));
+ check('a lone surrogate is replaced rather than sent',
+ workflowAssistText('a\ud83db') === 'a\ufffdb' && !LONE_SURROGATE.test(workflowAssistText('\ude00x\ud83d')));
+
+ // The references
+ const withRefs = (contextItems: ContextItem[]) => refusalOf(requestInput({ contextItems }));
+ const tag = withRefs([documentItem('doc-1'), tagItem('finance')]);
+ check('a # tag is refused with the tags message', tag instanceof WorkflowAssistRequestError
+ && tag.code === 'tags_unsupported' && tag.message === TAGS_UNSUPPORTED_MESSAGE, String(tag));
+ const scope = withRefs([scopeItem()]);
+ check('a whole workspace is refused', scope instanceof WorkflowAssistRequestError && scope.message === WORKSPACE_REFERENCE_MESSAGE);
+ const many = Array.from({ length: 21 }, (_, index) => documentItem(`doc-${String(index).padStart(2, '0')}`));
+ const limit = withRefs(many);
+ check('21 documents are refused with the limit', limit instanceof WorkflowAssistRequestError
+ && limit.code === 'reference_limit' && limit.message === REFERENCE_LIMIT_MESSAGE, String(limit));
+ const twenty = buildWorkflowAssistRequest(requestInput({ contextItems: many.slice(0, 20).reverse() })).references;
+ check('20 documents are sent, sorted, in the shape the server authorizes', twenty.length === 20
+ && twenty[0].id === 'doc-00' && twenty.every((item) => item.kind === 'document' && item.scope.kind === 'personal'
+ && item.scope.id === null && item.label?.startsWith('Document')), twenty.slice(0, 2));
+ const duplicated = buildWorkflowAssistRequest(requestInput({ contextItems: [documentItem('doc-1'), documentItem('doc-1', 'Again')] })).references;
+ check('a document picked twice is sent once, with its first label', duplicated.length === 1 && duplicated[0].label === 'Document doc-1');
+ const group = buildWorkflowAssistRequest(requestInput({
+ contextItems: [documentItem('doc-g', 'Guide', { kind: 'group', id: 'group-7', name: 'Team' })],
+ })).references;
+ check('a group document keeps its workspace', group[0]?.scope.kind === 'group' && group[0].scope.id === 'group-7');
+}
+
+// ---------------------------------------------------------------------------------------------
+// The conversation replayed
+// ---------------------------------------------------------------------------------------------
+
+function replayTurn(state: WorkflowAssistReplayState, changes: string[] = ['Schedule: Run at', 'Alerts: Alert mode'], reply = 'Done.') {
+ return { instruction: 'Do it', reply, state, changes };
+}
+
+function conversationChecks() {
+ const turns = Array.from({ length: 12 }, (_, index) => replayTurn('none', [], `Answer ${index + 1}`))
+ .map((turn, index) => ({ ...turn, instruction: `Ask ${index + 1}` }));
+ const items = workflowAssistConversation(turns);
+ check('a replay keeps the last ten exchanges as twenty items', items.length === 20
+ && items[0].text === 'Ask 3' && items[19].text === 'Answer 12', items.map((item) => item.text));
+ check('a replay starts with the user and alternates', items.every((item, index) => item.role === (index % 2 ? 'assistant' : 'user')));
+
+ const assistantText = (state: WorkflowAssistReplayState, changes?: string[]) =>
+ workflowAssistConversation([replayTurn(state, changes)])[1]?.text ?? '';
+ check('an applied turn replays its reply and the changes applied',
+ assistantText('applied') === 'Done.\n\nChanges applied: Schedule: Run at; Alerts: Alert mode.', assistantText('applied'));
+ check('a partly undone turn says some were undone', assistantText('partly_undone').endsWith('The user later undid some of them.'));
+ check('an undone turn says its changes were undone', assistantText('undone').startsWith('Done.\n\nThese changes were applied and later undone:'));
+ check('a turn awaiting confirmation says so', assistantText('pending').includes("waiting for the user's confirmation and are not applied yet"));
+ check('a declined turn says nothing was applied', assistantText('declined') === 'Done.\n\nNo changes were applied.');
+ check('a turn from an earlier session says it may not have been saved', assistantText('earlier').includes('earlier editing session'));
+ check('a turn with no change replays its reply alone', assistantText('none') === 'Done.');
+ const fifteen = Array.from({ length: 15 }, (_, index) => `Change ${index + 1}`);
+ check('a long change list is summarized', assistantText('applied', fifteen).endsWith('Change 12; and 3 more.'), assistantText('applied', fifteen));
+
+ const huge = workflowAssistConversation([replayTurn('applied', ['Name'], emoji(4000))])[1]?.text ?? '';
+ check('an assistant item is capped at 4,000 code points without splitting a pair',
+ codePointLength(huge) === 4000 && !LONE_SURROGATE.test(huge), codePointLength(huge));
+ check('a turn with no text is left out', workflowAssistConversation([{ instruction: ' ', reply: '', state: 'none', changes: [] }]).length === 0);
+ const controls = workflowAssistConversation([{ instruction: 'A\u0000B', reply: 'C\u0007D', state: 'none', changes: [] }]);
+ check('replayed text is cleaned like the instruction', controls[0].text === 'AB' && controls[1].text === 'CD');
+
+ const exchanges = [
+ { id: 'a', status: 'done', text: 'First', reply: 'One' },
+ { id: 'b', status: 'failed', text: 'Failed' },
+ { id: 'c', status: 'cancelled', text: 'Cancelled' },
+ { id: 'd', status: 'pending', text: 'Pending' },
+ { id: 'e', status: 'done', text: 'Resent', reply: 'Two' },
+ ];
+ const view: WorkflowAssistHistoryView = { steps: [], attribution: new Map(), pending: null };
+ const replay = workflowAssistReplay(exchanges, () => undefined, view, 'epoch', 'e');
+ check('only completed turns are replayed, never the one being sent',
+ replay.length === 1 && replay[0].instruction === 'First' && replay[0].reply === 'One', replay);
+ const recorded = workflowAssistReplay(exchanges, (id) => id === 'a' ? turnRecord({ instruction: 'First, cleaned', reply: 'One, cleaned' }) : undefined, view, 'epoch');
+ check('a recorded turn replays what was sent and answered', recorded[0].instruction === 'First, cleaned'
+ && recorded[0].reply === 'One, cleaned' && recorded.length === 2, recorded);
+
+ const request = buildWorkflowAssistRequest(requestInput({ conversation: [replayTurn('applied')] }));
+ check('the request replays through the same rules', request.conversation.length === 2
+ && request.conversation[1].text.startsWith('Done.\n\nChanges applied'));
+}
+
+// ---------------------------------------------------------------------------------------------
+// Where a turn stands, read from a real authoring session
+// ---------------------------------------------------------------------------------------------
+
+const fixtureOptions = () => structuredClone(fixture.options) as unknown as WorkflowEditorOptions;
+
+function openSession(draft: WorkflowDefinition) {
+ const session = new WorkflowAuthoringSession(draft);
+ const errors: string[] = [];
+ session.configure({
+ options: fixtureOptions(), readOnly: false, saving: false, commandPending: false, selectionId: null,
+ onError: (message: string) => { if (message) errors.push(message); },
+ onRestore: () => {},
+ onRollback: () => {},
+ });
+ return { session, errors };
+}
+
+function viewOf(session: WorkflowAuthoringSession): WorkflowAssistHistoryView {
+ const snapshot = session.getSnapshot();
+ return { steps: snapshot.steps, attribution: snapshot.attribution, pending: snapshot.pending };
+}
+
+function turnRecord(patch: Partial = {}): WorkflowAssistTurnRecord {
+ return {
+ threadKey: 'workflow:personal:m5b-offline-authoring', epoch: 'epoch-1', instruction: 'Rename it', outcome: 'changed',
+ reply: 'Done.', changes: [], warnings: [], contextDocuments: [], apply: 'applied', undoSequence: 0, ...patch,
+ };
+}
+
+function turnStateChecks() {
+ const { session } = openSession(structured());
+ const start = session.draft;
+ const candidate = withTask({ ...start, name: 'AI name' }, 'seed-task', { instructions: 'Decide with care.' });
+ const verified = verifyAssistChanges(start, candidate, []);
+ const applied = session.applyAssist(candidate, { turnId: 'turn-a', label: workflowAssistTurnLabel('Ask AI', 'Rename it') });
+ const record = turnRecord({ changes: verified.changes });
+ const stateOf = (value: WorkflowAssistTurnRecord, epoch = 'epoch-1', turnId = 'turn-a') =>
+ workflowAssistTurnState(turnId, value, viewOf(session), epoch);
+ check('an applied turn reads as applied', applied.status === 'applied' && stateOf(record) === 'applied', applied);
+ check('a turn from an earlier editing session reads as earlier', stateOf(record, 'epoch-2') === 'earlier');
+ check('a turn with nothing to apply reads as none', stateOf(turnRecord({ apply: 'none' })) === 'none'
+ && stateOf(turnRecord({ apply: 'noop', changes: verified.changes })) === 'none'
+ && stateOf(turnRecord({ outcome: 'question', changes: verified.changes })) === 'none');
+ check('a turn the editor refused reads as declined', stateOf(turnRecord({ apply: 'rejected', changes: verified.changes }), 'epoch-1', 'turn-z') === 'declined');
+
+ session.request('undo');
+ check('Undo in the editor reads as undone', stateOf(record) === 'undone');
+ session.request('redo');
+ check('Redo reads as applied again', stateOf(record) === 'applied');
+
+ // A later edit to one of the turn's fields, then Undo this change.
+ session.changeDraft((draft) => ({ ...draft, name: 'My own name' }), { label: 'Workflow name' });
+ const undo = session.revertTurn('turn-a');
+ const skippedName = undo.status === 'applied' ? undo.skippedKeys.find((item) => item.key === 'name') : undefined;
+ check('Undo this change reverts what was not edited later and skips the rest', undo.status === 'applied'
+ && undo.reverted === 1 && undo.skipped === 1 && skippedName?.reason === 'Changed after this turn.'
+ && session.draft.name === 'My own name'
+ && session.draft.tasks.find((task) => task.id === 'seed-task')?.instructions === start.tasks.find((task) => task.id === 'seed-task')?.instructions,
+ undo);
+ check('a partly undone turn reads as partly undone', stateOf({ ...record, undo }) === 'partly_undone');
+ check('a fully undone turn reads as undone', stateOf({ ...record, undo: { ...(undo as Row), skipped: 0 } as WorkflowAssistTurnRecord['undo'] }) === 'undone');
+ check('Undo again has nothing left to undo', session.revertTurn('turn-a').status === 'noop');
+ session.saved(session.draft);
+ check('after Save the turn can no longer be undone', session.revertTurn('turn-a').status === 'unavailable');
+
+ // A candidate that needs confirming first, declined and then confirmed.
+ const large = openSession(structured());
+ (large.session as unknown as { history: { record: () => unknown } }).history.record = () => ({ status: 'overflow' });
+ const hugeCandidate = { ...large.session.draft, name: 'Huge' };
+ const hugeChanges = verifyAssistChanges(large.session.draft, hugeCandidate, []).changes;
+ const proposal = large.session.applyAssist(hugeCandidate, { turnId: 'turn-p', label: 'Huge' });
+ const pendingRecord = turnRecord({ apply: 'pending', changes: hugeChanges });
+ check('a turn waiting for confirmation reads as pending', proposal.status === 'confirmation_required'
+ && workflowAssistTurnState('turn-p', pendingRecord, viewOf(large.session), 'epoch-1') === 'pending', proposal);
+ large.session.cancel();
+ check('a declined confirmation reads as declined', workflowAssistTurnState('turn-p', pendingRecord, viewOf(large.session), 'epoch-1') === 'declined');
+ large.session.applyAssist(hugeCandidate, { turnId: 'turn-p', label: 'Huge' });
+ large.session.confirm();
+ check('a confirmed turn reads as applied through its attribution',
+ large.session.draft.name === 'Huge' && workflowAssistTurnState('turn-p', pendingRecord, viewOf(large.session), 'epoch-1') === 'applied');
+
+ check('a submission id is a valid history turn id', isWorkflowHistoryTurnId(makeSubmissionId()));
+ const label = workflowAssistTurnLabel('Ask AI', ` ${'word '.repeat(60)}\u0000`);
+ check('a turn label is short and clean', label.startsWith('Ask AI: word') && codePointLength(label) <= 'Ask AI: '.length + 80
+ && label.endsWith('…') && !label.includes('\u0000'), label);
+}
+
+// ---------------------------------------------------------------------------------------------
+// The answer
+// ---------------------------------------------------------------------------------------------
+
+function answer(patch: Row = {}): Row {
+ return {
+ submission_id: 'submission-1', outcome: 'explained', reply: 'It runs every weekday.', candidate: null,
+ changes: [], warnings: [], context_documents: [], ...patch,
+ };
+}
+
+function responseChecks() {
+ const parse = (value: unknown) => parseWorkflowAssistResponse(value, 'submission-1');
+ const explained = parse(answer());
+ check('an explained answer is read', explained?.outcome === 'explained' && explained.reply === 'It runs every weekday.'
+ && explained.candidate === null);
+ check('a question is read', parse(answer({ outcome: 'question', reply: 'Which task?' }))?.outcome === 'question');
+ const draft = classic();
+ const changed = parse(answer({ outcome: 'changed', candidate: wire(draft) }));
+ check('a changed answer carries its candidate', changed?.outcome === 'changed' && Array.isArray(changed.candidate?.tasks));
+
+ const refused: [string, unknown][] = [
+ ['another submission id', answer({ submission_id: 'submission-2' })],
+ ['an unknown outcome', answer({ outcome: 'deleted' })],
+ ['a changed answer without a candidate', answer({ outcome: 'changed', candidate: null })],
+ ['a candidate without tasks', answer({ outcome: 'changed', candidate: { name: 'x' } })],
+ ['a candidate on an explained answer', answer({ candidate: wire(draft) })],
+ ['a reply that is not text', answer({ reply: { text: 'hi' } })],
+ ['changes that are not a list', answer({ changes: {} })],
+ ['warnings missing', answer({ warnings: undefined })],
+ ['context documents missing', answer({ context_documents: null })],
+ ['not an object', [answer()]],
+ ];
+ for (const [label, value] of refused) {
+ check(`an answer with ${label} is refused`, parse(value) === null);
+ }
+
+ const hostile = ' [click](javascript:alert(1)) **bold** ';
+ check('a hostile reply is kept as literal text', parse(answer({ reply: hostile }))?.reply === hostile);
+ check('control characters leave the reply, lone surrogates are replaced',
+ parse(answer({ reply: 'a\u0000b\u0007c\ud83d' }))?.reply === 'abc\ufffd');
+ const long = parse(answer({ reply: emoji(1600) }))?.reply ?? '';
+ check('a reply over 1,500 code points is clipped', codePointLength(long) === 1500 && long.endsWith('…') && !LONE_SURROGATE.test(long));
+
+ const parsed = parse(answer({
+ changes: [
+ { key: 'name', kind: 'modified', label: 'Name', owner_label: 'Workflow', target: { focus_key: 'name' }, summary: 'Workflow: Name' },
+ { key: 'task:t1:instructions', label: 'Instructions', owner_label: 'Step 1', target: { focus_key: 'k', node_id: 'n-1' }, summary: 'x' },
+ { key: 'bad', label: 'No target', owner_label: 'x', summary: 'x' },
+ { key: 'bad-2', label: 7, owner_label: 'x', target: { focus_key: 'k' }, summary: 'x' },
+ 'not a change',
+ ],
+ warnings: [
+ { code: 'run_as_reapproval', message: 'Saving requires re-approving Run as.' },
+ { code: 'email_requires_m365_agent', message: 'Email needs an agent.', target: { focus_key: 'task:t1:runner', node_id: 'n-1' } },
+ { code: 'Bad Code', message: 'x' },
+ { code: 'draft_has_errors', message: '' },
+ ],
+ context_documents: ['Security checklist.pdf', 7, '', ...Array.from({ length: 25 }, (_, index) => `Doc ${index}`)],
+ }));
+ check('malformed change entries are dropped and good ones kept', parsed?.changes.map((change) => change.key).join() === 'name,task:t1:instructions'
+ && parsed.changes[1].nodeId === 'n-1' && parsed.changes[0].focusKey === 'name', parsed?.changes);
+ check('warnings need a known-looking code and a message', parsed?.warnings.map((warning) => warning.code).join() === 'run_as_reapproval,email_requires_m365_agent'
+ && parsed.warnings[1].focusKey === 'task:t1:runner' && parsed.warnings[1].nodeId === 'n-1', parsed?.warnings);
+ check('context documents are text labels, at most 20', parsed?.contextDocuments[0] === 'Security checklist.pdf'
+ && (parsed?.contextDocuments.length ?? 0) <= 20 && parsed?.contextDocuments.every((label) => typeof label === 'string' && label), parsed?.contextDocuments);
+ const longLabel = parse(answer({ context_documents: [`${'x'.repeat(300)}.pdf`] }))?.contextDocuments[0] ?? '';
+ check('a long document label is clipped', codePointLength(longLabel) === 200 && longLabel.endsWith('…'));
+}
+
+// ---------------------------------------------------------------------------------------------
+// Failures
+// ---------------------------------------------------------------------------------------------
+
+function failureChecks() {
+ const describe = describeWorkflowAssistError;
+ const tags = describe(400, { error: 'Workflows use documents, not tags.', code: 'tags_unsupported' });
+ check('a 400 shows the server message and code', tags.message === 'Workflows use documents, not tags.' && tags.code === 'tags_unsupported'
+ && tags.retryAfterSeconds === null && !tags.reload);
+ check('a 400 without a body has a fallback', describe(400, null).message.includes('Nothing was changed'));
+ check('a 400 for time zones and oversize input keeps its code',
+ describe(400, { error: 'Choose a time zone.', code: 'time_zone_invalid' }).code === 'time_zone_invalid'
+ && describe(400, { error: 'Too large.', code: 'assistant_input_too_large' }).code === 'assistant_input_too_large');
+ check('a 403 explains Ask AI is not available', describe(403, { code: 'workflow_assistant_disabled' }).message === "Ask AI isn't available to you right now.");
+ const role = 'Personal workflows require the WorkflowUser app role.';
+ check('a one-word 403 error shows its sentence', describe(403, { error: 'Forbidden', message: role }).message === role);
+ check('a one-word error without a sentence is shown as is', describe(403, { error: 'Forbidden' }).message === 'Forbidden'
+ && describe(403, { error: 'Forbidden', message: ' ' }).message === 'Forbidden');
+ check('a sentence error is not replaced by a message', describe(400, { error: 'Allow User Workflows is disabled.', message: 'x' }).message
+ === 'Allow User Workflows is disabled.');
+ check('a 404 says the workflow could not be found', describe(404, { code: 'workflow_not_found' }).message.includes("couldn't be found"));
+ const conflict = describe(409, { error: 'The workflow changed.', code: 'workflow_definition_conflict' });
+ check('a definition conflict offers a reload', conflict.reload && conflict.code === 'workflow_definition_conflict');
+ const deleted = describe(409, { error: 'The workflow was deleted.', code: 'workflow_deleted' });
+ check('a deleted workflow keeps the draft and offers no reload', !deleted.reload
+ && deleted.message === 'The workflow was deleted. Your draft is still in the editor.', deleted.message);
+ check('a 409 without a code still says the draft was kept', describe(409, null).message.includes('Your draft was kept'));
+ check('a 413 suggests a smaller change', describe(413, { code: 'request_too_large' }).message.includes('smaller change'));
+ check('a 500 and a 502 change nothing and offer no wait', [500, 502].every((status) => {
+ const failure = describe(status, { error: 'x', code: status === 500 ? 'assistant_failed' : 'assistant_output_invalid' });
+ return failure.retryAfterSeconds === null && !failure.reload;
+ }));
+ check('an unknown status says which', describe(418, null).message.includes('(status 418)'));
+
+ const busy = describe(429, { error: 'Busy.', code: 'assistant_busy', rate_limited: true, retry_after_seconds: 1 }, '1');
+ check('a busy 429 waits the header seconds', busy.retryAfterSeconds === 1 && busy.code === 'assistant_busy');
+ const markdown = '**Slow down.** Wait [a bit](https://example.com) ';
+ const limited = describe(429, { error: markdown, code: 'assistant_rate_limited', rate_limited: true, retry_after_seconds: 540 }, '540');
+ check('the rate limit message stays literal text', limited.message === markdown && limited.retryAfterSeconds === 540, limited.message);
+ check('the Retry-After header wins over the body', describe(429, { retry_after_seconds: 30 }, '12').retryAfterSeconds === 12);
+ check('the body is read when there is no header', describe(429, { retry_after_seconds: 45 }).retryAfterSeconds === 45);
+ check('a 429 with no wait at all waits a second', describe(429, null).retryAfterSeconds === 1);
+ const now = Date.parse('2026-01-01T00:00:00Z');
+ check('an HTTP date header is read', workflowAssistRetryAfter('Thu, 01 Jan 2026 00:00:30 GMT', null, now) === 30);
+ check('a huge wait is clamped to an hour', workflowAssistRetryAfter('999999', null) === 3600);
+ check('a throttled 503 waits what the provider asked', describe(503, { code: 'assistant_unavailable' }, '10').retryAfterSeconds === 10);
+ check('a 503 without Retry-After asks no wait', describe(503, { code: 'assistant_timeout' }).retryAfterSeconds === null);
+ check('a code that is not a code is dropped', describe(400, { error: 'x', code: 'Not A Code' }).code === '');
+ check('an error that is not text falls back', describe(400, { error: 42 }).message.includes('Nothing was changed'));
+}
+
+// ---------------------------------------------------------------------------------------------
+// The browser request
+// ---------------------------------------------------------------------------------------------
+
+type FetchStub = (url: string | URL | Request, init?: RequestInit) => Promise;
+
+async function withFetch(stub: FetchStub, run: () => Promise): Promise {
+ const real = globalThis.fetch;
+ globalThis.fetch = stub as typeof fetch;
+ try {
+ return await run();
+ } finally {
+ globalThis.fetch = real;
+ }
+}
+
+function jsonResponse(status: number, body: unknown, headers: Record = {}): Response {
+ return new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json', ...headers } });
+}
+
+// Like the browser's fetch: a signal that is already aborted rejects at once.
+const waitForAbort: FetchStub = (_url, init) => new Promise((_resolve, reject) => {
+ const signal = init?.signal;
+ const fail = () => reject(new DOMException('Aborted', 'AbortError'));
+ if (signal?.aborted) fail();
+ else signal?.addEventListener('abort', fail, { once: true });
+});
+
+async function fetchChecks() {
+ const request = buildWorkflowAssistRequest(requestInput());
+ let seen: { url: string; init: RequestInit } | null = null;
+ const ok = await withFetch(async (url, init) => {
+ seen = { url: String(url), init: init ?? {} };
+ return jsonResponse(200, answer());
+ }, () => postWorkflowAssist(request, new AbortController().signal));
+ const sent = seen as { url: string; init: RequestInit } | null;
+ const headers = new Headers(sent?.init.headers);
+ check('an answer that fits the contract is returned', ok.ok && ok.response.reply === 'It runs every weekday.');
+ check('the request is a same-origin JSON POST to the assist route', Boolean(sent?.url.endsWith(WORKFLOW_ASSIST_PATH))
+ && sent?.init.method === 'POST' && sent.init.credentials === 'same-origin'
+ && headers.get('Content-Type') === 'application/json' && headers.get('Accept') === 'application/json', sent?.url);
+ check('the body is the request, exactly', sent?.init.body === JSON.stringify(request));
+ check('the browser waits about 170 seconds', WORKFLOW_ASSIST_DEADLINE_MS === 170_000);
+
+ const failureOf = async (stub: FetchStub, deadline?: number) => {
+ const result = await withFetch(stub, () => postWorkflowAssist(request, new AbortController().signal, deadline));
+ return !result.ok && !result.aborted ? result.failure : null;
+ };
+ const html = await failureOf(async () => new Response('Sign in', { status: 200, headers: { 'Content-Type': 'text/html' } }));
+ check('a 200 that is not JSON is unreadable, and nothing is applied', html?.code === 'unreadable' && html.message === UNREADABLE_ANSWER_MESSAGE);
+ const other = await failureOf(async () => jsonResponse(200, answer({ submission_id: 'someone-else' })));
+ check('an answer to another submission is unreadable', other?.code === 'unreadable');
+ const limited = await failureOf(async () => jsonResponse(429, { error: 'Busy.', code: 'assistant_busy', retry_after_seconds: 1 }, { 'Retry-After': '7' }));
+ check('a 429 carries the Retry-After header', limited?.status === 429 && limited.retryAfterSeconds === 7);
+ const conflict = await failureOf(async () => jsonResponse(409, { error: 'Changed.', code: 'workflow_definition_conflict' }));
+ check('a 409 conflict offers the reload', conflict?.reload === true);
+ const unreadableError = await failureOf(async () => new Response('Bad gateway', { status: 502 }));
+ check('a failure without JSON uses the status message', unreadableError?.status === 502 && unreadableError.message.includes('Nothing was changed'));
+ const timeout = await failureOf(waitForAbort, 20);
+ check('the browser deadline is a failure the reader can retry', timeout?.code === 'browser_timeout' && timeout.message.includes('took too long'));
+ const network = await failureOf(async () => { throw new TypeError('Failed to fetch'); });
+ check('a lost connection is a failure the reader can retry', network?.code === 'network_error');
+
+ const controller = new AbortController();
+ const running = withFetch(waitForAbort, () => postWorkflowAssist(request, controller.signal));
+ controller.abort();
+ const cancelled = await running;
+ check('Cancel is an abort, not a failure', !cancelled.ok && cancelled.aborted === true);
+ const before = new AbortController();
+ before.abort();
+ const early = await withFetch(waitForAbort, () => postWorkflowAssist(request, before.signal));
+ check('an already cancelled turn never waits', !early.ok && early.aborted === true);
+ const late = new AbortController();
+ const settled = await withFetch(async () => {
+ late.abort();
+ return jsonResponse(200, answer());
+ }, () => postWorkflowAssist(request, late.signal));
+ check('an answer that lands after Cancel is not used', !settled.ok && settled.aborted === true);
+
+ // Draft with AI
+ const workflow = { ...classic(), name: 'Daily digest', description: 'Summarize new documents.' };
+ let drafted: { url: string; body: Row } | null = null;
+ const text = await withFetch(async (url, init) => {
+ drafted = { url: String(url), body: JSON.parse(String(init?.body)) };
+ return jsonResponse(200, { instructions: ' Summarize each new document in three bullets. ' });
+ }, () => requestDraftInstructions(workflow, 'Summarize', new AbortController().signal));
+ const draftedRequest = drafted as { url: string; body: Row } | null;
+ check('Draft with AI asks the draft-instructions route for a personal workflow',
+ Boolean(draftedRequest?.url.endsWith(WORKFLOW_DRAFT_INSTRUCTIONS_PATH)) && draftedRequest !== null
+ && sameKeys(draftedRequest.body, ['workflow_scope', 'name', 'description', 'brief'])
+ && draftedRequest.body.workflow_scope === 'personal' && draftedRequest.body.brief === 'Summarize', draftedRequest?.body);
+ check('drafted instructions are trimmed text', text === 'Summarize each new document in three bullets.');
+ const draftFailure = async (stub: FetchStub) => {
+ try {
+ await withFetch(stub, () => requestDraftInstructions(workflow, 'Summarize', new AbortController().signal));
+ return '';
+ } catch (problem) {
+ return problem instanceof Error ? problem.message : String(problem);
+ }
+ };
+ check('a refused draft shows the server message', await draftFailure(async () => jsonResponse(400, { error: 'Personal workflows are off.' }))
+ === 'Personal workflows are off.');
+ check('an empty draft is a failure', (await draftFailure(async () => jsonResponse(200, { instructions: ' ' }))).includes("didn't return"));
+ check('a draft over the task limit is not added', (await draftFailure(async () => jsonResponse(200, {
+ instructions: 'x'.repeat(WORKFLOW_TASK_INSTRUCTIONS_LIMIT + 1),
+ }))).includes('longer than'));
+ const empty = withTask(classic(), 't2', { instructions: '' });
+ const filled = withDraftedInstructions(empty, 't2', 'Drafted.');
+ check('drafted instructions fill only the empty task', filled?.tasks[1].instructions === 'Drafted.'
+ && filled.tasks[0] === empty.tasks[0] && withDraftedInstructions(classic(), 't2', 'x') === null
+ && withDraftedInstructions(empty, 'gone', 'x') === null);
+}
+
+// ---------------------------------------------------------------------------------------------
+// Laying the candidate over the live draft, and the editor's own diff
+// ---------------------------------------------------------------------------------------------
+
+function rebaseChecks() {
+ const live = { ...structured(), probe_undefined: undefined } as unknown as WorkflowDefinition;
+ const sent = wire(live);
+ check('JSON dropping an undefined key is still the same draft', jsonEquivalent(live, sent) && !Object.hasOwn(sent, 'probe_undefined'));
+ check('an unchanged candidate is the live draft itself', rebaseAssistCandidate(live, sent) === live);
+
+ const renamed = rebaseAssistCandidate(live, { ...sent, name: 'Renamed' }) as unknown as Row;
+ check('the changed value comes from the candidate', renamed.name === 'Renamed' && renamed !== live);
+ check('an undefined key the JSON dropped is kept', Object.hasOwn(renamed, 'probe_undefined') && renamed.probe_undefined === undefined);
+ check('untouched parts keep their live identity', renamed.flow === live.flow && renamed.tasks === live.tasks
+ && renamed.reference_inputs === live.reference_inputs);
+ const withoutDescription: Row = { ...sent };
+ delete withoutDescription.description;
+ check('a key the model removed is not put back', !Object.hasOwn(rebaseAssistCandidate(live, withoutDescription as WorkflowDefinition), 'description'));
+ const added = rebaseAssistCandidate(live, { ...sent, alert_mode: 'rules' } as WorkflowDefinition) as unknown as Row;
+ check('a key the model added is taken', added.alert_mode === 'rules');
+
+ const tasks = rebaseAssistCandidate(live, withTask(sent, 'seed-task', { instructions: 'Decide with care.' }));
+ const seed = (definition: WorkflowDefinition) => definition.tasks.find((task) => task.id === 'seed-task');
+ check('only the changed task is a new object', seed(tasks) !== seed(live) && seed(tasks)?.instructions === 'Decide with care.'
+ && tasks.tasks.filter((task) => task.id !== 'seed-task').every((task) => live.tasks.includes(task)) && tasks.flow === live.flow);
+
+ const four = classic(4);
+ const reordered = wire(four);
+ reordered.tasks = [reordered.tasks[2], reordered.tasks[0], reordered.tasks[1], reordered.tasks[3]];
+ const moved = rebaseAssistCandidate(four, reordered);
+ check('reordered tasks are matched by id and keep their live objects', moved.tasks.map((task) => task.id).join() === 't3,t1,t2,t4'
+ && moved.tasks[0] === four.tasks[2] && moved.tasks[1] === four.tasks[0] && moved.tasks[3] === four.tasks[3]);
+
+ const twin = { ...four, tasks: [four.tasks[0], { ...four.tasks[1], id: 't1' }] } as WorkflowDefinition;
+ const twinWire = wire(twin);
+ twinWire.tasks[0] = { ...twinWire.tasks[0], name: 'First' };
+ const byIndex = rebaseAssistCandidate(twin, twinWire);
+ check('items with duplicate ids are matched by position', byIndex.tasks[1] === twin.tasks[1] && byIndex.tasks[0].name === 'First');
+
+ const nan = { ...four, error_handling: { strategy: 'halt', retry_count: Number.NaN } } as WorkflowDefinition;
+ const nanRebased = rebaseAssistCandidate(nan, { ...wire(nan), name: 'Other' });
+ check('a value JSON cannot carry is kept when the candidate did not change it',
+ nanRebased.error_handling === nan.error_handling && Number.isNaN(nanRebased.error_handling.retry_count));
+ check('jsonEquivalent reads undefined array items and non-finite numbers as null',
+ jsonEquivalent([undefined, Number.POSITIVE_INFINITY], [null, null]) && !jsonEquivalent({ a: 1 }, { a: 1, b: 2 })
+ && !jsonEquivalent({ a: 1 }, { a: '1' }));
+
+ const enabled = rebaseAssistCandidate(four, { ...wire(four), is_enabled: !four.is_enabled });
+ check('a forbidden change survives the rebase, so the editor still refuses it',
+ enabled.is_enabled !== four.is_enabled && workflowAssistViolation(four, enabled) !== '');
+ const { session } = openSession(four);
+ const refused = session.applyAssist(enabled, { turnId: 'turn-bad', label: 'Enable it' });
+ check('applyAssist refuses the forbidden change and the draft is unchanged', refused.status === 'rejected' && session.draft === four);
+
+ check('the draft a turn was sent with is recognized, even as an equal copy', assistDraftUnchanged(four, four)
+ && assistDraftUnchanged(four, structuredClone(four)) && assistDraftUnchanged(four, { ...four, tasks: [...four.tasks] })
+ && !assistDraftUnchanged(four, { ...four, name: 'Changed while waiting' }));
+}
+
+function reported(key: string, patch: Partial = {}): WorkflowAssistReportedChange {
+ return { key, label: 'Label', ownerLabel: 'Owner', summary: `Server: ${key}`, focusKey: key, ...patch };
+}
+
+function verifyChecks() {
+ const sent = classic(3);
+ const candidate = wire(sent);
+ candidate.name = 'Nightly review';
+ candidate.tasks[1] = { ...candidate.tasks[1], instructions: 'Check each document.' };
+ const rebased = rebaseAssistCandidate(sent, candidate);
+ const t2 = workflowTaskKey('t2', 'instructions');
+ const verification = verifyAssistChanges(sent, rebased, [reported('name', { summary: 'Workflow name' }), reported('task:ghost:name')]);
+ const keys = verification.changes.map((change) => change.key);
+ check('the change list is the editor diff, exactly', keys.join() === diffWorkflowChanges(sent, rebased).changes.map((change) => change.key).join()
+ && keys.includes('name') && keys.includes(t2), keys);
+ check('a key the server claimed that the diff did not produce is never shown',
+ !keys.includes('task:ghost:name') && verification.unverified.join() === 'task:ghost:name', verification.unverified);
+ const name = verification.changes.find((change) => change.key === 'name');
+ const own = verification.changes.find((change) => change.key === t2);
+ check('the server wording is used for a key the diff also produced', name?.summary === 'Workflow name' && name.reported);
+ check('a change the server did not report is still shown, in the editor wording',
+ own !== undefined && !own.reported && own.summary === workflowAssistChangeSummary(own.change), own?.summary);
+ check('an answer that changes nothing shows nothing', verifyAssistChanges(sent, rebaseAssistCandidate(sent, wire(sent)), [reported('name')]).changes.length === 0);
+
+ const flowSent = structured();
+ const flowCandidate = rebaseAssistCandidate(flowSent, withTask(wire(flowSent), 'seed-task', { instructions: 'Decide with care.' }));
+ const key = workflowTaskKey('seed-task', 'instructions');
+ const editorChange = diffWorkflowChanges(flowSent, flowCandidate).byKey.get(key);
+ const ghost = verifyAssistChanges(flowSent, flowCandidate, [reported(key, { focusKey: 'elsewhere', nodeId: 'ghost-node' })]).changes[0];
+ check('a flow block the draft does not have is never used for Jump to',
+ ghost?.change.target.nodeId === editorChange?.target.nodeId && ghost?.change.target.focusKey === editorChange?.target.focusKey, ghost?.change.target);
+ const real = verifyAssistChanges(flowSent, flowCandidate, [reported(key, { nodeId: 'report-node' })]).changes[0];
+ check('a flow block that exists is used for Jump to', real?.change.target.nodeId === 'report-node'
+ && real.change.target.focusKey === editorChange?.target.focusKey);
+}
+
+// ---------------------------------------------------------------------------------------------
+// Stores
+// ---------------------------------------------------------------------------------------------
+
+function settle(): Promise {
+ return new Promise((resolve) => setTimeout(resolve, 0));
+}
+
+function threadOf(key: string): AssistThreadRecord | undefined {
+ return useAssistThreadStore.getState().threads[key];
+}
+
+function doneExchange(id: string): AssistExchange {
+ return { id, text: 'Make it blue', draft: draftOf('Make it blue'), status: 'done', startedAt: 1, reply: 'Done.' };
+}
+
+async function storeChecks() {
+ const turns = useWorkflowAssistStore.getState();
+ turns.reset();
+ for (let index = 0; index < MAX_WORKFLOW_ASSIST_TURNS + 5; index += 1) {
+ useWorkflowAssistStore.getState().record(`turn-${index}`, turnRecord({ instruction: `Turn ${index}` }));
+ }
+ const kept = useWorkflowAssistStore.getState();
+ check('turn records are capped, oldest first', kept.order.length === MAX_WORKFLOW_ASSIST_TURNS
+ && Object.keys(kept.turns).length === MAX_WORKFLOW_ASSIST_TURNS && !kept.turns['turn-4'] && Boolean(kept.turns['turn-5']));
+ const before = useWorkflowAssistStore.getState().turns;
+ useWorkflowAssistStore.getState().update('turn-9', (record) => record);
+ useWorkflowAssistStore.getState().update('missing', (record) => ({ ...record, reply: 'x' }));
+ check('an update that changes nothing leaves the store alone', useWorkflowAssistStore.getState().turns === before);
+ useWorkflowAssistStore.getState().update('turn-9', (record) => ({ ...record, undoSequence: record.undoSequence + 1 }));
+ check('an update changes one record', useWorkflowAssistStore.getState().turns['turn-9'].undoSequence === 1);
+ useWorkflowAssistStore.getState().waitUntil(5000);
+ useWorkflowAssistStore.getState().waitUntil(3000);
+ check('a wait only ever moves later', useWorkflowAssistStore.getState().retryUntil === 5000);
+ useWorkflowAssistStore.getState().reset();
+ check('reset forgets everything', useWorkflowAssistStore.getState().order.length === 0 && useWorkflowAssistStore.getState().retryUntil === 0);
+
+ // The thread's opt-ins, which default to what every other editor does.
+ const store = useAssistThreadStore.getState();
+ store.resetThreads();
+ const key = 'workflow:new:check';
+ const sends: AssistSendRequest[] = [];
+ const send = async (request: AssistSendRequest): Promise => {
+ sends.push(request);
+ return { ok: true, reply: 'Fine.' };
+ };
+ const typeInto = (text: string) => useAssistThreadStore.getState().updateThread(key, CONVERSATION, (record) => ({ ...record, draft: draftOf(text) }));
+ const submit = (options: { countCodePoints?: boolean; text?: string } = {}) =>
+ submitAssistDraft({ key, conversationId: CONVERSATION, mode: 'local', maxLength: 2000, send, ...options });
+ typeInto(emoji(2001));
+ check('2,001 emoji are not sent when counting code points', submit({ countCodePoints: true }) === null && threadOf(key)?.draft.text === emoji(2001));
+ typeInto(emoji(1001));
+ check('by default 1,001 emoji are still too long', submit() === null);
+ typeInto(emoji(2000));
+ const sentId = submit({ countCodePoints: true });
+ check('2,000 emoji are sent when counting code points, and the input clears',
+ sentId !== null && threadOf(key)?.draft.text === '' && threadOf(key)?.exchanges.at(-1)?.text === emoji(2000));
+ await settle();
+ typeInto('Keep this');
+ const quick = submit({ text: 'Explain this workflow', countCodePoints: true });
+ await settle();
+ check('a quick action is its own message and leaves the input alone', quick !== null
+ && threadOf(key)?.exchanges.at(-1)?.text === 'Explain this workflow' && threadOf(key)?.draft.text === 'Keep this'
+ && sends.at(-1)?.text === 'Explain this workflow');
+
+ // A thread an editor held never sweeps the chat's idle threads, even after it lets go.
+ store.resetThreads();
+ const image = 'image:conv-1:msg-1';
+ const put = (target: string, conversationId: string, change: (record: AssistThreadRecord) => AssistThreadRecord) =>
+ useAssistThreadStore.getState().updateThread(target, conversationId, change);
+ const idleImage = () => put(image, 'conv-1', (record) => ({ ...record, exchanges: [doneExchange('img-1')] }));
+ const unit = pruneThreads({ w: newThread(CONVERSATION, 1), [image]: { ...newThread('conv-1', 1), exchanges: [doneExchange('x')] } },
+ 'w', CONVERSATION);
+ check('by default, touching a thread sweeps idle threads of other conversations', !unit[image]);
+ const quiet = pruneThreads({ w: newThread(CONVERSATION, 1), [image]: { ...newThread('conv-1', 1), exchanges: [doneExchange('x')] } },
+ 'w', CONVERSATION, undefined, new Set(['w']));
+ check('touching a quiet thread sweeps nothing', Boolean(quiet[image]));
+ const heldTouch = pruneThreads({ w: newThread(CONVERSATION, 1), [image]: { ...newThread('conv-1', 1), exchanges: [doneExchange('x')] } },
+ 'w', CONVERSATION, new Set(['w']));
+ check('touching a held thread sweeps nothing, even one not yet marked quiet', Boolean(heldTouch[image]));
+ const crowd: Record = {};
+ for (let index = 0; index <= MAX_THREADS; index += 1) crowd[`t${index}`] = newThread(CONVERSATION, index + 1);
+ const capped = pruneThreads(crowd, `t${MAX_THREADS}`, CONVERSATION, new Set(['t0']));
+ check('past the cap, the least recently touched thread is kept while held, and the next oldest goes',
+ Boolean(capped.t0) && !capped.t1 && Object.keys(capped).length === MAX_THREADS);
+ idleImage();
+ const release = holdAssistThread('workflow:new:held');
+ put('workflow:new:held', CONVERSATION, (record) => ({ ...record, draft: draftOf('typing') }));
+ check('a held workflow thread does not sweep the chat image thread', Boolean(threadOf(image)));
+ release();
+ release();
+ put('workflow:new:held', CONVERSATION, (record) => ({ ...record, exchanges: [doneExchange('late')], draft: blankDraft() }));
+ check('an answer landing after the editor let go does not sweep it either', Boolean(threadOf(image)));
+ const twice = holdAssistThread('workflow:new:twice');
+ const again = holdAssistThread('workflow:new:twice');
+ twice();
+ put('workflow:new:twice', CONVERSATION, (record) => ({ ...record, draft: draftOf('x') }));
+ check('a thread held twice stays held after one release', Boolean(threadOf(image)));
+ again();
+ put('workflow:new:plain', CONVERSATION, (record) => ({ ...record, draft: draftOf('x') }));
+ check('a thread no editor held still sweeps as before', !threadOf(image));
+ idleImage();
+ check('the image editor still sweeps the idle released workflow thread', !threadOf('workflow:new:held') && Boolean(threadOf(image)));
+ put('workflow:new:held', CONVERSATION, (record) => ({ ...record, draft: draftOf('back') }));
+ check('once swept, the workflow thread is forgotten as quiet', !threadOf(image));
+ // An image request that finishes after the user moved to the workflow editor sweeps from the
+ // image's conversation. The idle workflow thread the open editor holds must survive it.
+ store.resetThreads();
+ const releaseKept = holdAssistThread('workflow:new:kept');
+ put('workflow:new:kept', CONVERSATION, (record) => ({ ...record, exchanges: [doneExchange('kept')], draft: blankDraft() }));
+ idleImage();
+ check('another conversation\'s sweep never drops an idle thread an editor holds',
+ Boolean(threadOf('workflow:new:kept')) && Boolean(threadOf(image)));
+ releaseKept();
+ store.resetThreads();
+}
+
+async function main() {
+ codePointChecks();
+ requestChecks();
+ conversationChecks();
+ turnStateChecks();
+ responseChecks();
+ failureChecks();
+ await fetchChecks();
+ rebaseChecks();
+ verifyChecks();
+ await storeChecks();
+ if (failures) {
+ console.log(`\n${failures} check(s) failed`);
+ process.exitCode = 1;
+ }
+}
+
+main().catch((error) => {
+ console.log('FAIL the checks threw', error instanceof Error ? error.stack : error);
+ process.exitCode = 1;
+});
diff --git a/functional_tests/test_v2_workflow_ask_ai_parity.py b/functional_tests/test_v2_workflow_ask_ai_parity.py
new file mode 100644
index 000000000..fe15322b8
--- /dev/null
+++ b/functional_tests/test_v2_workflow_ask_ai_parity.py
@@ -0,0 +1,458 @@
+#!/usr/bin/env python3
+# test_v2_workflow_ask_ai_parity.py
+"""
+Functional test for the round trip between the V2 editor's Ask AI tab and the AI workflow assistant.
+Version: 0.261.213
+Implemented in: 0.261.213
+
+This test ensures that the requests the Ask AI tab builds are the requests the assistant accepts,
+and that the answers the assistant gives are the answers the tab applies. It runs the tab's real
+TypeScript under node on both sides of real assistant requests that use a scripted model:
+
+* node builds each request with ``buildWorkflowAssistRequest`` from the draft the editor holds (a
+ saved workflow, a v3 flow, New workflow's draft and a chat proposal's draft), with ``#`` documents
+ as the picker makes them and a thread history, and prints the exact JSON text the browser POSTs;
+* the assistant reads that text with its own checks (``parse_assist_body``, ``parse_assist_request``),
+ and that exact text is POSTed to the real ``POST /api/user/workflows/assist`` route: 3b's route
+ harness from route_tests/test_workflow_assist_policy.py, which runs the real route body, helpers
+ and gates on a closed Flask app over a scripted model. A new or proposal draft must arrive with
+ ``base: null`` and no ``id``, a saved one with its ``id`` and ``definition_revision``, and the
+ focus, time zone, documents and completed turns as the contract says;
+* node replays each 200 body the route answered as the tab does: ``parseWorkflowAssistResponse``,
+ ``rebaseAssistCandidate`` over the live draft, the editor's own diff (``verifyAssistChanges``) and
+ ``applyAssist``. The editor's diff must find exactly the change keys, Jump to targets and wording
+ the server reported.
+
+Every scenario in test_workflow_assist_candidate_parity.py runs this way, plus cases for the request
+shapes. The TypeScript side is test_v2_workflow_ask_ai_parity_logic.ts. The node checks are skipped
+when application/v2_ui/node_modules is missing; run npm ci there first.
+"""
+
+import ast
+import copy
+import importlib.util
+import json
+import os
+import subprocess
+import sys
+from pathlib import Path
+
+import pytest
+
+REPO_ROOT = Path(__file__).resolve().parents[1]
+APP_DIR = REPO_ROOT / "application" / "single_app"
+sys.path.insert(0, str(APP_DIR))
+sys.path.insert(0, str(REPO_ROOT / "functional_tests"))
+
+import functions_workflow_assist as core # noqa: E402
+from functions_workflow_schedules import build_workflow_schedule_editor_options # noqa: E402
+from test_support import workflow_assist as wa # noqa: E402
+from test_support.versioning import assert_app_version_at_least # noqa: E402
+from test_workflow_assist_candidate_parity import ( # noqa: E402
+ PARITY_OPTIONS,
+ SCENARIOS,
+ URGENT_ONLY,
+ WEEKDAYS_AT_SEVEN,
+)
+
+V2_DIR = REPO_ROOT / "application" / "v2_ui"
+LOGIC_CHECK = Path(__file__).with_name("test_v2_workflow_ask_ai_parity_logic.ts")
+ROUTE_POLICY = REPO_ROOT / "functional_tests" / "route_tests" / "test_workflow_assist_policy.py"
+FIXTURE_ENV = "WORKFLOW_ASK_AI_PARITY_FIXTURE"
+REQUEST_FIELDS = {"submission_id", "base", "instruction", "conversation", "focus", "time_zone", "draft", "references"}
+
+# The editor options the V2 editor receives: 3b's parity options with the real schedule choices,
+# so the time zone list the tab checks is the server's own.
+ASK_AI_OPTIONS = {
+ **copy.deepcopy(PARITY_OPTIONS),
+ "schedule": build_workflow_schedule_editor_options(min_interval_seconds=300),
+}
+
+
+def _load_route_policy():
+ """3b's route policy module, loaded under its own name for its route harness."""
+ spec = importlib.util.spec_from_file_location("_ask_ai_parity_route_policy", ROUTE_POLICY)
+ module = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(module)
+ return module
+
+
+route_policy = _load_route_policy()
+
+
+class RouteHarness(route_policy.Harness):
+ """3b's route harness, with the services given the editor options the tab was given."""
+
+ def build_services(self, settings, *, client_factory):
+ self.built.append((settings, client_factory))
+ self.bundle = wa.services(self.model, stored=self.stored, limiter=self.limiter, options=ASK_AI_OPTIONS)
+ return self.bundle
+
+# A thread history: eleven completed turns, so the oldest falls out of the ten the tab replays, with
+# a failed and a cancelled turn among them that must never reach the server.
+REPLAY = {
+ "done": [f"Turn {number}: tighten the review \U0001F512\tthen\nsummarize it." for number in range(1, 12)],
+ "replies": [f"Reply {number} \u2705\twith a tab\nand a new line." for number in range(1, 12)],
+ "failed": "This failed turn is never replayed.",
+ "cancelled": "This cancelled turn is never replayed.",
+}
+
+# Ask AI about this task, for some of 3b's scenarios.
+SCENARIO_FOCUS = {
+ "one task's document target": wa.REVIEW_ID,
+ "flow task renamed and rewritten": "spare-task",
+}
+
+
+def _draft_server_fields():
+ """The fields a chat proposal's draft is served without, read from the proposal module's source.
+
+ Importing that module would bring in Cosmos DB, so its constant is read as a literal instead.
+ """
+ source = (APP_DIR / "functions_orchestration_workflow_proposals.py").read_text(encoding="utf-8")
+ for node in ast.parse(source).body:
+ if isinstance(node, ast.Assign) and any(getattr(target, "id", None) == "DRAFT_SERVER_FIELDS" for target in node.targets):
+ return frozenset(ast.literal_eval(node.value.args[0]))
+ raise AssertionError("DRAFT_SERVER_FIELDS is missing from functions_orchestration_workflow_proposals.py")
+
+
+PROPOSAL_FIELDS = _draft_server_fields()
+
+
+def _proposal_source(stored):
+ """A workflow as a chat proposal serves it to the editor: without the server's own fields."""
+ return {key: copy.deepcopy(value) for key, value in stored.items() if key not in PROPOSAL_FIELDS}
+
+
+def _case(name, reply, *, live, stored=None, source=None, instruction="Change the workflow.", references=(),
+ focus_task=None, replay=False):
+ return {
+ "name": name, "live": live, "stored": stored, "source": source, "instruction": instruction,
+ "references": list(references), "focus_task": focus_task, "replay": replay, "reply": reply,
+ }
+
+
+def _scenario_case(scenario):
+ stored = scenario["stored"]
+ draft = scenario["draft"]
+ if stored is not None and draft is None:
+ live, source = "stored", None
+ else:
+ live, source = "draft", draft if draft is not None else wa.new_draft()
+ return _case(
+ scenario["name"], wa.reply("changed", "Done.", scenario["operations"]), live=live, stored=stored,
+ source=source, references=scenario["references"], focus_task=SCENARIO_FOCUS.get(scenario["name"]),
+ )
+
+
+def _request_cases():
+ return [
+ _case(
+ "saved workflow with a focus, documents and a replayed conversation",
+ wa.reply("explained", "The review already checks each document \u2705."),
+ live="stored", stored=wa.stored_workflow(),
+ instruction="Make the review \U0001F512 stricter\tfor every document\nand tell the team \U0001F469\u200d\U0001F4BB.",
+ references=[wa.INCIDENT, wa.TEAM_GUIDE, wa.CHECKLIST], focus_task=wa.REVIEW_ID, replay=True,
+ ),
+ _case(
+ "saved flow focused on a nested task's block",
+ wa.reply("explained", "That step accepts the decision."),
+ live="stored", stored=wa.flow_stored(), instruction="What does this step do?", focus_task="yes-task",
+ ),
+ _case(
+ "New workflow's draft",
+ wa.reply("changed", "Done.", [
+ {"op": "set_name", "name": "Morning digest"},
+ {"op": "set_task_instructions", "task": "task_1", "instructions": "Summarize the new documents."},
+ ]),
+ live="new", instruction="Call it Morning digest and have the task summarize new documents.",
+ ),
+ _case(
+ "chat proposal's draft opened with Edit",
+ wa.reply("changed", "Done.", [WEEKDAYS_AT_SEVEN, *URGENT_ONLY]),
+ live="proposal", source=_proposal_source(wa.stored_workflow()),
+ instruction="Run this at 7 AM on weekdays and only alert me when something is urgent",
+ ),
+ _case(
+ "instruction of exactly 2,000 code points",
+ wa.reply("explained", "Tell me what to change."),
+ live="stored", stored=wa.stored_workflow(),
+ instruction="\U0001F600" * core.ASSIST_INSTRUCTION_MAX_LENGTH,
+ ),
+ _case(
+ "question about where a document goes",
+ wa.reply("question", "Should every task read Security checklist.pdf, or only Review?"),
+ live="stored", stored=wa.stored_workflow(), instruction="Use #Security checklist.pdf.",
+ references=[wa.CHECKLIST],
+ ),
+ _case(
+ "document read as context",
+ wa.reply("changed", "I read Security checklist.pdf and rewrote the description.", [
+ {"op": "set_description", "description": "Check new documents against the security checklist."},
+ ]),
+ live="stored", stored=wa.stored_workflow(),
+ instruction="Use #Security checklist.pdf to rewrite the description.", references=[wa.CHECKLIST],
+ ),
+ ]
+
+
+def _cases():
+ cases = [_scenario_case(scenario) for scenario in SCENARIOS] + _request_cases()
+ for index, case in enumerate(cases, start=1):
+ case["submission_id"] = f"ask-ai-parity:{index:03d}"
+ return cases
+
+
+def _for_node(case, **extra):
+ fields = {key: value for key, value in case.items() if key != "reply"}
+ fields.update(extra)
+ return fields
+
+
+def _flow_node_for_task(region, task_id):
+ for node in (region or {}).get("nodes") or []:
+ if node.get("kind") == "task" and node.get("task_id") == task_id:
+ return node["id"]
+ for child in ("then", "else", "body"):
+ if isinstance(node.get(child), dict):
+ found = _flow_node_for_task(node[child], task_id)
+ if found:
+ return found
+ return None
+
+
+def _expected_focus(case):
+ task_id = case["focus_task"]
+ if task_id is None:
+ return None
+ stored = case["stored"] or case["source"] or {}
+ if stored.get("definition_version") == 3:
+ return _flow_node_for_task(stored.get("flow"), task_id)
+ return task_id
+
+
+def _reference_order(reference):
+ scope = reference["scope"]
+ return (reference["kind"], scope["kind"], scope.get("id") or "", reference["id"])
+
+
+def _node(bundle_path, fixture_path, fixture):
+ fixture_path.write_text(json.dumps(fixture, allow_nan=False), encoding="utf-8")
+ try:
+ return subprocess.run(
+ ["node", str(bundle_path)],
+ cwd=str(V2_DIR), capture_output=True, text=True, encoding="utf-8", shell=(sys.platform == "win32"),
+ timeout=300, env={**os.environ, FIXTURE_ENV: str(fixture_path)},
+ )
+ finally:
+ if fixture_path.exists():
+ fixture_path.unlink()
+
+
+def _emitted(run, label):
+ """One line the logic check printed, read with Python's JSON parser."""
+ # Split on newlines only: splitlines() also splits on U+001C, U+0085 and U+2028.
+ lines = [line for line in run.stdout.split("\n") if line.startswith(f"{label} ")]
+ assert len(lines) == 1, f"the tab printed {len(lines)} {label} lines\n{(run.stdout + run.stderr)[-3000:]}"
+ return json.loads(lines[0][len(label) + 1:])
+
+
+def _answer(case, text):
+ """What the assistant's route answers to the exact text the tab sent, over a scripted model."""
+ raw = text.encode("utf-8")
+ body = core.parse_assist_body(raw)
+ request = core.parse_assist_request(copy.deepcopy(body), wa.USER_ID)
+ harness = RouteHarness()
+ harness.model = wa.ScriptedModel(case["reply"])
+ harness.stored = case["stored"] if body["base"] is not None else None
+ response = harness.post(data=raw, content_type="application/json", headers={"Accept": "application/json"})
+ answered = response.get_data(as_text=True)
+ # Raised rather than asserted, so an optimized run still stops on a refusal.
+ if response.status_code != 200:
+ raise AssertionError(f"{case['name']}: the route answered {response.status_code}: {answered[:500]}")
+ return {
+ "body": body, "request": request, "bundle": harness.bundle, "result": json.loads(answered),
+ "read": list(harness.parsed), "runs": list(harness.runs), "mimetype": response.mimetype,
+ "cache_control": response.headers.get("Cache-Control"),
+ }
+
+
+@pytest.fixture(scope="module")
+def round_trip():
+ """Both node runs around the assistant: the tab's requests, the answers, and the tab's replay."""
+ assert LOGIC_CHECK.exists(), "the TypeScript logic check is missing"
+ if not (V2_DIR / "node_modules").exists():
+ pytest.skip("run npm ci in application/v2_ui to build the requests with the tab's code")
+
+ cases = _cases()
+ bundle_path = V2_DIR / "node_modules" / ".cache-workflow-ask-ai-parity-check.mjs"
+ fixture_path = V2_DIR / "node_modules" / ".cache-workflow-ask-ai-parity-fixture.json"
+ shared = {"options": ASK_AI_OPTIONS, "time_zone": wa.TIME_ZONE, "replay": REPLAY}
+ try:
+ subprocess.run(
+ [
+ "npx", "esbuild", str(LOGIC_CHECK), "--bundle", "--platform=node", "--format=esm",
+ "--packages=external", f"--outfile={bundle_path}", "--log-level=error",
+ ],
+ cwd=str(V2_DIR), check=True, shell=(sys.platform == "win32"), timeout=300,
+ )
+ built = _node(bundle_path, fixture_path, {**shared, "phase": "requests", "cases": [_for_node(case) for case in cases]})
+ assert built.returncode == 0, (built.stdout + built.stderr)[-4000:]
+ requests = _emitted(built, "REQUESTS")
+ assert [item["name"] for item in requests] == [case["name"] for case in cases]
+ refused = [f"{item['name']}: {item['refused']}" for item in requests if "refused" in item]
+ assert not refused, "the tab refused to build: " + "; ".join(refused)
+
+ answers = [_answer(case, item["text"]) for case, item in zip(cases, requests)]
+ replayed = _node(bundle_path, fixture_path, {**shared, "phase": "responses", "cases": [
+ _for_node(case, request_text=item["text"], response=answer["result"])
+ for case, item, answer in zip(cases, requests, answers)
+ ]})
+ finally:
+ if bundle_path.exists():
+ bundle_path.unlink()
+ return {"cases": cases, "requests": requests, "answers": answers, "replayed": replayed}
+
+
+def test_version_is_at_least_the_implementing_release():
+ assert_app_version_at_least("0.261.213")
+
+
+def test_the_proposal_draft_is_served_without_an_id():
+ assert {"id", "user_id", "definition_revision"} <= PROPOSAL_FIELDS
+ assert "id" not in _proposal_source(wa.stored_workflow())
+
+
+def test_every_request_the_tab_builds_is_one_the_assistant_accepts(round_trip):
+ for case, answer in zip(round_trip["cases"], round_trip["answers"]):
+ body = answer["body"]
+ name = case["name"]
+ assert set(body) == REQUEST_FIELDS, name
+ assert answer["result"]["submission_id"] == case["submission_id"], name
+ assert body["time_zone"] == wa.TIME_ZONE, name
+ assert body["focus"] == _expected_focus(case), name
+ assert answer["request"].instruction == case["instruction"].strip(), name
+ if case["replay"]:
+ assert len(body["conversation"]) == 20, name
+ else:
+ assert body["conversation"] == [], name
+
+
+def test_the_route_answers_the_exact_text_the_tab_sends(round_trip):
+ for case, item, answer in zip(round_trip["cases"], round_trip["requests"], round_trip["answers"]):
+ name = case["name"]
+ assert answer["read"] == [len(item["text"].encode("utf-8"))], name
+ assert answer["runs"] == [wa.USER_ID], name
+ assert answer["mimetype"] == "application/json", name
+ assert answer["cache_control"] == "no-store, private", name
+ assert answer["result"]["submission_id"] == case["submission_id"], name
+
+
+def test_a_new_or_proposal_draft_is_sent_without_a_base_or_an_id(round_trip):
+ unsaved = [
+ (case, answer) for case, answer in zip(round_trip["cases"], round_trip["answers"])
+ if case["live"] in ("new", "proposal") or case["stored"] is None
+ ]
+ assert {case["live"] for case, _answer in unsaved} == {"new", "proposal", "draft"}
+ for case, answer in unsaved:
+ body = answer["body"]
+ assert body["base"] is None, case["name"]
+ assert "id" not in body["draft"], case["name"]
+ assert not answer["bundle"].recorder.named("read_base"), case["name"]
+
+
+def test_a_saved_workflow_is_sent_with_its_id_and_revision(round_trip):
+ saved = [
+ (case, answer) for case, answer in zip(round_trip["cases"], round_trip["answers"]) if case["stored"] is not None
+ ]
+ assert any(case["stored"].get("definition_version") == 3 for case, _answer in saved)
+ for case, answer in saved:
+ body = answer["body"]
+ stored = case["stored"]
+ assert body["base"] == {"workflow_id": stored["id"], "definition_revision": stored["definition_revision"]}, case["name"]
+ assert body["draft"]["id"] == stored["id"], case["name"]
+ assert body["draft"].get("definition_revision", stored["definition_revision"]) == stored["definition_revision"], case["name"]
+ assert answer["bundle"].recorder.named("read_base") == [(wa.USER_ID, stored["id"])], case["name"]
+
+
+def test_focus_names_the_task_or_its_flow_block(round_trip):
+ focused = {case["name"]: answer["body"]["focus"] for case, answer in zip(round_trip["cases"], round_trip["answers"])
+ if case["focus_task"]}
+ assert focused == {
+ "one task's document target": wa.REVIEW_ID,
+ "flow task renamed and rewritten": "constructor",
+ "saved workflow with a focus, documents and a replayed conversation": wa.REVIEW_ID,
+ "saved flow focused on a nested task's block": "yes-node",
+ }
+
+
+def test_documents_are_sent_in_the_servers_canonical_form(round_trip):
+ for case, answer in zip(round_trip["cases"], round_trip["answers"]):
+ references = answer["body"]["references"]
+ assert [reference["id"] for reference in sorted(case["references"], key=_reference_order)] == [
+ reference["id"] for reference in references
+ ], case["name"]
+ assert references == sorted(references, key=_reference_order), case["name"]
+ for reference in references:
+ assert reference["kind"] == "document" and set(reference) <= {"kind", "id", "label", "scope"}, case["name"]
+ assert set(reference["scope"]) == {"kind", "id"}, case["name"]
+ assert (reference["scope"]["id"] is None) == (reference["scope"]["kind"] == "personal"), case["name"]
+ resolved = answer["bundle"].recorder.named("resolve_references")
+ assert resolved == ([(wa.USER_ID, answer["request"].references)] if references else []), case["name"]
+
+
+def test_only_completed_turns_are_replayed(round_trip):
+ [answer] = [answer for case, answer in zip(round_trip["cases"], round_trip["answers"]) if case["replay"]]
+ conversation = answer["body"]["conversation"]
+ assert [item["role"] for item in conversation] == ["user", "assistant"] * 10
+ texts = [item["text"] for item in conversation]
+ assert not any(REPLAY["failed"] in text or REPLAY["cancelled"] in text for text in texts)
+ # Eleven turns completed; the oldest falls out of the ten replayed.
+ assert texts[0] == REPLAY["done"][1] and not any(text.startswith("Turn 1:") for text in texts)
+ assert [texts[index] for index in range(0, 20, 2)] == REPLAY["done"][1:]
+ for index, reply in enumerate(REPLAY["replies"][1:]):
+ assert texts[2 * index + 1].startswith(reply), texts[2 * index + 1]
+ assert any("Changes applied: Workflow: Name." in text for text in texts)
+ assert any("later undone" in text for text in texts)
+ assert any("earlier editing session" in text for text in texts)
+ # A turn that changed nothing is replayed as its reply alone.
+ assert REPLAY["replies"][2] in texts
+ assert all("\t" in text and "\n" in text for text in texts)
+ assert answer["request"].conversation == [
+ {"role": item["role"], "text": item["text"].strip()} for item in conversation
+ ]
+
+
+def test_the_boundary_instruction_reaches_the_assistant_whole(round_trip):
+ [answer] = [answer for case, answer in zip(round_trip["cases"], round_trip["answers"])
+ if case["name"] == "instruction of exactly 2,000 code points"]
+ instruction = answer["body"]["instruction"]
+ assert len(instruction) == core.ASSIST_INSTRUCTION_MAX_LENGTH
+ assert len(instruction.encode("utf-16-le")) // 2 == 2 * core.ASSIST_INSTRUCTION_MAX_LENGTH
+
+
+def test_the_assistant_answers_every_case_as_scripted(round_trip):
+ outcomes = {case["name"]: answer["result"]["outcome"] for case, answer in zip(round_trip["cases"], round_trip["answers"])}
+ assert all(outcomes[scenario["name"]] == "changed" for scenario in SCENARIOS)
+ assert outcomes["question about where a document goes"] == "question"
+ assert outcomes["saved flow focused on a nested task's block"] == "explained"
+ [context] = [answer["result"] for case, answer in zip(round_trip["cases"], round_trip["answers"])
+ if case["name"] == "document read as context"]
+ assert context["outcome"] == "changed" and context["context_documents"] == [wa.CHECKLIST["label"]]
+ run_as = {case["name"] for case, answer in zip(round_trip["cases"], round_trip["answers"])
+ if any(warning["code"] == "run_as_reapproval" for warning in answer["result"]["warnings"])}
+ assert "weekdays at seven with urgent alerts and Run as" in run_as
+
+
+def test_the_tab_applies_every_answer_as_the_server_reported_it(round_trip):
+ replayed = round_trip["replayed"]
+ output = replayed.stdout + replayed.stderr
+ failures = [line for line in output.split("\n") if line.startswith("FAIL")]
+ assert replayed.returncode == 0 and not failures, "\n".join(failures or [output[-4000:]])
+ expected = sum(10 if answer["result"]["outcome"] == "changed" else 4 for answer in round_trip["answers"])
+ passed = sum(1 for line in replayed.stdout.split("\n") if line.startswith(" ok "))
+ assert passed == expected, f"expected {expected} tab checks, saw {passed}"
+
+
+if __name__ == "__main__":
+ sys.exit(pytest.main([__file__, "-q"]))
diff --git a/functional_tests/test_v2_workflow_ask_ai_parity_logic.ts b/functional_tests/test_v2_workflow_ask_ai_parity_logic.ts
new file mode 100644
index 000000000..0cbf15d1c
--- /dev/null
+++ b/functional_tests/test_v2_workflow_ask_ai_parity_logic.ts
@@ -0,0 +1,318 @@
+// test_v2_workflow_ask_ai_parity_logic.ts
+// The Ask AI tab's half of a round trip through the AI workflow assistant.
+//
+// Version: 0.261.213
+// Implemented in: 0.261.213
+//
+// test_v2_workflow_ask_ai_parity.py bundles this with esbuild and runs it under node twice, each
+// time with the JSON file WORKFLOW_ASK_AI_PARITY_FIXTURE names:
+//
+// * Phase "requests" builds each case's request with the tab's real buildWorkflowAssistRequest, from
+// the draft the editor holds, the # documents as the picker makes them and a thread history, and
+// prints the exact text the browser POSTs. The Python side parses that text with the assistant's
+// own checks and answers it with a scripted model.
+// * Phase "responses" replays each answer as the tab does: parseWorkflowAssistResponse reads it,
+// rebaseAssistCandidate lays the candidate over the live draft, the editor's own diff must find
+// exactly the changes, Jump to targets and wording the server reported, and applyAssist applies it.
+
+import { readFileSync } from 'node:fs';
+import { PERSONAL_SCOPE, documentContextItem, groupScope, type ContextItem } from '../application/v2_ui/src/lib/chatContext';
+import type { WorkspaceDocument, WorkspaceRef } from '../application/v2_ui/src/lib/types';
+import {
+ buildWorkflowAssistRequest,
+ jsonEquivalent,
+ parseWorkflowAssistResponse,
+ rebaseAssistCandidate,
+ verifyAssistChanges,
+ workflowAssistChangeSummary,
+ workflowAssistReplay,
+ workflowAssistTurnLabel,
+ type WorkflowAssistHistoryView,
+ type WorkflowAssistReplayExchange,
+ type WorkflowAssistTurnRecord,
+ type WorkflowAssistVerifiedChange,
+} from '../application/v2_ui/src/lib/workflowAssist';
+import { WorkflowAuthoringSession, workflowAssistViolation } from '../application/v2_ui/src/components/workflows/WorkflowAuthoringHistory';
+import { isWorkflowHistoryTurnId } from '../application/v2_ui/src/lib/workflowAuthoringHistory';
+import { diffWorkflowChanges } from '../application/v2_ui/src/lib/workflowChangeTracking';
+import { flowTaskNodeId } from '../application/v2_ui/src/lib/workflowFlow';
+import {
+ newWorkflowDefinition,
+ normalizeWorkflowDefinition,
+ workflowScheduleTimezones,
+ type WorkflowDefinition,
+ type WorkflowEditorOptions,
+ type WorkflowScope,
+} from '../application/v2_ui/src/lib/workflowEditor';
+
+type Row = Record;
+
+interface ReferenceSpec {
+ kind: string;
+ id: string;
+ label: string;
+ scope: { kind: string; id?: string; name?: string };
+}
+
+interface ParityCase {
+ name: string;
+ /** Where the editor's draft comes from: a saved workflow, a chat proposal, a given draft, or New workflow. */
+ live: 'stored' | 'proposal' | 'draft' | 'new';
+ stored: Row | null;
+ source: Row | null;
+ instruction: string;
+ references: ReferenceSpec[];
+ /** The task Ask AI about this task scoped the turn to, or null. */
+ focus_task: string | null;
+ replay: boolean;
+ submission_id: string;
+ request_text?: string;
+ response?: Row;
+}
+
+interface ReplaySpec {
+ done: string[];
+ replies: string[];
+ failed: string;
+ cancelled: string;
+}
+
+interface ParityFixture {
+ phase: 'requests' | 'responses';
+ options: Row;
+ time_zone: string;
+ replay: ReplaySpec;
+ cases: ParityCase[];
+}
+
+const PERSONAL: WorkflowScope = { type: 'personal' };
+const EPOCH = 'epoch-current';
+
+let failures = 0;
+function check(name: string, condition: boolean, detail?: unknown) {
+ if (condition) {
+ console.log(` ok ${name}`);
+ } else {
+ failures += 1;
+ console.log(`FAIL ${name}`, detail === undefined ? '' : JSON.stringify(detail, null, 1)?.slice(0, 3000));
+ }
+}
+
+/** One labelled line of JSON; U+0085, U+2028 and U+2029 are escaped so the line stays one line. */
+function emit(label: string, value: unknown) {
+ const line = JSON.stringify(value).replace(
+ /[\u0085\u2028\u2029]/g, (character) => `\\u${character.charCodeAt(0).toString(16).padStart(4, '0')}`,
+ );
+ console.log(`${label} ${line}`);
+}
+
+/** A JSON value with its object keys sorted, so two values compare as text. */
+function canonical(value: unknown): unknown {
+ if (Array.isArray(value)) return value.map(canonical);
+ if (value !== null && typeof value === 'object') {
+ const record = value as Row;
+ return Object.fromEntries(Object.keys(record).sort().map((key) => [key, canonical(record[key])]));
+ }
+ return value;
+}
+
+function sameJson(left: unknown, right: unknown): boolean {
+ return JSON.stringify(canonical(left)) === JSON.stringify(canonical(right));
+}
+
+/** The draft the editor holds for a case, and the baseline it opened. */
+function editorDraft(item: ParityCase, sent?: Row): { baseline: WorkflowDefinition; live: WorkflowDefinition } {
+ switch (item.live) {
+ case 'stored': {
+ // WorkflowsSection opens a saved workflow this way.
+ const live = normalizeWorkflowDefinition(structuredClone(item.stored), PERSONAL);
+ return { baseline: live, live };
+ }
+ case 'proposal': {
+ // The proposal card's Edit opens the proposal's draft this way.
+ const live = normalizeWorkflowDefinition(structuredClone(item.source), PERSONAL);
+ return { baseline: live, live };
+ }
+ case 'draft': {
+ const live = structuredClone(item.source) as WorkflowDefinition;
+ const baseline = item.stored ? normalizeWorkflowDefinition(structuredClone(item.stored), PERSONAL) : live;
+ return { baseline, live };
+ }
+ default: {
+ // New workflow's task ids are random, so the replay reads back the draft that was sent.
+ const live = sent ? structuredClone(sent) as WorkflowDefinition : newWorkflowDefinition(PERSONAL);
+ return { baseline: live, live };
+ }
+ }
+}
+
+/** The # documents as the picker makes them. */
+function contextItems(references: readonly ReferenceSpec[]): ContextItem[] {
+ const items: ContextItem[] = [];
+ for (const reference of references) {
+ const scope = reference.scope.kind === 'personal' ? PERSONAL_SCOPE
+ : groupScope({ id: reference.scope.id, name: reference.scope.name } as WorkspaceRef);
+ const document = { id: reference.id, file_name: reference.label } as WorkspaceDocument;
+ items.push(documentContextItem(document, scope, items, 'user', 'mention'));
+ }
+ return items;
+}
+
+/** The value Ask AI about this task sends: the task's id, or its flow block's id on a flow. */
+function focusValue(draft: WorkflowDefinition, taskId: string | null): string | null {
+ if (!taskId) return null;
+ return draft.definition_version === 3 ? flowTaskNodeId(draft, taskId) || taskId : taskId;
+}
+
+/**
+ * A thread history as the tab holds it: completed turns with a failed and a cancelled one among
+ * them, the exchange being sent, and the records and history steps the replay reads.
+ */
+function replayHistory(spec: ReplaySpec, sending: ParityCase) {
+ const exchanges: WorkflowAssistReplayExchange[] = [];
+ const records: Record = {};
+ const steps: { origin: string; turnId?: string; applied: boolean }[] = [];
+ spec.done.forEach((text, index) => {
+ const id = `replayed-turn-${index + 1}`;
+ exchanges.push({ id, status: 'done', text, reply: spec.replies[index] });
+ const changed = index % 3 !== 2;
+ const change = { key: 'name', summary: 'Workflow: Name', reported: true } as unknown as WorkflowAssistVerifiedChange;
+ records[id] = {
+ threadKey: 'workflow:personal:parity', epoch: index === 1 ? 'epoch-earlier' : EPOCH, instruction: text,
+ outcome: changed ? 'changed' : 'explained', reply: spec.replies[index],
+ changes: changed ? [change] : [], warnings: [], contextDocuments: [],
+ apply: changed ? 'applied' : 'none', undoSequence: 0,
+ };
+ if (changed && index % 2 === 0) steps.push({ origin: 'ai', turnId: id, applied: true });
+ if (index === 4) {
+ exchanges.push({ id: 'failed-turn', status: 'failed', text: spec.failed });
+ exchanges.push({ id: 'cancelled-turn', status: 'cancelled', text: spec.cancelled });
+ }
+ });
+ exchanges.push({ id: sending.submission_id, status: 'pending', text: sending.instruction });
+ const view: WorkflowAssistHistoryView = { steps, attribution: new Map(), pending: null };
+ return workflowAssistReplay(exchanges, (id) => records[id], view, EPOCH, sending.submission_id);
+}
+
+function buildRequests(fixture: ParityFixture) {
+ const zones = workflowScheduleTimezones(fixture.options as WorkflowEditorOptions);
+ return fixture.cases.map((item) => {
+ const { baseline, live } = editorDraft(item);
+ try {
+ const request = buildWorkflowAssistRequest({
+ submissionId: item.submission_id,
+ instruction: item.instruction,
+ baseline,
+ draft: live,
+ contextItems: contextItems(item.references),
+ conversation: item.replay ? replayHistory(fixture.replay, item) : [],
+ focus: focusValue(live, item.focus_task),
+ timeZone: fixture.time_zone,
+ timeZones: zones,
+ });
+ // postWorkflowAssist sends exactly this text.
+ return { name: item.name, text: JSON.stringify(request) };
+ } catch (problem) {
+ return { name: item.name, refused: problem instanceof Error ? problem.message : String(problem) };
+ }
+ });
+}
+
+function openSession(draft: WorkflowDefinition, options: Row) {
+ const session = new WorkflowAuthoringSession(draft);
+ const errors: string[] = [];
+ session.configure({
+ options: structuredClone(options) as unknown as WorkflowEditorOptions,
+ readOnly: false, saving: false, commandPending: false, selectionId: null,
+ onError: (message: string) => { if (message) errors.push(message); },
+ onRestore: () => {},
+ onRollback: () => {},
+ });
+ return { session, errors };
+}
+
+function serverDiff(changes: readonly Row[]) {
+ return changes.map((change) => ({
+ key: change.key, kind: change.kind, label: change.label, owner_label: change.owner_label,
+ focus_key: change.target?.focus_key, node_id: change.target?.node_id ?? null,
+ }));
+}
+
+function replayResponse(item: ParityCase, options: Row) {
+ const name = item.name;
+ const response = item.response as Row;
+ const sent = JSON.parse(item.request_text as string) as Row;
+ const { live } = editorDraft(item, sent.draft);
+ check(`${name}: the live draft is the draft the tab sent`, jsonEquivalent(live, sent.draft));
+
+ const parsed = parseWorkflowAssistResponse(structuredClone(response), item.submission_id);
+ check(`${name}: the tab reads the assistant's answer`, parsed !== null && parsed.outcome === response.outcome,
+ { outcome: response.outcome, parsed: parsed?.outcome ?? null });
+ check(`${name}: the answer's warnings and documents read as context reach the card`,
+ parsed !== null
+ && sameJson(parsed.warnings.map((warning) => warning.code), response.warnings.map((warning: Row) => warning.code))
+ && sameJson(parsed.contextDocuments, response.context_documents),
+ { warnings: response.warnings, context: response.context_documents, parsed: parsed?.contextDocuments });
+ if (!parsed) return;
+
+ if (response.outcome !== 'changed') {
+ check(`${name}: an answer that changes nothing carries no candidate`,
+ parsed.candidate === null && parsed.changes.length === 0 && response.candidate === null);
+ return;
+ }
+
+ const rebased = rebaseAssistCandidate(live, parsed.candidate as WorkflowDefinition);
+ check(`${name}: laying the candidate over the live draft changes nothing JSON can see`,
+ jsonEquivalent(rebased, response.candidate));
+ const violation = workflowAssistViolation(live, rebased);
+ check(`${name}: the editor accepts the candidate`, violation === '', violation);
+
+ const own = diffWorkflowChanges(live, rebased).changes.map((change) => ({
+ key: change.key, kind: change.kind, label: change.label, owner_label: change.ownerLabel,
+ focus_key: change.target.focusKey, node_id: change.target.nodeId ?? null,
+ }));
+ check(`${name}: the editor's own diff finds the changes and Jump to targets the server reported`,
+ sameJson(own, serverDiff(response.changes)), { editor: own, server: serverDiff(response.changes) });
+
+ const verification = verifyAssistChanges(live, rebased, parsed.changes);
+ check(`${name}: every reported change is verified and none is left over`,
+ verification.unverified.length === 0 && verification.changes.length === response.changes.length
+ && verification.changes.every((change) => change.reported),
+ { unverified: verification.unverified, keys: verification.changes.map((change) => change.key) });
+
+ const wording = verification.changes.map((change) => workflowAssistChangeSummary(change.change));
+ const serverWording = response.changes.map((change: Row) => String(change.summary).replace(/\s+/g, ' ').trim());
+ check(`${name}: the editor words each change as the server does`, sameJson(wording, serverWording),
+ { editor: wording, server: serverWording });
+
+ const applying = openSession(live, options);
+ const turnId = item.submission_id;
+ const result = applying.session.applyAssist(rebased, {
+ turnId, label: workflowAssistTurnLabel('Ask AI', item.instruction),
+ });
+ if (result.status === 'confirmation_required') {
+ console.log(` (${name}: the editor asked to confirm the change first)`);
+ applying.session.confirm();
+ }
+ check(`${name}: applyAssist applies the answer under the turn's id`,
+ isWorkflowHistoryTurnId(turnId) && (result.status === 'applied'
+ || result.status === 'confirmation_required' && applying.session.getSnapshot().pending === null),
+ { result, errors: applying.errors });
+ check(`${name}: the applied draft is the assistant's candidate`,
+ jsonEquivalent(applying.session.draft, response.candidate) && applying.errors.length === 0,
+ { errors: applying.errors });
+}
+
+const fixturePath = process.env.WORKFLOW_ASK_AI_PARITY_FIXTURE;
+if (!fixturePath) {
+ throw new Error('Set WORKFLOW_ASK_AI_PARITY_FIXTURE to the file test_v2_workflow_ask_ai_parity.py writes.');
+}
+const fixture = JSON.parse(readFileSync(fixturePath, 'utf-8')) as ParityFixture;
+
+if (fixture.phase === 'requests') {
+ emit('REQUESTS', buildRequests(fixture));
+} else {
+ fixture.cases.forEach((item) => replayResponse(item, fixture.options));
+ console.log(`\n${fixture.cases.length} answers replayed; ${failures ? `${failures} FAILED` : 'all passed'}`);
+}
+if (failures) process.exitCode = 1;
diff --git a/ui_tests/fixtures/workflow_ask_ai.py b/ui_tests/fixtures/workflow_ask_ai.py
new file mode 100644
index 000000000..25b7bd3b5
--- /dev/null
+++ b/ui_tests/fixtures/workflow_ask_ai.py
@@ -0,0 +1,231 @@
+# workflow_ask_ai.py
+"""
+Browser stubs for the workflow editor's Ask AI tab (Phase 3c).
+Version: 0.261.213
+Implemented in: 0.261.213
+Refs: microsoft/simplechat#1548
+
+`POST /api/user/workflows/assist` is answered inside the page, never by a live model. A normal
+answer comes from 3b's real pipeline (`run_workflow_assist`) over the request the browser sent,
+with a scripted model and the fixture's own records, so its candidate, change list, warnings and
+documents are what the server would send for that draft. A refusal is the real
+`WorkflowAssistError` payload. Both are serialized the way the route does it: strict JSON with
+sorted keys, `Cache-Control: no-store, private`, and `Retry-After` when the error carries one.
+
+`POST /api/workflows/draft-instructions` is answered with fixed instructions, in the route's shape.
+
+A handler never raises: a problem is recorded in `unexpected` and answered with a 500, because a
+raise inside a Playwright route handler would leave the request hanging.
+"""
+
+import copy
+import json
+import sys
+from pathlib import Path
+
+from playwright.sync_api import Error as PlaywrightError
+
+ROOT = Path(__file__).resolve().parents[2]
+for _path in (ROOT / "application" / "single_app", ROOT / "functional_tests"):
+ if str(_path) not in sys.path:
+ sys.path.insert(0, str(_path))
+
+# The application and functional-test helpers are importable only after the path setup above.
+import functions_workflow_assist as core # noqa: E402
+from test_support import workflow_assist as wa # noqa: E402
+
+
+ASSIST_ROUTE = "**/api/user/workflows/assist"
+DRAFT_INSTRUCTIONS_ROUTE = "**/api/workflows/draft-instructions"
+
+
+def _fulfill(route, status, payload, headers=None):
+ route.fulfill(
+ status=status,
+ body=json.dumps(payload, allow_nan=False, sort_keys=True) + "\n",
+ content_type="application/json",
+ headers={"Cache-Control": "no-store, private", **(headers or {})},
+ )
+
+
+def _failure(exc, settings):
+ headers = {"Retry-After": str(exc.retry_after)} if exc.retry_after is not None else {}
+ return exc.status, exc.payload(settings), headers
+
+
+class AssistStub:
+ """Answers each assist request with the next queued answer, optionally holding the response.
+
+ An answer is a callable taking the raw request body and returning ``(status, payload, headers)``.
+ Build them with ``pipeline``, ``changed``, ``said``, ``refused``, ``canned`` or ``custom``.
+ """
+
+ def __init__(self, *, user_id, record_error, options, read_base, resolve, settings=None):
+ self.user_id = user_id
+ self.record_error = record_error
+ self.options = options
+ self.read_base = read_base
+ self.resolve = resolve
+ self.settings = settings if settings is not None else {}
+ self.bodies = []
+ self.headers = []
+ self.answers = []
+ self.models = []
+ self.hold = 0
+ self.held = []
+ self.unexpected = []
+
+ # -- answers -------------------------------------------------------------------------------
+
+ def queue(self, *answers):
+ self.answers.extend(answers)
+ return self
+
+ def pipeline(self, *replies, limiter=None):
+ """3b's pipeline over the sent request, with ``replies`` as the model's scripted answers.
+
+ A reply is the model's text, or a callable that gets the parsed request and returns that
+ text, for answers that name the request's own `task_N` handles.
+ """
+
+ def answer(raw):
+ try:
+ body = core.parse_assist_body(raw)
+ except core.WorkflowAssistError as exc:
+ # The route answers a body it can't read before the run, without Retry-After.
+ return exc.status, exc.payload(self.settings), {}
+ request = json.loads(raw.decode("utf-8"))
+ model = wa.ScriptedModel(*(reply(request) if callable(reply) else reply for reply in replies))
+ self.models.append(model)
+ services = wa.services(
+ model, options=self.options(), resolve=self.resolve, read_base=self.read_base,
+ limiter=limiter,
+ )
+ try:
+ return 200, core.run_workflow_assist(body, user_id=self.user_id, services=services), {}
+ except core.WorkflowAssistError as exc:
+ return _failure(exc, self.settings)
+
+ return answer
+
+ def changed(self, *operations, text="Done."):
+ """A ``changed`` answer. An operation may be a callable that gets the parsed request."""
+ return self.pipeline(lambda body: wa.reply(
+ "changed", text, [operation(body) if callable(operation) else operation for operation in operations],
+ ))
+
+ def said(self, outcome, text):
+ return self.pipeline(wa.reply(outcome, text))
+
+ def refused(self, code, message=None, retry_after=None):
+ """A refusal the pipeline raises before or around the model, as the route answers it."""
+ exc = core.WorkflowAssistError(code, message, retry_after=retry_after)
+ return lambda _raw: _failure(exc, self.settings)
+
+ @staticmethod
+ def canned(status, payload, headers=None):
+ return lambda _raw: (status, copy.deepcopy(payload), dict(headers or {}))
+
+ @staticmethod
+ def custom(build):
+ """``build(body)`` gets the parsed request and returns ``(status, payload, headers)``."""
+ return lambda raw: build(json.loads(raw.decode("utf-8")))
+
+ # -- the route -----------------------------------------------------------------------------
+
+ def handle(self, route):
+ request = route.request
+ if request.method != "POST":
+ self.unexpected.append(f"{request.method} {request.url}")
+ self._send(route, 405, {"error": "Method not allowed."})
+ return
+ headers = request.headers
+ self.headers.append(dict(headers))
+ if "application/json" not in headers.get("content-type", ""):
+ self.unexpected.append(f"assist request without a JSON body: {headers.get('content-type')!r}")
+ raw = request.post_data_buffer or b""
+ try:
+ self.bodies.append(json.loads(raw.decode("utf-8")))
+ except (UnicodeDecodeError, ValueError):
+ self.bodies.append(None)
+ self.unexpected.append("assist request body was not JSON")
+ if not self.answers:
+ self.unexpected.append("an assist request arrived with no answer queued")
+ self._send(route, 500, core.WorkflowAssistError("assistant_failed").payload())
+ return
+ answer = self.answers.pop(0)
+ try:
+ status, payload, reply_headers = answer(raw)
+ except Exception as exc: # A raise here would leave the browser waiting forever.
+ self.unexpected.append(f"the assist stub failed: {type(exc).__name__}: {exc}")
+ status, payload, reply_headers = 500, core.WorkflowAssistError("assistant_failed").payload(), {}
+ if self.hold:
+ self.hold -= 1
+ if status >= 400:
+ self.record_error(request.url, status)
+ self.held.append((route, status, payload, reply_headers))
+ return
+ self._send(route, status, payload, reply_headers)
+
+ def _send(self, route, status, payload, headers=None):
+ if status >= 400:
+ self.record_error(route.request.url, status)
+ _fulfill(route, status, payload, headers)
+
+ def release(self):
+ """Answer every held request. One the browser cancelled is gone, and that is fine."""
+ held, self.held = self.held, []
+ for route, status, payload, headers in held:
+ try:
+ _fulfill(route, status, payload, headers)
+ except PlaywrightError:
+ # A request the browser cancelled has no route left to answer.
+ pass
+
+ def wait_for_requests(self, page, count, timeout_ms=10000):
+ waited = 0
+ while len(self.bodies) < count:
+ if waited >= timeout_ms:
+ raise AssertionError(f"expected {count} assist requests, saw {len(self.bodies)}")
+ page.wait_for_timeout(50)
+ waited += 50
+ return self.bodies[count - 1]
+
+ def assert_clean(self):
+ assert not self.unexpected, self.unexpected
+ assert not self.answers, f"{len(self.answers)} queued assist answer(s) were never requested"
+ assert not self.held and not self.hold, "an assist response was left held"
+
+
+class DraftInstructionsStub:
+ """Answers the draft-instructions route with the next queued instructions, or an error."""
+
+ def __init__(self, *, record_error):
+ self.record_error = record_error
+ self.bodies = []
+ self.answers = []
+ self.unexpected = []
+
+ def queue(self, instructions=None, *, status=200, error=None):
+ self.answers.append((status, {"success": True, "instructions": instructions} if status == 200 else {"error": error}))
+ return self
+
+ def handle(self, route):
+ request = route.request
+ try:
+ self.bodies.append(request.post_data_json)
+ except (ValueError, PlaywrightError):
+ self.bodies.append(None)
+ self.unexpected.append("draft-instructions body was not JSON")
+ if request.method != "POST" or not self.answers:
+ self.unexpected.append(f"unexpected {request.method} {request.url}")
+ status, payload = 500, {"error": "Failed to draft workflow instructions."}
+ else:
+ status, payload = self.answers.pop(0)
+ if status >= 400:
+ self.record_error(request.url, status)
+ route.fulfill(status=status, json=payload)
+
+ def assert_clean(self):
+ assert not self.unexpected, self.unexpected
+ assert not self.answers, "a queued draft-instructions answer was never requested"
diff --git a/ui_tests/test_v2_workflow_ask_ai.py b/ui_tests/test_v2_workflow_ask_ai.py
new file mode 100644
index 000000000..9e5ea3793
--- /dev/null
+++ b/ui_tests/test_v2_workflow_ask_ai.py
@@ -0,0 +1,1606 @@
+# test_v2_workflow_ask_ai.py
+"""
+Offline real-bundle browser regressions for the workflow editor's Ask AI tab (Phase 3c).
+Version: 0.261.213
+Implemented in: 0.261.213
+Refs: microsoft/simplechat#1548
+
+Covers the "Done when" instruction end to end (highlights, Previously, Undo this change, a single
+field Revert and Confirm and save, with the Run as case), the three `#` placement outcomes and a
+document read only as context, immediate send with the editor lock, its timer, Cancel and Retry,
+the stale-draft guard, the request each kind of draft sends, code-point counting, Undo with a key
+that changed later, every status the assist route answers, a reload after a conflict that is closed,
+superseded or overtaken by a save while it loads or that fails, hostile model text, Jump to on a
+task field and on a flow block, where the tab is hidden, the keyboard, a narrow screen, Draft with
+AI on a List task and on a Flow block (each under 6x CPU throttling, so focus must reach the
+drafted instructions before their highlight renders and must stay there), the quick actions, and a
+card from an earlier editing session.
+
+`POST /api/user/workflows/assist` is answered in the page by 3b's real pipeline with a scripted
+model (see `fixtures/workflow_ask_ai.py`), so no live model, workflow run, approval or Azure
+service is contacted. Reuses the closed Flow authoring harness, its local production assets and
+the real compiler. Run with PLAYWRIGHT_SERVICE_URL='' and PYTHONPATH including
+application/single_app, functional_tests and ui_tests/fixtures.
+"""
+
+import copy
+import json
+import re
+import sys
+from contextlib import contextmanager
+from pathlib import Path
+from urllib.parse import urlsplit
+
+import pytest
+from playwright.sync_api import expect
+
+ROOT = Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(ROOT))
+sys.path.insert(0, str(ROOT / "ui_tests"))
+sys.path.insert(0, str(ROOT / "ui_tests" / "fixtures"))
+
+# The shared fixtures import pure application helpers after setting their paths.
+from ui_tests import test_v2_workflow_flow_authoring as authoring
+from ui_tests.fixtures.workflow_ask_ai import (
+ ASSIST_ROUTE,
+ DRAFT_INSTRUCTIONS_ROUTE,
+ AssistStub,
+ DraftInstructionsStub,
+ core,
+)
+from ui_tests.fixtures.workflow_editor import OWNER_ID, WORKFLOW_ID
+from ui_tests.fixtures.workflow_flow import FLOW_WORKFLOW_ID, GROUP_ID
+from ui_tests.fixtures.workspace_authoring import ORIGIN
+from ui_tests.test_v2_workflow_change_tracking import (
+ RUN_AS_NOTE,
+ UNSUPPORTED_SCHEDULE,
+ assert_dialog_fits,
+ author,
+ changed,
+ changes_toggle,
+ open_classic,
+ open_panel,
+ previously,
+ side_panel,
+ task_item,
+)
+from ui_tests.test_v2_workflow_flow_authoring import connect_options # noqa: F401
+from functions_workflow_definitions import workflow_definition_revision # noqa: E402
+from functions_workflow_flow import compile_workflow_flow # noqa: E402
+
+
+TIME_ZONE = "America/Chicago"
+pytestmark = [pytest.mark.ui, pytest.mark.browser_context_args(timezone_id=TIME_ZONE)]
+
+DONE_WHEN = "run this at 7 AM on weekdays and only alert me when something is urgent"
+WEEKDAYS_AT_SEVEN = {"op": "set_schedule_calendar", "frequency": "weekdays", "time_of_day": "07:00"}
+URGENT_ONLY = [
+ {"op": "set_alert_mode", "mode": "rules"},
+ {
+ "op": "add_alert_rule", "name": "Urgent findings", "severity": "high",
+ "condition": {"type": "model_evaluation", "prompt": "The results include something urgent."},
+ },
+]
+REQUEST_KEYS = {"submission_id", "base", "instruction", "conversation", "focus", "time_zone", "draft", "references"}
+SUBMISSION_ID = re.compile(r"^[A-Za-z0-9._:-]{1,128}$")
+BRIEF_REF = {"kind": "document", "id": "personal-brief", "scope": {"kind": "personal", "id": None}, "label": "Private brief"}
+BRIEF_KEY = "reference:personal%3Aworkspace-editor-user%3Apersonal-brief"
+STALE_DRAFT = "Nothing was applied because the draft changed while Ask AI was working. Send it again to use the current draft."
+CANCELLED = "Cancelled. Nothing was changed."
+EMAIL_MESSAGE = (
+ "This task sends email, which needs an agent with the Microsoft 365 action. "
+ "Choose such an agent for the task or the workflow."
+)
+M365_MESSAGE = "Connect your Microsoft 365 account so this workflow can send email."
+DRAFT_ERRORS_MESSAGE = "The draft already had a problem that saving will report. The assistant did not cause it; fix it before saving."
+RUN_AS_WARNING = "Saving this change requires re-approving Run as, because it changes how the workflow runs."
+NEEDS_NAME = "Name the workflow or this task first, so the assistant knows what to draft."
+UNREADABLE = "The assistant's answer couldn't be read. Nothing was changed."
+UNUSABLE = "The assistant's answer couldn't be used. Nothing was changed. Try again."
+EXPLAIN = "Explain what this workflow does, step by step. Don't change anything."
+WAIT_NOTE = re.compile(r"^You can send again in [1-9]\d* s\.$")
+# Model and admin text that would run, link or format if it were ever rendered as HTML or Markdown.
+HOSTILE = (
+ ' [Open this](javascript:window.__hostile=2) '
+ "**urgent** "
+)
+RATE_LIMIT_TEXT = (
+ "**Slow down.**\n\nSee [the policy](https://example.com/policy) "
+ ' and wait a moment.'
+)
+MARKUP = "img, script, a, strong, b, em, i"
+
+
+class AskAiFixture(authoring.WorkflowAuthoringFixture):
+ """The Flow authoring harness with Ask AI on for this user and its two routes stubbed in the page."""
+
+ def __init__(self, page):
+ super().__init__(page)
+ self.ask_ai_enabled = True
+ self.document_searches = []
+
+ def record_error(url, status):
+ self.expected_http_errors.add((url, status))
+
+ self.assist = AssistStub(
+ user_id=OWNER_ID, record_error=record_error, options=self._assist_options,
+ read_base=self._read_base, resolve=self._resolve,
+ )
+ self.drafts = DraftInstructionsStub(record_error=record_error)
+ # page.route takes precedence over the harness's context route, so neither request reaches it.
+ page.route(ASSIST_ROUTE, self.assist.handle)
+ page.route(DRAFT_INSTRUCTIONS_ROUTE, self.drafts.handle)
+
+ def _bootstrap(self):
+ payload = super()._bootstrap()
+ # The bootstrap sends this per user: on only where the setting, personal workflows and the role allow it.
+ payload["features"]["enable_workflow_ai_assistant"] = self.ask_ai_enabled
+ return payload
+
+ def _options(self, group):
+ options = super()._options(group)
+ options["schedule"]["timezones"] = [
+ zone for zone in options["schedule"]["timezones"] if zone not in self.unlisted_schedule_timezones
+ ]
+ return options
+
+ def _assist_options(self):
+ """The personal editor options the browser was given, which the pipeline checks the draft against."""
+ options = self._options(False)
+ options["max_tasks"] = self.max_tasks
+ options["can_manage"] = True
+ return options
+
+ def _read_base(self, user_id, workflow_id):
+ stored = self.personal_workflows.get(workflow_id)
+ if stored is None or user_id != OWNER_ID:
+ return None
+ record = copy.deepcopy(stored)
+ record.setdefault("user_id", OWNER_ID)
+ return record
+
+ def _resolve(self, user_id, references):
+ """`resolve_scope_references` for this user's own documents; anything else is unavailable."""
+ documents = {document["id"]: document for document in self.documents}
+ resolved = []
+ for reference in references:
+ document = documents.get(reference.get("id"))
+ if reference.get("kind") != "document" or (reference.get("scope") or {}).get("kind") != "personal" \
+ or document is None:
+ raise core.WorkflowAssistError("reference_unavailable")
+ resolved.append({
+ "kind": "document", "id": document["id"], "label": document["file_name"],
+ "scope": {"kind": "personal", "id": user_id, "name": "Personal"},
+ })
+ return resolved
+
+ def _dispatch(self, route, entry):
+ # The # menu searches personal and group documents, the group route with one comma-joined list.
+ if entry.method == "GET" and entry.path in ("/api/documents", "/api/group_documents"):
+ if entry.path == "/api/documents":
+ documents = self.documents
+ else:
+ group_ids = [
+ group_id for value in entry.query.get("group_ids", []) for group_id in value.split(",") if group_id
+ ]
+ if not group_ids or any(group_id not in self.group_workflows for group_id in group_ids):
+ self.unexpected_requests.append(f"GET {entry.path} for {group_ids}")
+ documents = [document for group_id in group_ids for document in self.group_documents.get(group_id, [])]
+ search = entry.query.get("search", [""])[0].strip().lower()
+ if search:
+ self.document_searches.append((entry.path, search))
+ documents = [
+ document for document in documents
+ if search in f"{document.get('title', '')} {document.get('file_name', '')}".lower()
+ ]
+ self._json(route, {"documents": documents, "total_count": len(documents)})
+ return
+ super()._dispatch(route, entry)
+
+ def fail_held_responses(self, status=503):
+ """Answer every held request with a failure, as a `reject_next` that waited would."""
+ pending, self.pending_responses = self.pending_responses, []
+ for route, _ in pending:
+ self._json(route, {"error": "Fixture request failed."}, status)
+
+ def release_held(self, method, path):
+ """Answer the one held `method path` request, and leave the others held."""
+ matches = [
+ index for index, (_, entry) in enumerate(self.pending_responses)
+ if (entry.method, entry.path) == (method, path)
+ ]
+ assert len(matches) == 1, f"Expected one held {method} {path}, found {len(matches)}."
+ route, entry = self.pending_responses.pop(matches[0])
+ self._dispatch(route, entry)
+
+ def assert_clean(self):
+ self.assist.assert_clean()
+ self.drafts.assert_clean()
+ for page in self.pages:
+ if not page.is_closed() and page.url.startswith(ORIGIN):
+ assert page.evaluate("() => window.__hostile ?? null") is None, "Model text ran as HTML."
+ super().assert_clean()
+
+
+@pytest.fixture
+def ask_ui(page):
+ fixture = AskAiFixture(page)
+ yield fixture
+ try:
+ fixture.assert_clean()
+ finally:
+ fixture.assist.release()
+ fixture.release_flow_responses()
+ fixture.release_save_responses()
+ fixture.flow_response_gates.clear()
+
+
+# ---------------------------------------------------------------------------------------------
+# Helpers
+# ---------------------------------------------------------------------------------------------
+
+def ask_toggle(editor):
+ return editor.get_by_role("button", name="Ask AI", exact=True)
+
+
+def composer(scope):
+ return scope.get_by_role("textbox", name="Message Ask AI", exact=True)
+
+
+def send_button(scope):
+ return scope.get_by_role("button", name="Send to Ask AI", exact=True)
+
+
+def open_ask_ai(editor):
+ toggle = ask_toggle(editor)
+ toggle.click()
+ expect(toggle).to_have_attribute("aria-expanded", "true")
+ panel = side_panel(editor)
+ expect(panel.get_by_role("tab", name="Ask AI", exact=True)).to_have_attribute("aria-selected", "true")
+ expect(composer(panel)).to_be_focused()
+ return panel
+
+
+def exchanges(panel):
+ return panel.locator("li[data-testid='assist-exchange']")
+
+
+def card(panel, submission_id):
+ return panel.locator(f"[data-workflow-assist-card='{submission_id}']")
+
+
+def type_and_send(panel, text):
+ box = composer(panel)
+ box.fill(text)
+ box.press("Enter")
+
+
+def ask(ui, panel, text):
+ """Send `text` and return the request body the assist route received."""
+ count = len(ui.assist.bodies)
+ type_and_send(panel, text)
+ return ui.assist.wait_for_requests(ui.page, count + 1)
+
+
+def answered(panel, body):
+ """The answer card for a request, once it is in."""
+ found = card(panel, body["submission_id"])
+ expect(found).to_be_visible()
+ return found
+
+
+def failed(panel):
+ exchange = exchanges(panel).last
+ expect(exchange).to_have_attribute("data-status", "failed")
+ return exchange
+
+
+def change_keys(found):
+ return found.locator("li[data-workflow-assist-change]").evaluate_all(
+ "items => items.map((item) => item.dataset.workflowAssistChange)")
+
+
+def undo_button(found):
+ return found.get_by_role("button", name=re.compile(r"^Undo this change: "))
+
+
+def with_warnings(answer, *warnings):
+ """An answer with more warnings after the pipeline's own, for the codes a fixture can't provoke."""
+
+ def wrapped(raw):
+ status, payload, headers = answer(raw)
+ payload = copy.deepcopy(payload)
+ payload["warnings"] = [*payload.get("warnings", []), *copy.deepcopy(warnings)]
+ return status, payload, headers
+
+ return wrapped
+
+
+def confirm_save(ui, editor, *, run_as=False):
+ """Save with AI changes: Review before saving opens first, and only Confirm and save writes."""
+ before = len(ui.workflow_writes)
+ editor.get_by_role("button", name="Save workflow", exact=True).click()
+ panel = side_panel(editor)
+ expect(panel.get_by_role("tab", name="Changes", exact=True)).to_have_attribute("aria-selected", "true")
+ review = panel.get_by_role("region", name="Review before saving", exact=True)
+ expect(review.get_by_role("heading", name="Review before saving", exact=True)).to_be_focused()
+ # The Run as note also heads the tab, so the review's own line is the one checked.
+ expect(review.locator("li").filter(has_text=RUN_AS_NOTE)).to_have_count(1 if run_as else 0)
+ assert len(ui.workflow_writes) == before, "Save wrote before Confirm and save."
+ review.get_by_role("button", name="Confirm and save", exact=True).click()
+ expect(editor).to_have_count(0)
+ assert len(ui.workflow_writes) == before + 1
+ ui.assert_authoring_only()
+ return ui.workflow_writes[-1].body
+
+
+def pick_brief(ui, panel, before="Use "):
+ """Type `before`, then pick Private brief from the # menu by keyboard."""
+ box = composer(panel)
+ box.fill(before)
+ # Typed, not filled: fill() sends no key events, so the # menu would not open until the next key.
+ box.press_sequentially("#priv")
+ options = ui.page.get_by_role("listbox", name="Context suggestions").get_by_role("option")
+ expect(options.first).to_contain_text("Private brief")
+ expect(options.first).to_have_attribute("aria-selected", "true")
+ box.press("Enter")
+ expect(box).to_have_value(f"{before}#[Private brief] ")
+ return box
+
+
+# ---------------------------------------------------------------------------------------------
+# Done when
+# ---------------------------------------------------------------------------------------------
+
+def test_done_when_instruction_is_highlighted_undone_reverted_and_confirmed(ask_ui):
+ ui, page = ask_ui, ask_ui.page
+ reply = "Scheduled for 7 AM on weekdays, with alerts only for urgent results."
+ ui.assist.queue(
+ ui.assist.changed(WEEKDAYS_AT_SEVEN, *URGENT_ONLY, text=reply),
+ ui.assist.changed(WEEKDAYS_AT_SEVEN, *URGENT_ONLY, text=reply),
+ )
+ editor = open_classic(ui)
+ panel = open_ask_ai(editor)
+
+ body = ask(ui, panel, DONE_WHEN)
+ assert set(body) == REQUEST_KEYS
+ assert SUBMISSION_ID.fullmatch(body["submission_id"])
+ stored = ui.personal_workflows[WORKFLOW_ID]
+ assert body["base"] == {"workflow_id": WORKFLOW_ID, "definition_revision": stored["definition_revision"]}
+ assert body["draft"]["id"] == WORKFLOW_ID
+ assert body["instruction"] == DONE_WHEN
+ assert body["conversation"] == [] and body["references"] == [] and body["focus"] is None
+ assert body["time_zone"] == TIME_ZONE
+
+ first = answered(panel, body)
+ expect(first).to_have_attribute("data-outcome", "changed")
+ expect(first).to_have_attribute("data-state", "applied")
+ expect(first.locator("[data-workflow-assist-reply]")).to_have_text(reply)
+ expect(first.locator("[data-workflow-assist-state]")).to_have_text(
+ "Changed 2 things in the draft. Review before saving.")
+ assert change_keys(first) == ["schedule", "alerts"]
+ expect(first.locator("li[data-workflow-assist-change='schedule']")).to_contain_text("Workflow: Trigger and schedule")
+ expect(first.locator("li[data-workflow-assist-change='alerts']")).to_contain_text("Workflow: Alerts")
+ expect(composer(panel)).to_be_focused()
+
+ schedule, alerts = changed(editor, "schedule"), changed(editor, "alerts")
+ expect(author(schedule)).to_have_text("AI assist")
+ expect(author(schedule)).to_have_attribute("data-workflow-change-author", "ai")
+ expect(author(alerts)).to_have_text("AI assist")
+ expect(previously(schedule)).to_contain_text("Manual")
+ expect(editor.get_by_label("Trigger", exact=True)).to_have_value("interval")
+ expect(editor.get_by_label("Repeats", exact=True)).to_have_value("weekdays")
+ expect(editor.get_by_label("Time", exact=True)).to_have_value("07:00")
+ expect(editor.get_by_label("Time zone", exact=True)).to_have_value(TIME_ZONE)
+ expect(editor.get_by_label("Alert rule 1 name", exact=True)).to_have_value("Urgent findings")
+ expect(changes_toggle(editor)).to_have_accessible_name("Changes (2 unsaved)")
+
+ undo_button(first).click()
+ result = first.locator("[data-workflow-assist-undo]")
+ expect(result).to_have_attribute("data-workflow-assist-undo", "applied")
+ expect(result).to_be_focused()
+ expect(result.locator("p").first).to_have_text("Undone: 2 reverted.")
+ expect(result.get_by_role("list", name="Reverted").get_by_role("listitem")).to_have_text(
+ ["Trigger and schedule", "Alerts"])
+ expect(first).to_have_attribute("data-state", "undone")
+ expect(first.locator("[data-workflow-assist-state]")).to_have_text("These changes were undone.")
+ expect(changed(editor, "schedule")).to_have_count(0)
+ expect(changed(editor, "alerts")).to_have_count(0)
+ expect(editor.get_by_label("Trigger", exact=True)).to_have_value("manual")
+ expect(changes_toggle(editor)).to_have_accessible_name("Changes (0 unsaved)")
+
+ # The next turn replays the first as its reply plus what happened to its changes.
+ again = ask(ui, panel, "Apply that schedule and alert again.")
+ assert again["conversation"] == [
+ {"role": "user", "text": DONE_WHEN},
+ {"role": "assistant", "text": (
+ f"{reply}\n\nThese changes were applied and later undone: "
+ "Workflow: Trigger and schedule; Workflow: Alerts."
+ )},
+ ]
+ second = answered(panel, again)
+ expect(second).to_have_attribute("data-state", "applied")
+ expect(author(changed(editor, "alerts"))).to_have_text("AI assist")
+
+ changed(editor, "alerts").get_by_role("button", name="Revert", exact=True).click()
+ expect(changed(editor, "alerts")).to_have_count(0)
+ expect(author(changed(editor, "schedule"))).to_have_text("AI assist")
+ expect(changes_toggle(editor)).to_have_accessible_name("Changes (1 unsaved)")
+
+ payload = confirm_save(ui, editor)
+ assert payload["trigger_type"] == "interval"
+ assert payload["schedule"]["kind"] == "calendar"
+ assert payload["schedule"]["frequency"] == "weekdays"
+ assert payload["schedule"]["time_of_day"] == "07:00"
+ assert payload["schedule"]["timezone"] == TIME_ZONE
+ assert "Urgent findings" not in json.dumps(payload)
+ assert len(ui.assist.bodies) == 2
+ page.wait_for_timeout(100)
+
+
+def test_run_as_warnings_and_review_before_saving(ask_ui):
+ ui = ask_ui
+ ui.personal_workflows[WORKFLOW_ID]["m365_run_as_user_id"] = OWNER_ID
+ instructions = "Collect only signed evidence."
+ ui.assist.queue(with_warnings(
+ ui.assist.changed({"op": "set_task_instructions", "task": "task_1", "instructions": instructions}),
+ {"code": "email_requires_m365_agent", "message": EMAIL_MESSAGE, "target": {"focus_key": "task:task-b"}},
+ {"code": "m365_not_connected", "message": M365_MESSAGE},
+ {"code": "draft_has_errors", "message": DRAFT_ERRORS_MESSAGE},
+ ))
+ editor = open_classic(ui)
+ expect(editor.get_by_label("Microsoft 365 Run as", exact=True)).to_have_value(OWNER_ID)
+ panel = open_ask_ai(editor)
+ found = answered(panel, ask(ui, panel, "Collect only signed evidence in the first task."))
+
+ assert change_keys(found) == ["task:task-a:instructions"]
+ warnings = found.get_by_role("list", name="Warnings").locator("li[data-workflow-assist-warning]")
+ expect(warnings).to_have_count(4)
+ assert warnings.evaluate_all("items => items.map((item) => item.dataset.workflowAssistWarning)") == [
+ "run_as_reapproval", "email_requires_m365_agent", "m365_not_connected", "draft_has_errors",
+ ]
+ expect(warnings).to_have_text([RUN_AS_WARNING, f"{EMAIL_MESSAGE}Jump to", M365_MESSAGE, DRAFT_ERRORS_MESSAGE])
+ frame = changed(editor, "task:task-a:instructions")
+ expect(author(frame)).to_have_text("AI assist")
+ expect(frame.get_by_label("Instructions", exact=True)).to_have_value(instructions)
+
+ found.get_by_role("button", name=f"Jump to: {EMAIL_MESSAGE}", exact=True).click()
+ expect(task_item(editor, "task-b").get_by_label("Task name", exact=True)).to_be_focused()
+
+ payload = confirm_save(ui, editor, run_as=True)
+ assert payload["m365_run_as_user_id"] == OWNER_ID
+ assert payload["tasks"][0]["instructions"] == instructions
+
+
+# ---------------------------------------------------------------------------------------------
+# # references
+# ---------------------------------------------------------------------------------------------
+
+@pytest.mark.parametrize("placement", ["shared", "document", "question", "context"])
+def test_hash_document_placements_and_context(ask_ui, placement):
+ ui = ask_ui
+ answers = {
+ "shared": ui.assist.changed({"op": "bind_reference", "document": "ref_1", "tasks": ["task_2"]}),
+ "document": ui.assist.changed({
+ "op": "set_task_document_target", "task": "task_2", "action": "analyze", "documents": ["ref_1"],
+ }),
+ "question": ui.assist.said("question", "Should both tasks read Private brief, or only the summary?"),
+ "context": ui.assist.said("explained", "The brief asks for a quarterly review of signed evidence."),
+ }
+ ui.assist.queue(answers[placement])
+ editor = open_classic(ui)
+ panel = open_ask_ai(editor)
+ box = pick_brief(ui, panel)
+ box.press_sequentially("for the summary task")
+ box.press("Enter")
+ body = ui.assist.wait_for_requests(ui.page, 1)
+ assert body["instruction"] == "Use #[Private brief] for the summary task"
+ assert body["references"] == [BRIEF_REF]
+ assert ui.document_searches and all(search == "priv" for _, search in ui.document_searches[-2:])
+
+ found = answered(panel, body)
+ expect(exchanges(panel).last.get_by_test_id("assist-reference-chip")).to_have_text("Document: Private brief")
+ context = found.locator("[data-workflow-assist-context]")
+ if placement == "shared":
+ assert change_keys(found) == [BRIEF_KEY, "task:task-a:reference_ids"]
+ expect(author(changed(editor, "task:task-a:reference_ids"))).to_have_text("AI assist")
+ shared = editor.get_by_role("region", name="Workflow shared references", exact=True)
+ expect(shared.locator(f"[data-workflow-change-key='{BRIEF_KEY}'] [data-workflow-change-author='ai']")).to_have_text(
+ "Added · AI assist")
+ expect(context).to_have_count(0)
+ elif placement == "document":
+ assert change_keys(found) == ["task:task-b:document_action"]
+ expect(author(changed(editor, "task:task-b:document_action"))).to_have_text("AI assist")
+ expect(context).to_have_count(0)
+ else:
+ expect(found).to_have_attribute("data-outcome", "question" if placement == "question" else "explained")
+ expect(found.get_by_text("Question", exact=True)).to_have_count(1 if placement == "question" else 0)
+ expect(found.locator("li[data-workflow-assist-change]")).to_have_count(0)
+ expect(context).to_have_text("Read as context: private-brief.pdf")
+ expect(editor.locator("[data-workflow-change-key]")).to_have_count(0)
+ expect(changes_toggle(editor)).to_have_accessible_name("Changes (0 unsaved)")
+ assert not ui.workflow_writes
+
+
+# ---------------------------------------------------------------------------------------------
+# Sending, the lock, Cancel and Retry
+# ---------------------------------------------------------------------------------------------
+
+def test_send_is_immediate_and_locks_the_editor_until_cancel_then_retry(ask_ui):
+ ui, page = ask_ui, ask_ui.page
+ description = "Reviews signed evidence every quarter."
+ ui.assist.hold = 1
+ ui.assist.queue(
+ ui.assist.changed({"op": "set_description", "description": description}),
+ ui.assist.changed({"op": "set_description", "description": description}),
+ )
+ editor = open_classic(ui)
+ panel = open_ask_ai(editor)
+ box = composer(panel)
+ type_and_send(panel, "Describe what this workflow reviews.")
+
+ exchange = exchanges(panel).last
+ expect(exchange).to_have_attribute("data-status", "pending")
+ expect(exchange).to_contain_text("Describe what this workflow reviews.")
+ expect(exchange).to_contain_text("Working…")
+ expect(box).to_have_value("")
+ expect(box).to_be_focused()
+ body = ui.assist.wait_for_requests(page, 1)
+
+ lock = editor.locator("[data-workflow-assist-lock]")
+ expect(lock).to_contain_text("Ask AI is working. The editor is locked until it answers.")
+ expect(lock.get_by_test_id("workflow-assist-lock-elapsed")).to_have_text(re.compile(r"^[1-9]\d* s$"), timeout=4000)
+ # A disabled fieldset disables every control in it; Playwright's to_be_disabled ignores the fieldset itself.
+ expect(editor.locator("fieldset[aria-busy='true']")).to_have_attribute("disabled", "")
+ expect(editor.get_by_label("Workflow name", exact=True)).to_be_disabled()
+ expect(editor.get_by_role("button", name="Save workflow", exact=True)).to_be_disabled()
+ expect(send_button(panel)).to_be_disabled()
+ quick = panel.get_by_role("group", name="Quick actions", exact=True).get_by_role("button")
+ expect(quick).to_have_count(5)
+ for index in range(5):
+ expect(quick.nth(index)).to_be_disabled()
+
+ lock.get_by_role("button", name="Cancel request", exact=True).click()
+ expect(exchange).to_have_attribute("data-status", "cancelled")
+ expect(exchange).to_contain_text(CANCELLED)
+ expect(lock).to_have_count(0)
+ expect(editor.get_by_label("Workflow name", exact=True)).to_be_enabled()
+ expect(box).to_be_focused()
+
+ # The answer that arrives after Cancel changes nothing.
+ ui.assist.release()
+ page.wait_for_timeout(300)
+ expect(exchange).to_have_attribute("data-status", "cancelled")
+ expect(changed(editor, "description")).to_have_count(0)
+ expect(editor.locator("section[aria-label='Workflow basics']").get_by_label("Description", exact=True)).to_have_value(
+ ui.personal_workflows[WORKFLOW_ID]["description"])
+
+ exchange.get_by_role("button", name="Retry", exact=True).click()
+ retried = ui.assist.wait_for_requests(page, 2)
+ assert retried["submission_id"] == body["submission_id"]
+ assert retried["conversation"] == []
+ found = answered(panel, retried)
+ expect(found).to_have_attribute("data-state", "applied")
+ expect(author(changed(editor, "description"))).to_have_text("AI assist")
+ expect(exchanges(panel)).to_have_count(1)
+ assert not ui.workflow_writes
+
+
+def basics_description(editor):
+ return editor.locator("section[aria-label='Workflow basics']").get_by_label("Description", exact=True)
+
+
+def test_an_answer_for_a_draft_that_changed_meanwhile_applies_nothing(ask_ui):
+ ui, page = ask_ui, ask_ui.page
+ instructions, description = "Collect only signed evidence.", "Reviews signed evidence."
+ operation = {"op": "set_task_instructions", "task": "task_1", "instructions": instructions}
+ ui.assist.hold = 1
+ ui.assist.queue(ui.assist.changed(operation), ui.assist.changed(operation))
+ editor = open_classic(ui)
+ panel = open_ask_ai(editor)
+ type_and_send(panel, "Collect only signed evidence in the first task.")
+ body = ui.assist.wait_for_requests(page, 1)
+
+ # The lock is the first guard. This stands in for any edit that gets past it.
+ fieldset = editor.locator("fieldset[aria-busy='true']")
+ expect(fieldset).to_have_attribute("disabled", "")
+ fieldset.evaluate("(element) => { element.disabled = false; }")
+ basics_description(editor).fill(description)
+ expect(author(changed(editor, "description"))).to_have_text("Edited")
+ ui.assist.release()
+
+ exchange = failed(panel)
+ expect(exchange.get_by_role("alert")).to_have_text(STALE_DRAFT)
+ expect(card(panel, body["submission_id"])).to_have_count(0)
+ expect(editor.locator("[data-workflow-change-author='ai']")).to_have_count(0)
+ expect(task_item(editor, "task-a").get_by_label("Instructions", exact=True)).to_have_value("Collect source evidence.")
+ expect(changes_toggle(editor)).to_have_accessible_name("Changes (1 unsaved)")
+
+ # Retry is the same turn, sent with the draft as it is now.
+ exchange.get_by_role("button", name="Retry", exact=True).click()
+ retried = ui.assist.wait_for_requests(page, 2)
+ assert retried["submission_id"] == body["submission_id"]
+ assert retried["draft"]["description"] == description
+ assert retried["conversation"] == []
+ found = answered(panel, retried)
+ assert change_keys(found) == ["task:task-a:instructions"]
+ expect(author(changed(editor, "task:task-a:instructions"))).to_have_text("AI assist")
+ expect(author(changed(editor, "description"))).to_have_text("Edited")
+ expect(basics_description(editor)).to_have_value(description)
+ expect(changes_toggle(editor)).to_have_accessible_name("Changes (2 unsaved)")
+ assert not ui.workflow_writes
+
+
+def test_a_new_draft_sends_no_base_and_no_id_and_saves_after_review(ask_ui):
+ ui, page = ask_ui, ask_ui.page
+ name, instructions = "Evidence review", "Collect the quarter's signed evidence."
+ ui.assist.queue(ui.assist.changed(
+ {"op": "set_name", "name": name},
+ {"op": "set_task_instructions", "task": "task_1", "instructions": instructions},
+ ))
+ ui.open("/workspace/workflows")
+ page.get_by_role("button", name="Create workflow", exact=True).click()
+ editor = page.get_by_role("dialog", name="Create workflow", exact=True)
+ expect(editor).to_be_visible()
+ editor.get_by_label("Model", exact=True).select_option(label="Workspace GPT · aoai")
+ panel = open_ask_ai(editor)
+
+ body = ask(ui, panel, "Call this Evidence review and have the task collect signed evidence.")
+ assert set(body) == REQUEST_KEYS
+ assert body["base"] is None
+ assert "id" not in body["draft"]
+ assert body["draft"]["name"] == ""
+ task_id = body["draft"]["tasks"][0]["id"]
+ found = answered(panel, body)
+ assert change_keys(found) == ["name", f"task:{task_id}:instructions"]
+ # A new draft points out only what the assistant changed.
+ expect(author(changed(editor, "name"))).to_have_text("AI assist")
+ expect(author(changed(editor, f"task:{task_id}:instructions"))).to_have_text("AI assist")
+ expect(editor.locator("[data-workflow-change-author='user']")).to_have_count(0)
+ expect(changes_toggle(editor)).to_have_accessible_name("Changes (2 unsaved)")
+
+ payload = confirm_save(ui, editor)
+ assert payload["name"] == name
+ assert payload["tasks"][0]["instructions"] == instructions
+ assert not payload.get("id")
+
+
+def test_task_focus_scopes_turns_and_only_completed_turns_are_replayed(ask_ui):
+ ui, page = ask_ui, ask_ui.page
+ # A browser zone the schedule editor doesn't list is left out, so the server uses its default.
+ ui.unlisted_schedule_timezones = {TIME_ZONE}
+ reply, later = "The summary task condenses the evidence into findings.", "Both tasks run in order."
+ ui.assist.queue(
+ ui.assist.refused("assistant_output_invalid"),
+ ui.assist.said("explained", "This answer arrives after Cancel."),
+ ui.assist.said("explained", reply),
+ ui.assist.said("explained", later),
+ )
+ editor = open_classic(ui)
+ editor.get_by_role("button", name="Ask AI about this task: Summarize evidence", exact=True).click()
+ panel = side_panel(editor)
+ expect(panel.get_by_role("tab", name="Ask AI", exact=True)).to_have_attribute("aria-selected", "true")
+ chip = panel.locator("[data-workflow-assist-focus='task-b']")
+ expect(chip).to_contain_text("About: Summarize evidence")
+ expect(composer(panel)).to_be_focused()
+ expect(composer(panel)).to_have_attribute("aria-describedby", re.compile(r"\S"))
+
+ first = ask(ui, panel, "Make this task shorter.")
+ assert first["focus"] == "task-b"
+ assert "time_zone" not in first
+ expect(failed(panel).get_by_role("alert")).to_have_text(core._ERRORS["assistant_output_invalid"][1])
+
+ ui.assist.hold = 1
+ second = ask(ui, panel, "What does it read?")
+ assert second["focus"] == "task-b" and second["conversation"] == []
+ editor.locator("[data-workflow-assist-lock]").get_by_role("button", name="Cancel request", exact=True).click()
+ expect(exchanges(panel).last).to_have_attribute("data-status", "cancelled")
+ ui.assist.release()
+
+ # The failed and the cancelled turns are never replayed, and the focus holds until it is cleared.
+ third = ask(ui, panel, "What does this task do?")
+ assert third["focus"] == "task-b" and third["conversation"] == []
+ expect(answered(panel, third).locator("[data-workflow-assist-reply]")).to_have_text(reply)
+ expect(chip).to_be_visible()
+ panel.get_by_role("button", name="Stop asking about Summarize evidence", exact=True).click()
+ expect(chip).to_have_count(0)
+ expect(composer(panel)).to_be_focused()
+
+ fourth = ask(ui, panel, "And the whole workflow?")
+ assert fourth["focus"] is None
+ assert fourth["conversation"] == [
+ {"role": "user", "text": "What does this task do?"},
+ {"role": "assistant", "text": reply},
+ ]
+ answered(panel, fourth)
+ assert not ui.workflow_writes
+
+
+def rename_flow_node(record, old, new):
+ """Give a task's flow node an ID of its own, so the node and the task can't be confused."""
+ nodes = record["flow"]["nodes"]
+ next(node for node in nodes if node["id"] == old)["id"] = new
+ for node in nodes:
+ if (node.get("target") or {}).get("node_id") == old:
+ node["target"]["node_id"] = new
+ for output in record["flow"]["outputs"]:
+ if output["source"]["node_id"] == old:
+ output["source"]["node_id"] = new
+ compile_workflow_flow(record)
+ record["definition_revision"] = workflow_definition_revision(record)
+
+
+def test_flow_focus_sends_the_node_and_jump_selects_its_block(ask_ui):
+ ui = ask_ui
+ rename_flow_node(ui.personal_workflows[FLOW_WORKFLOW_ID], "finish", "finish-step")
+ instructions = "Produce the finish result as three short bullet points."
+
+ def finish_instructions(body):
+ handle = [task["id"] for task in body["draft"]["tasks"]].index("finish") + 1
+ return {"op": "set_task_instructions", "task": f"task_{handle}", "instructions": instructions}
+
+ ui.assist.queue(ui.assist.changed(finish_instructions))
+ editor = authoring.open_editor(ui)
+ view = authoring.switch_surface(editor, "Flow")
+ fields = authoring.select_node(view, "finish-step")
+ fields.get_by_role("button", name="Ask AI about this task: Finish", exact=True).click()
+ panel = side_panel(editor)
+ expect(panel.locator("[data-workflow-assist-focus='finish']")).to_contain_text("About: Finish")
+ expect(composer(panel)).to_be_focused()
+
+ body = ask(ui, panel, "Make the finish result three bullet points.")
+ assert body["draft"]["definition_version"] == 3
+ assert body["focus"] == "finish-step"
+ found = answered(panel, body)
+ assert change_keys(found) == ["task:finish:instructions"]
+
+ authoring.select_node(view, "evaluate")
+ found.get_by_role("button", name="Jump to Finish: Instructions", exact=True).click()
+ expect(authoring.node_button(view, "finish-step")).to_have_attribute("aria-pressed", "true")
+ field = authoring.configuration(view).get_by_label("Instructions", exact=True)
+ expect(field).to_be_focused()
+ expect(field).to_have_value(instructions)
+ expect(author(changed(authoring.configuration(view), "task:finish:instructions"))).to_have_text("AI assist")
+ assert not ui.workflow_writes
+
+
+# ---------------------------------------------------------------------------------------------
+# The instruction limit and Undo
+# ---------------------------------------------------------------------------------------------
+
+def test_the_instruction_limit_counts_code_points_like_the_server(ask_ui):
+ ui, page = ask_ui, ask_ui.page
+ reply = "That is a lot of stars."
+ ui.assist.queue(ui.assist.said("explained", reply))
+ editor = open_classic(ui)
+ panel = open_ask_ai(editor)
+ box = composer(panel)
+ # Each star is two UTF-16 units, so counting units would refuse both of these.
+ star = "\U0001F31F"
+
+ over = star * 2001
+ box.fill(over)
+ expect(panel.get_by_text("2001/2000", exact=True)).to_be_visible()
+ expect(panel.get_by_text("This is 1 character over the limit. Shorten it to send.", exact=True)).to_be_visible()
+ expect(box).to_have_attribute("aria-invalid", "true")
+ expect(send_button(panel)).to_be_disabled()
+ box.press("Enter")
+ page.wait_for_timeout(300)
+ assert not ui.assist.bodies
+ expect(box).to_have_value(over)
+
+ exact = star * 2000
+ box.fill(exact)
+ expect(panel.get_by_text("2000/2000", exact=True)).to_be_visible()
+ expect(box).not_to_have_attribute("aria-invalid", "true")
+ expect(send_button(panel)).to_be_enabled()
+ box.press("Enter")
+ body = ui.assist.wait_for_requests(page, 1)
+ # Python counts code points, as the server does.
+ assert body["instruction"] == exact and len(body["instruction"]) == 2000
+ expect(answered(panel, body).locator("[data-workflow-assist-reply]")).to_have_text(reply)
+
+
+def test_undo_skips_a_field_changed_later_and_jump_reaches_a_task_field(ask_ui):
+ ui = ask_ui
+ described, instructions, mine = "Reviews the quarter.", "Collect only signed evidence.", "Reviews the signed quarter."
+ ui.assist.queue(ui.assist.changed(
+ {"op": "set_description", "description": described},
+ {"op": "set_task_instructions", "task": "task_1", "instructions": instructions},
+ ))
+ editor = open_classic(ui)
+ panel = open_ask_ai(editor)
+ found = answered(panel, ask(ui, panel, "Describe the review and have the first task collect signed evidence."))
+ assert change_keys(found) == ["description", "task:task-a:instructions"]
+
+ field = task_item(editor, "task-a").get_by_label("Instructions", exact=True)
+ found.get_by_role("button", name="Jump to Collect evidence: Instructions", exact=True).click()
+ expect(field).to_be_focused()
+ expect(field).to_have_value(instructions)
+ # At this width the panel sits beside the editor, so it stays open.
+ expect(ask_toggle(editor)).to_have_attribute("aria-expanded", "true")
+
+ basics_description(editor).fill(mine)
+ expect(author(changed(editor, "description"))).to_have_text("Edited")
+ undo_button(found).click()
+ result = found.locator("[data-workflow-assist-undo='applied']")
+ expect(result).to_be_focused()
+ expect(result.locator("p").first).to_have_text("Undone: 1 reverted, 1 skipped because it changed later.")
+ expect(result.get_by_role("list", name="Reverted").get_by_role("listitem")).to_have_text(
+ ["Collect evidence · Instructions"])
+ expect(result.get_by_role("list", name="Skipped").get_by_role("listitem")).to_have_text(
+ ["Description: Changed after this turn."])
+ expect(found).to_have_attribute("data-state", "partly_undone")
+ expect(found.locator("[data-workflow-assist-state]")).to_have_text("Some of these changes were undone.")
+ expect(undo_button(found)).to_have_count(0)
+ expect(field).to_have_value("Collect source evidence.")
+ expect(changed(editor, "task:task-a:instructions")).to_have_count(0)
+ expect(basics_description(editor)).to_have_value(mine)
+ expect(author(changed(editor, "description"))).to_have_text("Edited")
+ # Jump to still reaches a field the turn no longer changes, which has no highlight to find.
+ found.get_by_role("button", name="Jump to Collect evidence: Instructions", exact=True).click()
+ expect(field).to_be_focused()
+ assert not ui.workflow_writes
+
+
+# ---------------------------------------------------------------------------------------------
+# Statuses
+# ---------------------------------------------------------------------------------------------
+
+def another_turns_answer(body):
+ """A well-formed answer, but for a different turn."""
+ return 200, {
+ "submission_id": f"{body['submission_id']}-other", "outcome": "explained", "reply": "This is for another turn.",
+ "candidate": None, "changes": [], "warnings": [], "context_documents": [],
+ }, {}
+
+
+def test_every_failure_changes_nothing_and_says_why(ask_ui):
+ ui, page = ask_ui, ask_ui.page
+ stub = ui.assist
+ cases = [
+ # The route's decorators answer before the assistant runs.
+ (stub.canned(400, {"error": "Allow User Workflows is disabled."}), "Allow User Workflows is disabled."),
+ (stub.canned(403, {"error": "Forbidden", "message": "Personal workflows require the WorkflowUser app role."}),
+ "Personal workflows require the WorkflowUser app role."),
+ (stub.canned(403, {"error": "The AI workflow assistant is not available.", "code": "workflow_assistant_disabled"}),
+ "The AI workflow assistant is not available."),
+ *[(stub.refused(code), core._ERRORS[code][1]) for code in (
+ "time_zone_invalid", "assistant_input_too_large", "workflow_not_found", "request_too_large",
+ "assistant_failed", "assistant_refused", "assistant_timeout", "assistant_limit_unavailable",
+ "reference_check_failed",
+ )],
+ (stub.canned(502, None), UNUSABLE),
+ (stub.custom(another_turns_answer), UNREADABLE),
+ ]
+ editor = open_classic(ui)
+ panel = open_ask_ai(editor)
+ for index, (answer, message) in enumerate(cases, 1):
+ stub.queue(answer)
+ ask(ui, panel, f"Change attempt {index}.")
+ exchange = failed(panel)
+ expect(exchange.get_by_role("alert")).to_have_text(message)
+ expect(exchange.get_by_role("button", name="Retry", exact=True)).to_be_enabled()
+ expect(panel.locator("[data-workflow-assist-wait]")).to_have_count(0)
+ expect(panel.locator("[data-workflow-assist-reload]")).to_have_count(0)
+ expect(editor.locator("[data-workflow-change-key]")).to_have_count(0)
+ expect(changes_toggle(editor)).to_have_accessible_name("Changes (0 unsaved)")
+
+ # A 502 changed nothing, so Retry sends the same turn again.
+ explained = "It collects evidence, then summarizes it."
+ stub.queue(stub.refused("assistant_output_invalid"), stub.said("explained", explained))
+ body = ask(ui, panel, "What does this workflow do?")
+ exchange = failed(panel)
+ expect(exchange.get_by_role("alert")).to_have_text(core._ERRORS["assistant_output_invalid"][1])
+ sent = len(stub.bodies)
+ exchange.get_by_role("button", name="Retry", exact=True).click()
+ retried = stub.wait_for_requests(page, sent + 1)
+ assert retried["submission_id"] == body["submission_id"]
+ expect(answered(panel, retried).locator("[data-workflow-assist-reply]")).to_have_text(explained)
+
+ # A 429, or a 503 from a throttled provider, asks for a wait; Send waits that long.
+ stub.settings.update({"enable_custom_rate_limit_message": True, "rate_limit_message": RATE_LIMIT_TEXT})
+ note = panel.locator("[data-workflow-assist-wait]")
+ waits = [
+ ("assistant_unavailable", core._ERRORS["assistant_unavailable"][1]),
+ ("assistant_busy", core._ERRORS["assistant_busy"][1]),
+ # The admin's Markdown is shown as the text it is, with its whitespace collapsed.
+ ("assistant_rate_limited", " ".join(RATE_LIMIT_TEXT.split())),
+ ]
+ for code, message in waits:
+ stub.queue(stub.refused(code, retry_after=5))
+ sent = len(stub.bodies)
+ ask(ui, panel, f"Try {code}.")
+ exchange = failed(panel)
+ expect(exchange.get_by_role("alert")).to_have_text(message)
+ expect(exchange.locator(MARKUP)).to_have_count(0)
+ expect(note).to_have_text(WAIT_NOTE)
+ box = composer(panel)
+ box.fill("Try once more.")
+ expect(send_button(panel)).to_be_disabled()
+ expect(exchange.get_by_role("button", name="Retry", exact=True)).to_be_disabled()
+ expect(panel.get_by_role("group", name="Quick actions", exact=True).get_by_role("button").first).to_be_disabled()
+ box.press("Enter")
+ page.wait_for_timeout(200)
+ assert len(stub.bodies) == sent + 1
+ expect(box).to_have_value("Try once more.")
+ expect(note).to_have_count(0, timeout=8000)
+ expect(send_button(panel)).to_be_enabled()
+ expect(exchange.get_by_role("button", name="Retry", exact=True)).to_be_enabled()
+ box.fill("")
+
+ expect(editor.locator("[data-workflow-change-key]")).to_have_count(0)
+ expect(changes_toggle(editor)).to_have_accessible_name("Changes (0 unsaved)")
+ assert not ui.workflow_writes
+
+
+def test_a_conflict_keeps_the_draft_and_offers_a_reload(ask_ui):
+ ui, page = ask_ui, ask_ui.page
+ stored = ui.personal_workflows[WORKFLOW_ID]
+ mine = "Quarterly review, my draft"
+ editor = open_classic(ui)
+ name = editor.get_by_label("Workflow name", exact=True)
+ name.fill(mine)
+ panel = open_ask_ai(editor)
+
+ stored["deleting"] = True
+ ui.assist.queue(ui.assist.pipeline())
+ ask(ui, panel, "Add a description.")
+ expect(failed(panel).get_by_role("alert")).to_have_text(
+ f"{core._ERRORS['workflow_deleted'][1]} Your draft is still in the editor.")
+ expect(panel.locator("[data-workflow-assist-reload]")).to_have_count(0)
+ expect(name).to_have_value(mine)
+
+ del stored["deleting"]
+ ui.mutate_revision()
+ ui.assist.queue(ui.assist.pipeline())
+ ask(ui, panel, "Add a description.")
+ expect(failed(panel).get_by_role("alert")).to_have_text(core._ERRORS["workflow_definition_conflict"][1])
+ expect(name).to_have_value(mine)
+ expect(author(changed(editor, "name"))).to_have_text("Edited")
+ assert not ui.workflow_writes
+
+ group = panel.get_by_role("group", name="Reload workflow", exact=True)
+ expect(group.locator("p").first).to_have_text(
+ "The saved workflow changed after you opened it. Reload it to keep using Ask AI.")
+ reload = group.get_by_role("button", name="Reload workflow", exact=True)
+ reload.click()
+ expect(group.locator("p").first).to_have_text("Reloading discards your unsaved changes to this workflow.")
+ keep = group.get_by_role("button", name="Keep editing", exact=True)
+ expect(keep).to_be_focused()
+ keep.click()
+ expect(reload).to_be_focused()
+ expect(name).to_have_value(mine)
+ reload.click()
+ group.get_by_role("button", name="Discard and reload", exact=True).click()
+
+ # The editor opens again on the saved workflow as it is now.
+ editor = page.get_by_role("dialog", name="Edit workflow", exact=True)
+ expect(editor.get_by_label("Workflow name", exact=True)).to_have_value("Quarterly review workflow")
+ expect(side_panel(editor)).to_have_count(0)
+ expect(editor.locator("[data-workflow-change-key]")).to_have_count(0)
+ ui.assist.queue(ui.assist.said("explained", "It reviews the quarter."))
+ panel = open_ask_ai(editor)
+ expect(panel.locator("[data-workflow-assist-reload]")).to_have_count(0)
+ body = ask(ui, panel, "What does it review?")
+ assert body["base"] == {"workflow_id": WORKFLOW_ID, "definition_revision": "revision:external-change"}
+ assert body["conversation"] == []
+ answered(panel, body)
+ assert not ui.workflow_writes
+
+
+RELOAD_READS = ("/api/user/workflows", "/api/user/workflows/editor-options")
+
+
+def conflicted_editor(ui, mine, *, applied=None):
+ """The saved workflow, renamed here and changed elsewhere, so Ask AI offers to reload it.
+
+ With `applied`, an earlier turn made that change first, and its card is returned as well.
+ """
+ editor = open_classic(ui)
+ name = editor.get_by_label("Workflow name", exact=True)
+ name.fill(mine)
+ panel = open_ask_ai(editor)
+ found = None
+ if applied:
+ ui.assist.queue(ui.assist.changed(applied))
+ found = answered(panel, ask(ui, panel, "Collect only signed evidence in the first task."))
+ expect(undo_button(found)).to_be_enabled()
+ ui.mutate_revision()
+ ui.assist.queue(ui.assist.pipeline())
+ ask(ui, panel, "Add a description.")
+ expect(failed(panel).get_by_role("alert")).to_have_text(core._ERRORS["workflow_definition_conflict"][1])
+ return editor, panel, name, found
+
+
+def held(ui, method):
+ return sorted(entry.path for _, entry in ui.pending_responses if entry.method == method)
+
+
+def wait_until_held(ui, method, paths):
+ """Wait until the page has made each of `paths`, and they are the `method` requests held."""
+ waited = 0
+ while len(held(ui, method)) < len(paths):
+ assert waited < 10000, f"The held {method} requests never arrived."
+ ui.page.wait_for_timeout(50)
+ waited += 50
+ assert held(ui, method) == sorted(paths)
+
+
+def hold_reload(ui, panel):
+ """Choose Discard and reload with both of the reload's reads held, so it is still loading."""
+ group = panel.get_by_role("group", name="Reload workflow", exact=True)
+ group.get_by_role("button", name="Reload workflow", exact=True).click()
+ for path in RELOAD_READS:
+ ui.defer_next("GET", path)
+ group.get_by_role("button", name="Discard and reload", exact=True).click()
+ expect(group.get_by_role("button", name="Reloading…", exact=True)).to_be_disabled()
+ wait_until_held(ui, "GET", RELOAD_READS)
+ return group
+
+
+def finished(path, method="GET"):
+ return lambda request: request.method == method and urlsplit(request.url).path == path
+
+
+def release_reload(ui):
+ """Answer the held reads, and wait until the page has read both before anything is checked."""
+ page = ui.page
+ with page.expect_event("requestfinished", predicate=finished(RELOAD_READS[0])):
+ with page.expect_event("requestfinished", predicate=finished(RELOAD_READS[1])):
+ ui.release_responses()
+ # The page acts on the answers once it has read them; give it time to do the wrong thing.
+ page.wait_for_timeout(500)
+
+
+def discard_and_close(page, editor):
+ page.get_by_role("dialog", name="Discard unsaved workflow changes?", exact=True).get_by_role(
+ "button", name="Discard changes", exact=True).click()
+ expect(editor).to_have_count(0)
+
+
+def test_a_closed_editor_stays_closed_when_its_reload_finishes(ask_ui):
+ ui, page = ask_ui, ask_ui.page
+ mine = "Quarterly review, my draft"
+ editor, panel, name, _ = conflicted_editor(ui, mine)
+ hold_reload(ui, panel)
+
+ # The draft the reload is about to discard is locked while it loads. Closing is not.
+ expect(name).to_be_disabled()
+ expect(name).to_have_value(mine)
+ expect(editor.get_by_role("button", name="Save workflow", exact=True)).to_be_disabled()
+ expect(panel.get_by_role("group", name="Quick actions", exact=True).get_by_role("button").first).to_be_disabled()
+ expect(editor.get_by_role("button", name="Cancel", exact=True)).to_be_enabled()
+ page.keyboard.press("Escape")
+ discard_and_close(page, editor)
+
+ release_reload(ui)
+ expect(editor).to_have_count(0)
+ expect(page.get_by_role("button", name="Edit Quarterly review workflow", exact=True)).to_be_visible()
+ assert not ui.workflow_writes
+
+
+def test_a_reload_never_replaces_another_workflows_editor(ask_ui):
+ ui, page = ask_ui, ask_ui.page
+ other = ui.personal_workflows[FLOW_WORKFLOW_ID]["name"]
+ editor, panel, _, _ = conflicted_editor(ui, "Quarterly review, my draft")
+ hold_reload(ui, panel)
+ editor.get_by_role("button", name="Cancel", exact=True).click()
+ discard_and_close(page, editor)
+
+ # Another workflow is opened and edited while the first one's reload is still loading.
+ page.get_by_role("button", name=f"Edit {other}", exact=True).click()
+ name = editor.get_by_label("Workflow name", exact=True)
+ expect(name).to_have_value(other)
+ typed = f"{other}, my draft"
+ name.fill(typed)
+
+ release_reload(ui)
+ expect(editor).to_have_count(1)
+ expect(name).to_have_value(typed)
+ expect(name).to_be_enabled()
+ expect(page.get_by_role("dialog", name="Discard unsaved workflow changes?", exact=True)).to_have_count(0)
+ assert not ui.workflow_writes
+
+
+def test_a_failed_reload_unlocks_the_draft_it_kept(ask_ui):
+ ui = ask_ui
+ mine = "Quarterly review, my draft"
+ operation = {"op": "set_task_instructions", "task": "task_1", "instructions": "Collect only signed evidence."}
+ editor, panel, name, found = conflicted_editor(ui, mine, applied=operation)
+ group = hold_reload(ui, panel)
+ expect(name).to_be_disabled()
+ expect(undo_button(found)).to_be_disabled()
+
+ ui.fail_held_responses()
+ expect(group.get_by_role("alert")).to_have_text("Couldn't reload the workflow. Try again.")
+ expect(group.get_by_role("button", name="Reload workflow", exact=True)).to_be_focused()
+ expect(name).to_be_enabled()
+ expect(name).to_have_value(mine)
+ expect(undo_button(found)).to_be_enabled()
+ expect(author(changed(editor, "task:task-a:instructions"))).to_have_text("AI assist")
+ expect(editor.get_by_role("button", name="Save workflow", exact=True)).to_be_enabled()
+ expect(panel.get_by_role("group", name="Quick actions", exact=True).get_by_role("button").first).to_be_enabled()
+ assert not ui.workflow_writes
+
+
+def test_a_save_that_lands_during_a_reload_keeps_the_editor_closed(ask_ui):
+ ui, page = ask_ui, ask_ui.page
+ stored = ui.personal_workflows[WORKFLOW_ID]
+ opened = stored["definition_revision"]
+ mine = "Quarterly review, my draft"
+ editor, panel, _, _ = conflicted_editor(ui, mine)
+
+ # Saving locks the form, not the side panel, so Discard and reload can be chosen mid-save.
+ ui.defer_next("POST", "/api/user/workflows")
+ editor.get_by_role("button", name="Save workflow", exact=True).click()
+ wait_until_held(ui, "POST", ["/api/user/workflows"])
+ hold_reload(ui, panel)
+
+ # The change made elsewhere is undone, so the save is accepted and lands first. The editor
+ # closes on the saved workflow, and the list is read again.
+ stored["definition_revision"] = opened
+ with page.expect_event("requestfinished", predicate=finished(RELOAD_READS[0])):
+ ui.release_held("POST", "/api/user/workflows")
+ expect(editor).to_have_count(0)
+ assert len(ui.workflow_writes) == 1
+ assert ui.workflow_writes[0].body["name"] == mine
+
+ # The reload finishes after the save, and is ignored.
+ release_reload(ui)
+ expect(editor).to_have_count(0)
+ expect(page.get_by_role("button", name=f"Edit {mine}", exact=True)).to_be_visible()
+ assert len(ui.workflow_writes) == 1
+
+
+# ---------------------------------------------------------------------------------------------
+# Undo and redo keys while the draft is locked
+# ---------------------------------------------------------------------------------------------
+
+HISTORY_KEYS = ("Control+z", "Control+y", "Control+Shift+z")
+FIRST_NAME, SECOND_NAME = "Branch review, first edit", "Branch review, second edit"
+
+
+def history_button(editor, direction):
+ return editor.get_by_role("button", name=f"{direction} workflow edit", exact=True)
+
+
+def structured_editor_with_history(ui):
+ """The structured workflow with one edit left to undo and one to redo, so every key has work to do.
+
+ Also returns a block: it is not a text field, and it stays focusable inside the locked fieldset,
+ so its keys reach workflow history rather than a field's own undo.
+ """
+ editor = authoring.open_editor(ui)
+ name = editor.get_by_label("Workflow name", exact=True)
+ for typed in (FIRST_NAME, SECOND_NAME):
+ name.fill(typed)
+ name.press("Tab")
+ history_button(editor, "Undo").click()
+ expect(name).to_have_value(FIRST_NAME)
+ expect(history_button(editor, "Undo")).to_have_attribute("aria-disabled", "false")
+ expect(history_button(editor, "Redo")).to_have_attribute("aria-disabled", "false")
+ expect(changes_toggle(editor)).to_have_accessible_name("Changes (1 unsaved)")
+ return editor, name, authoring.list_block(editor, "evaluate")
+
+
+def expect_history_keys_ignored(ui, editor, name, block, reason):
+ """Each undo and redo key on the block changes nothing, and says nothing."""
+ for direction in ("Undo", "Redo"):
+ expect(history_button(editor, direction)).to_have_attribute("title", reason)
+ alerts = editor.get_by_role("alert").count()
+ block.focus()
+ expect(block).to_be_focused()
+ for key in HISTORY_KEYS:
+ ui.page.keyboard.press(key)
+ # Give a key that got through time to change the draft.
+ ui.page.wait_for_timeout(200)
+ expect(name).to_have_value(FIRST_NAME)
+ expect(changes_toggle(editor)).to_have_accessible_name("Changes (1 unsaved)")
+ expect(editor.get_by_role("alert")).to_have_count(alerts)
+ expect(block).to_be_focused()
+
+
+def expect_history_keys_work(ui, name, block, original):
+ """The same keys on the same block undo and redo once the draft is unlocked."""
+ for key, value in zip(HISTORY_KEYS, (original, FIRST_NAME, SECOND_NAME)):
+ block.focus()
+ ui.page.keyboard.press(key)
+ expect(name).to_have_value(value)
+
+
+def test_undo_and_redo_keys_wait_while_ask_ai_works(ask_ui):
+ ui = ask_ui
+ original = ui.personal_workflows[FLOW_WORKFLOW_ID]["name"]
+ editor, name, block = structured_editor_with_history(ui)
+ panel = open_ask_ai(editor)
+ ui.assist.hold = 1
+ ui.assist.queue(ui.assist.said("explained", "It reviews each branch."))
+ ask(ui, panel, "What does this workflow review?")
+ lock = editor.locator("[data-workflow-assist-lock]")
+ expect(lock).to_be_visible()
+
+ expect_history_keys_ignored(ui, editor, name, block, "Wait for Ask AI to finish, or cancel it.")
+
+ lock.get_by_role("button", name="Cancel request", exact=True).click()
+ expect(lock).to_have_count(0)
+ ui.assist.release()
+ expect_history_keys_work(ui, name, block, original)
+ assert not ui.workflow_writes
+
+
+def test_undo_and_redo_keys_wait_while_the_saved_workflow_reloads(ask_ui):
+ ui = ask_ui
+ original = ui.personal_workflows[FLOW_WORKFLOW_ID]["name"]
+ editor, name, block = structured_editor_with_history(ui)
+ panel = open_ask_ai(editor)
+ ui.mutate_revision(FLOW_WORKFLOW_ID)
+ ui.assist.queue(ui.assist.pipeline())
+ ask(ui, panel, "Add a description.")
+ expect(failed(panel).get_by_role("alert")).to_have_text(core._ERRORS["workflow_definition_conflict"][1])
+ group = hold_reload(ui, panel)
+
+ expect_history_keys_ignored(ui, editor, name, block, "Wait for the saved workflow to reload.")
+
+ ui.fail_held_responses()
+ expect(group.get_by_role("alert")).to_have_text("Couldn't reload the workflow. Try again.")
+ expect(name).to_be_enabled()
+ expect_history_keys_work(ui, name, block, original)
+ assert not ui.workflow_writes
+
+
+# ---------------------------------------------------------------------------------------------
+# Untrusted text and the client's own diff
+# ---------------------------------------------------------------------------------------------
+
+def test_model_text_renders_as_text_and_only_the_clients_diff_is_listed(ask_ui):
+ ui, page = ask_ui, ask_ui.page
+ described, instructions = "Reviews the quarter.", "Collect only signed evidence."
+ answer = ui.assist.changed(
+ {"op": "set_description", "description": described},
+ {"op": "set_task_instructions", "task": "task_1", "instructions": instructions},
+ )
+
+ def tampered(raw):
+ status, payload, headers = answer(raw)
+ payload = copy.deepcopy(payload)
+ reported = {change["key"]: change for change in payload["changes"]}
+ description = reported["description"]
+ description.update(summary=HOSTILE, label=HOSTILE)
+ # The instructions entry is left out and a name change that never happened is made up.
+ made_up = {**copy.deepcopy(description), "key": "name", "label": "Workflow name",
+ "summary": "Workflow: Workflow name", "target": {"focus_key": "name"}}
+ payload.update(
+ reply=HOSTILE, changes=[description, made_up],
+ warnings=[{"code": "m365_not_connected", "message": HOSTILE}], context_documents=[HOSTILE],
+ )
+ return status, payload, headers
+
+ ui.assist.queue(tampered)
+ editor = open_classic(ui)
+ panel = open_ask_ai(editor)
+ found = answered(panel, ask(ui, panel, "Describe it and tighten the first task."))
+
+ expect(found.locator("[data-workflow-assist-reply]")).to_have_text(HOSTILE)
+ # The list is the client's diff of the draft it sent against the candidate.
+ assert change_keys(found) == ["description", "task:task-a:instructions"]
+ expect(found.locator("li[data-workflow-assist-change='description'] span")).to_have_text(HOSTILE)
+ expect(found.locator("li[data-workflow-assist-change='task:task-a:instructions'] span")).to_have_text(
+ "Collect evidence: Instructions")
+ expect(found.get_by_role("button", name=f"Jump to {HOSTILE}", exact=True)).to_have_count(1)
+ expect(found.locator("li[data-workflow-assist-warning='m365_not_connected'] span")).to_have_text(HOSTILE)
+ expect(found.locator("[data-workflow-assist-context]")).to_have_text(f"Read as context: {HOSTILE}")
+ expect(found.locator(MARKUP)).to_have_count(0)
+ assert page.evaluate("() => window.__hostile ?? null") is None
+
+ expect(author(changed(editor, "description"))).to_have_text("AI assist")
+ expect(author(changed(editor, "task:task-a:instructions"))).to_have_text("AI assist")
+ expect(changed(editor, "name")).to_have_count(0)
+ expect(changes_toggle(editor)).to_have_accessible_name("Changes (2 unsaved)")
+ assert not ui.workflow_writes
+
+
+# ---------------------------------------------------------------------------------------------
+# Where Ask AI is offered
+# ---------------------------------------------------------------------------------------------
+
+@pytest.mark.parametrize("restriction", ["setting_off", "group", "reader", "unsupported", "schedule"])
+def test_ask_ai_is_offered_only_for_personal_workflows_the_editor_can_change(ask_ui, restriction):
+ ui, page = ask_ui, ask_ui.page
+ if restriction == "setting_off":
+ # The bootstrap turns it off for this user: the setting, personal workflows or the role.
+ ui.ask_ai_enabled = False
+ editor = open_classic(ui)
+ elif restriction == "group":
+ editor = authoring.open_editor(ui, group_id=GROUP_ID)
+ elif restriction == "reader":
+ ui.group_can_manage = False
+ ui.open("/groups")
+ ui.select_group(GROUP_ID)
+ page.get_by_role("button", name="View Alpha read-only Flow", exact=True).click()
+ editor = page.get_by_role("dialog", name="Edit workflow", exact=True)
+ elif restriction == "unsupported":
+ ui.personal_workflows[FLOW_WORKFLOW_ID]["flow"]["nodes"][0]["future_executor"] = {"unchanged": True}
+ ui.open(f"/workspace/workflows?workflow_id={FLOW_WORKFLOW_ID}")
+ editor = page.get_by_role("dialog", name="Edit workflow", exact=True)
+ else:
+ ui.personal_workflows[WORKFLOW_ID].update(trigger_type="interval", schedule=copy.deepcopy(UNSUPPORTED_SCHEDULE))
+ ui.open(f"/workspace/workflows?workflow_id={WORKFLOW_ID}")
+ editor = page.get_by_role("dialog", name="Edit workflow", exact=True)
+ expect(editor).to_be_visible()
+
+ if restriction in ("setting_off", "group"):
+ # The editor can change these workflows, so a task with no instructions would offer Draft with
+ # AI if Ask AI were available.
+ instructions = editor.get_by_label("Instructions", exact=True).first
+ instructions.fill("")
+ expect(instructions).to_have_value("")
+ panel = open_panel(editor)
+ expect(panel.get_by_role("tab")).to_have_count(1)
+ expect(panel.locator("[data-workflow-ask-ai]")).to_have_count(0)
+ else:
+ expect(editor).to_contain_text("This workflow is read-only.")
+ expect(editor.locator("aside")).to_have_count(0)
+ expect(ask_toggle(editor)).to_have_count(0)
+ expect(editor.locator("[data-workflow-ask-ai-task]")).to_have_count(0)
+ expect(editor.locator("[data-workflow-draft-ai]")).to_have_count(0)
+ expect(editor.get_by_role("button", name=re.compile(r"^(Ask AI|Draft with AI)"))).to_have_count(0)
+ assert not ui.assist.bodies and not ui.drafts.bodies
+ assert not ui.workflow_writes
+
+
+# ---------------------------------------------------------------------------------------------
+# Keyboard and narrow screens
+# ---------------------------------------------------------------------------------------------
+
+def test_keyboard_tabs_the_hash_menu_and_escape(ask_ui):
+ ui, page = ask_ui, ask_ui.page
+ # A tag that "#brief" would match, if tags were offered.
+ ui.extra_gets["/api/documents/tags"] = {"tags": [{"name": "brief-notes"}]}
+ editor = open_classic(ui)
+ panel = open_ask_ai(editor)
+ changes_tab = panel.get_by_role("tab", name="Changes", exact=True)
+ ask_tab = panel.get_by_role("tab", name="Ask AI", exact=True)
+ expect(panel.get_by_role("tab")).to_have_count(2)
+ ask_tab.focus()
+ for key, tab, other in (
+ ("ArrowRight", changes_tab, ask_tab), ("ArrowLeft", ask_tab, changes_tab),
+ ("Home", changes_tab, ask_tab), ("End", ask_tab, changes_tab),
+ ):
+ page.keyboard.press(key)
+ expect(tab).to_be_focused()
+ expect(tab).to_have_attribute("aria-selected", "true")
+ expect(tab).to_have_attribute("tabindex", "0")
+ expect(other).to_have_attribute("aria-selected", "false")
+ expect(other).to_have_attribute("tabindex", "-1")
+ expect(panel.get_by_role("tabpanel")).to_have_attribute("aria-labelledby", tab.get_attribute("id"))
+
+ # The # menu offers documents only, and Escape closes it without closing the panel.
+ tag_reads = sum(1 for entry in ui.requests if entry.path.endswith("/tags"))
+ box = composer(panel)
+ box.fill("Use ")
+ box.press_sequentially("#brief")
+ menu = page.get_by_role("listbox", name="Context suggestions")
+ expect(menu.get_by_role("option").first).to_contain_text("Private brief")
+ expect(menu.get_by_text("Documents", exact=True)).to_be_visible()
+ expect(menu.get_by_text("Tags", exact=True)).to_have_count(0)
+ expect(menu.get_by_text("Workspaces", exact=True)).to_have_count(0)
+ expect(menu.get_by_text(re.compile(r"^Tag ·"))).to_have_count(0)
+ expect(menu.get_by_text("brief-notes")).to_have_count(0)
+ assert sum(1 for entry in ui.requests if entry.path.endswith("/tags")) == tag_reads
+ page.keyboard.press("Escape")
+ expect(menu).to_have_count(0)
+ expect(side_panel(editor)).to_be_visible()
+ expect(box).to_be_focused()
+ expect(box).to_have_value("Use #brief")
+
+ # So does the document picker, and focus carries on in the input.
+ box.fill("")
+ panel.get_by_role("button", name="Add context", exact=True).click()
+ search = panel.get_by_role("searchbox", name="Search documents", exact=True)
+ expect(search).to_be_focused()
+ page.keyboard.press("Escape")
+ expect(search).to_have_count(0)
+ expect(side_panel(editor)).to_be_visible()
+ expect(box).to_be_focused()
+
+ # With nothing open in it, Escape closes the panel and returns to its toggle, not the editor.
+ page.keyboard.press("Escape")
+ expect(side_panel(editor)).to_have_count(0)
+ expect(ask_toggle(editor)).to_be_focused()
+ expect(ask_toggle(editor)).to_have_attribute("aria-expanded", "false")
+ expect(editor).to_be_visible()
+ assert not ui.assist.bodies
+
+
+def test_a_narrow_screen_shows_ask_ai_in_place_of_the_editor(ask_ui):
+ ui = ask_ui
+ described = "Reviews the quarter's signed evidence."
+ ui.assist.queue(ui.assist.changed({"op": "set_description", "description": described}))
+ editor = open_classic(ui, width=390, height=844)
+ name = editor.get_by_label("Workflow name", exact=True)
+ assert_dialog_fits(ui, editor)
+ panel = open_ask_ai(editor)
+ expect(name).to_be_hidden()
+ assert_dialog_fits(ui, editor)
+
+ found = answered(panel, ask(ui, panel, "Describe what it reviews."))
+ found.get_by_role("button", name="Jump to Workflow: Description", exact=True).click()
+ expect(side_panel(editor)).to_have_count(0)
+ expect(ask_toggle(editor)).to_have_attribute("aria-expanded", "false")
+ description = basics_description(editor)
+ expect(description).to_be_focused()
+ expect(description).to_have_value(described)
+ expect(author(changed(editor, "description"))).to_have_text("AI assist")
+ assert_dialog_fits(ui, editor)
+ assert not ui.workflow_writes
+
+
+# ---------------------------------------------------------------------------------------------
+# Draft with AI, quick actions and an earlier editing session
+# ---------------------------------------------------------------------------------------------
+
+@contextmanager
+def cpu_throttled(page, rate=6):
+ """Slow the page's CPU like a slower device, so the highlight's deferred render lands after the jump."""
+ try:
+ session = page.context.new_cdp_session(page)
+ except Exception as error: # noqa: BLE001 - only Chromium has the DevTools protocol
+ pytest.skip(f"This browser cannot throttle the CPU: {error}")
+ else:
+ session.send("Emulation.setCPUThrottlingRate", {"rate": rate})
+ try:
+ yield
+ finally:
+ session.send("Emulation.setCPUThrottlingRate", {"rate": 1})
+ session.detach()
+
+
+def record_focus(page):
+ """Log every element that takes focus from now on, so focus that leaves and comes back is still caught."""
+ page.evaluate("""() => {
+ window.__workflowFocusLog = [];
+ document.addEventListener('focusin', (event) => window.__workflowFocusLog.push(event.target), true);
+ }""")
+
+
+def stray_focus(field):
+ """Everything that took focus since record_focus, other than Draft with AI's button and the field."""
+ return field.evaluate("""(field) => window.__workflowFocusLog
+ .filter((target) => target !== field && !target.matches('[data-workflow-draft-ai]'))
+ .map((target) => target.getAttribute('aria-label') || target.id || target.tagName)""")
+
+
+def test_draft_with_ai_fills_an_empty_task_as_an_undoable_ai_change(ask_ui):
+ ui, page = ask_ui, ask_ui.page
+ drafted = "Collect the quarter's signed evidence and list anything that is missing."
+ ui.open("/workspace/workflows")
+ page.get_by_role("button", name="Create workflow", exact=True).click()
+ editor = page.get_by_role("dialog", name="Create workflow", exact=True)
+ expect(editor).to_be_visible()
+ editor.get_by_label("Model", exact=True).select_option(label="Workspace GPT · aoai")
+ button = editor.get_by_role("button", name="Draft with AI: instructions for Task 1", exact=True)
+ task_id = button.get_attribute("data-workflow-draft-ai")
+ task = task_item(editor, task_id)
+ status = task.locator("p[role='status']")
+
+ # With nothing to go on, it asks for a name instead of guessing.
+ button.click()
+ expect(status).to_have_text(NEEDS_NAME)
+ assert not ui.drafts.bodies
+
+ editor.get_by_label("Workflow name", exact=True).fill("Evidence review")
+ ui.drafts.queue(drafted)
+ instructions = task.get_by_label("Instructions", exact=True)
+ record_focus(page)
+ # Focus reaches the drafted instructions even when the highlight renders after the jump.
+ with cpu_throttled(page):
+ button.click()
+ expect(instructions).to_have_value(drafted, timeout=30_000)
+ expect(instructions).to_be_focused(timeout=30_000)
+ # And it stays there once the jump lands, with nothing else taking it on the way.
+ page.wait_for_timeout(1_000)
+ assert instructions.evaluate("element => element === document.activeElement")
+ assert stray_focus(instructions) == []
+ assert ui.drafts.bodies == [{
+ "workflow_scope": "personal", "name": "Evidence review", "description": "", "brief": "Task 1",
+ }]
+ expect(author(changed(editor, f"task:{task_id}:instructions"))).to_have_text("AI assist")
+ expect(changes_toggle(editor)).to_have_accessible_name("Changes (1 unsaved)")
+ expect(button).to_have_count(0)
+ assert not ui.assist.bodies
+
+ payload = confirm_save(ui, editor)
+ assert payload["name"] == "Evidence review"
+ assert payload["tasks"][0]["instructions"] == drafted
+
+
+def test_draft_with_ai_on_a_flow_block_focuses_its_drafted_instructions(ask_ui):
+ ui = ask_ui
+ drafted = "Summarize the evaluated decision as three short bullet points."
+ record = ui.personal_workflows[FLOW_WORKFLOW_ID]
+ # Saved without instructions, so nothing marks the field until Draft with AI fills it.
+ next(task for task in record["tasks"] if task["id"] == "finish")["instructions"] = ""
+ record["definition_revision"] = workflow_definition_revision(record)
+ editor = authoring.open_editor(ui)
+ view = authoring.switch_surface(editor, "Flow")
+ fields = authoring.select_node(view, "finish")
+ instructions = fields.get_by_label("Instructions", exact=True)
+ expect(instructions).to_have_value("")
+ expect(changed(editor, "task:finish:instructions")).to_have_count(0)
+
+ ui.drafts.queue(drafted)
+ record_focus(ui.page)
+ # The change also asks the canvas to focus its block; the jump drops that request, so the drafted field keeps focus.
+ with cpu_throttled(ui.page):
+ fields.get_by_role("button", name="Draft with AI: instructions for Finish", exact=True).click()
+ expect(instructions).to_have_value(drafted, timeout=30_000)
+ expect(instructions).to_be_focused(timeout=30_000)
+ ui.page.wait_for_timeout(1_000)
+ assert instructions.evaluate("element => element === document.activeElement")
+ # The block never took focus, even for a moment before the jump.
+ assert stray_focus(instructions) == []
+ expect(authoring.node_button(view, "finish")).to_have_attribute("aria-pressed", "true")
+ expect(author(changed(fields, "task:finish:instructions"))).to_have_text("AI assist")
+ assert ui.drafts.bodies == [{
+ "workflow_scope": "personal", "name": record["name"], "description": record["description"], "brief": "Finish",
+ }]
+ assert not ui.assist.bodies
+ assert not ui.workflow_writes
+
+
+def test_quick_actions_send_visible_turns_and_cards_outlive_a_save(ask_ui):
+ ui, page = ask_ui, ask_ui.page
+ explained, described = "It collects evidence, then summarizes it.", "Reviews the quarter's evidence."
+ ui.assist.queue(
+ ui.assist.said("explained", explained),
+ ui.assist.changed({"op": "set_description", "description": described}, text="Described."),
+ ui.assist.said("explained", "It has a description now."),
+ )
+ editor = open_classic(ui)
+ panel = open_ask_ai(editor)
+ quick = panel.get_by_role("group", name="Quick actions", exact=True).get_by_role("button")
+ expect(quick).to_have_text([
+ "Explain this workflow", "Tighten task instructions", "Add a schedule", "Alert me only when urgent",
+ "Check what's needed to run",
+ ])
+ quick.first.click()
+ first = ui.assist.wait_for_requests(page, 1)
+ assert first["instruction"] == EXPLAIN
+ expect(exchanges(panel).first).to_contain_text(EXPLAIN)
+ expect(answered(panel, first).locator("[data-workflow-assist-reply]")).to_have_text(explained)
+
+ instruction = "Describe what it reviews."
+ second = ask(ui, panel, instruction)
+ expect(answered(panel, second)).to_have_attribute("data-state", "applied")
+ payload = confirm_save(ui, editor)
+ assert payload["description"] == described
+
+ # The thread outlives the editor; its card now belongs to an earlier editing session.
+ page.get_by_role("button", name="Edit Quarterly review workflow", exact=True).click()
+ editor = page.get_by_role("dialog", name="Edit workflow", exact=True)
+ expect(basics_description(editor)).to_have_value(described)
+ panel = open_ask_ai(editor)
+ found = card(panel, second["submission_id"])
+ expect(found).to_have_attribute("data-state", "earlier")
+ expect(found.locator("[data-workflow-assist-state]")).to_have_text("Made in an earlier editing session.")
+ expect(found.get_by_role("button", name=re.compile(r"^Jump to"))).to_have_count(0)
+ undo_button(found).click()
+ result = found.locator("[data-workflow-assist-undo='unavailable']")
+ expect(result).to_be_focused()
+ expect(result).to_have_text("This turn can no longer be undone.")
+ expect(basics_description(editor)).to_have_value(described)
+ expect(changes_toggle(editor)).to_have_accessible_name("Changes (0 unsaved)")
+
+ third = ask(ui, panel, "Does it have a description?")
+ assert third["conversation"] == [
+ {"role": "user", "text": EXPLAIN},
+ {"role": "assistant", "text": explained},
+ {"role": "user", "text": instruction},
+ {"role": "assistant", "text": (
+ "Described.\n\nThese changes were made in an earlier editing session and may not have been saved: "
+ "Workflow: Description."
+ )},
+ ]
+ answered(panel, third)
+ assert len(ui.workflow_writes) == 1
diff --git a/ui_tests/test_v2_workflow_ask_ai_proposal.py b/ui_tests/test_v2_workflow_ask_ai_proposal.py
new file mode 100644
index 000000000..3d7995122
--- /dev/null
+++ b/ui_tests/test_v2_workflow_ask_ai_proposal.py
@@ -0,0 +1,234 @@
+# test_v2_workflow_ask_ai_proposal.py
+"""
+Real-component browser test for Ask AI on a workflow proposal's draft (Phase 3c).
+Version: 0.261.213
+Implemented in: 0.261.213
+Refs: microsoft/simplechat#1548
+
+Phase 4's proposal card opens the workflow editor on the proposal's draft through Edit. Ask AI
+works there too: the draft has no saved workflow behind it, so a turn sends `base: null` and a
+draft without an `id`, and a Save with an AI change in it opens Review before saving first, then
+accepts the proposal through the card and never through the workflow save route.
+
+The editor holds its assist thread while it is open, and the thread lives outside the chat's
+conversations, so an Ask AI turn opened from a chat must not sweep that chat's idle image or plan
+transcripts. Closing the editor lets its thread go, and the chat prunes as it always did.
+
+Reuses the proposal card harness (the production MessageList, WorkflowProposalCards and
+WorkflowEditorDialog in Chromium with the production CSS). `POST /api/user/workflows/assist` is
+answered in the page by 3b's real pipeline with a scripted model (`fixtures/workflow_ask_ai.py`),
+so no live model or Azure service is contacted.
+
+Build CSS with the existing V2 build, keeping outputs in UI test artifacts:
+npm --prefix .\\application\\v2_ui run build -- --outDir ..\\..\\ui_tests\\artifacts\\orchestration-plan-editor
+Run with PLAYWRIGHT_SERVICE_URL='' and PYTHONPATH including application/single_app,
+functional_tests and ui_tests/fixtures.
+"""
+
+import re
+import sys
+from pathlib import Path
+from urllib.parse import urlsplit
+
+import pytest
+from playwright.sync_api import expect
+
+ROOT = Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(ROOT))
+sys.path.insert(0, str(ROOT / "ui_tests"))
+sys.path.insert(0, str(ROOT / "ui_tests" / "fixtures"))
+
+# The shared fixtures import pure application helpers after setting their paths. pytest looks up
+# card_ui's own fixtures by name in this module, so each comes from the module that defines it.
+from ui_tests.fixtures.playwright_connection import connect_options # noqa: E402,F401
+from ui_tests.fixtures.workflow_ask_ai import ASSIST_ROUTE, AssistStub # noqa: E402
+from ui_tests.test_v2_orchestration_plan_editor import editor_assets, editor_browser # noqa: E402,F401
+from ui_tests.test_v2_orchestration_workflow_proposal_card import ( # noqa: E402,F401
+ CONVERSATION,
+ HOSTILE_INSTRUCTIONS,
+ NAME,
+ card,
+ card_ui,
+ editor_options,
+ mount,
+ open_editor,
+)
+from ui_tests.test_v2_workflow_change_tracking import ( # noqa: E402
+ RUN_AS_NOTE,
+ author,
+ changed,
+ changes_toggle,
+ open_panel,
+ side_panel,
+)
+
+
+pytestmark = pytest.mark.ui
+DESCRIBED = "Reviews the week's email and flags anything urgent."
+# An image editor's transcript on the proposal's answer, and a diagram thread in another chat.
+IMAGE_THREAD = f"image:{CONVERSATION}:answer-1"
+OTHER_CONVERSATION = "other-conversation"
+OTHER_THREAD = f"block:{OTHER_CONVERSATION}:mermaid:other-answer:0"
+
+
+@pytest.fixture
+def ask_card_ui(card_ui):
+ page, api = card_ui
+
+ def record_error(url, status):
+ api.expected_errors.add((urlsplit(url).path, status))
+
+ # A proposal draft has no saved workflow to read, and these tests send no # references.
+ stub = AssistStub(
+ user_id="proposal-tester", record_error=record_error, options=editor_options, read_base=None, resolve=None,
+ )
+ # Registered after the harness's catch-all route, so it answers the assist route first.
+ page.route(ASSIST_ROUTE, stub.handle)
+ try:
+ yield page, api, stub
+ stub.assert_clean()
+ finally:
+ stub.release()
+
+
+def enable_ask_ai(page):
+ """The bootstrap's per-user flag, as the server sends it when Ask AI is available to this user."""
+ page.evaluate("""() => {
+ const store = window.OrchHarness.stores.bootstrap.useBootstrapStore;
+ const data = store.getState().data;
+ store.setState({ data: { ...data, features: { ...data.features, enable_workflow_ai_assistant: true } } });
+ }""")
+
+
+def description_field(dialog):
+ return dialog.locator("section[aria-label='Workflow basics']").get_by_label("Description", exact=True)
+
+
+def thread_keys(page):
+ return page.evaluate(
+ "() => Object.keys(window.OrchHarness.stores.assistThread.useAssistThreadStore.getState().threads).sort()"
+ )
+
+
+def touch_thread(page, key, conversation, text):
+ """Record a completed exchange the way an image or diagram editor does, which prunes the store around it."""
+ page.evaluate("""(spec) => {
+ const store = window.OrchHarness.stores.assistThread.useAssistThreadStore;
+ store.getState().updateThread(spec.key, spec.conversation, (record) => ({
+ ...record,
+ exchanges: [...record.exchanges, {
+ id: `${spec.key}:turn`, text: spec.text, status: 'done', startedAt: Date.now(), reply: 'Done.',
+ draft: { text: spec.text, contextItems: [], attachedPrompt: null, promptValues: {}, uploads: [] },
+ }],
+ }));
+ }""", {"key": key, "conversation": conversation, "text": text})
+
+
+def test_a_proposal_draft_sends_no_base_and_saves_through_review(ask_card_ui):
+ page, api, stub = ask_card_ui
+ stub.queue(stub.changed({"op": "set_description", "description": DESCRIBED}, text="Updated the description."))
+ mount(page, api)
+ enable_ask_ai(page)
+ dialog, _ = open_editor(page, card(page))
+
+ toggle = dialog.get_by_role("button", name="Ask AI", exact=True)
+ toggle.click()
+ expect(toggle).to_have_attribute("aria-expanded", "true")
+ panel = side_panel(dialog)
+ expect(panel.get_by_role("tab", name="Ask AI", exact=True)).to_have_attribute("aria-selected", "true")
+ box = panel.get_by_role("textbox", name="Message Ask AI", exact=True)
+ expect(box).to_be_focused()
+ box.fill("Say that it flags anything urgent.")
+ box.press("Enter")
+ body = stub.wait_for_requests(page, 1)
+
+ # A proposal draft is a new draft: no base, and no id in the draft.
+ assert body["base"] is None
+ assert "id" not in body["draft"]
+ assert body["draft"]["name"] == NAME
+ assert body["draft"]["tasks"][0]["instructions"] == HOSTILE_INSTRUCTIONS
+ assert (body["conversation"], body["focus"], body["references"]) == ([], None, [])
+
+ found = panel.locator(f"[data-workflow-assist-card='{body['submission_id']}']")
+ expect(found).to_have_attribute("data-state", "applied")
+ expect(found.locator("[data-workflow-assist-reply]")).to_have_text("Updated the description.")
+ expect(description_field(dialog)).to_have_value(DESCRIBED)
+ expect(author(changed(dialog, "description"))).to_have_text("AI assist")
+ expect(changes_toggle(dialog)).to_have_accessible_name("Changes (1 unsaved)")
+
+ # The first Save reviews the AI change; only Confirm and save accepts the proposal.
+ dialog.get_by_role("button", name="Save workflow", exact=True).click()
+ expect(panel.get_by_role("tab", name="Changes", exact=True)).to_have_attribute("aria-selected", "true")
+ review = panel.get_by_role("region", name="Review before saving", exact=True)
+ expect(review.get_by_role("heading", name="Review before saving", exact=True)).to_be_focused()
+ expect(review.locator("li").filter(has_text=RUN_AS_NOTE)).to_have_count(0)
+ assert not api.writes(), "Save accepted the proposal before Confirm and save."
+ review.get_by_role("button", name="Confirm and save", exact=True).click()
+ expect(dialog).to_have_count(0)
+
+ bodies = [call["body"] for call in api.writes()]
+ assert len(bodies) == 1, bodies
+ assert set(bodies[0]) == {"conversation_id", "workflow"}, sorted(bodies[0])
+ assert bodies[0]["conversation_id"] == CONVERSATION
+ workflow = bodies[0]["workflow"]
+ assert workflow["description"] == DESCRIBED
+ assert "id" not in workflow
+ assert not [call for call in api.requests if call["path"].startswith("/api/user/workflows")
+ and call["method"] == "POST"], "The editor saved a workflow directly."
+ expect(card(page).get_by_role("status")).to_have_text("Created, paused")
+
+
+def test_ask_ai_is_hidden_in_a_proposal_draft_when_the_assistant_is_off(ask_card_ui):
+ page, api, stub = ask_card_ui
+ mount(page, api)
+ dialog, _ = open_editor(page, card(page))
+
+ # An empty task would offer Draft with AI if Ask AI were available.
+ instructions = dialog.get_by_label("Instructions", exact=True).first
+ instructions.fill("")
+ expect(instructions).to_have_value("")
+ panel = open_panel(dialog)
+ expect(panel.get_by_role("tab")).to_have_count(1)
+ expect(panel.locator("[data-workflow-ask-ai]")).to_have_count(0)
+ expect(dialog.get_by_role("button", name="Ask AI", exact=True)).to_have_count(0)
+ expect(dialog.locator("[data-workflow-ask-ai-task]")).to_have_count(0)
+ expect(dialog.locator("[data-workflow-draft-ai]")).to_have_count(0)
+ expect(dialog.get_by_role("button", name=re.compile(r"^(Ask AI|Draft with AI)"))).to_have_count(0)
+ assert not stub.bodies
+
+
+def test_the_editor_leaves_the_chats_idle_assist_threads_alone(ask_card_ui):
+ page, api, stub = ask_card_ui
+ stub.queue(stub.changed({"op": "set_description", "description": DESCRIBED}, text="Updated the description."))
+ mount(page, api)
+ enable_ask_ai(page)
+ # Idle, so a touch in any other conversation would sweep it.
+ touch_thread(page, IMAGE_THREAD, CONVERSATION, "Make it brighter.")
+ assert thread_keys(page) == [IMAGE_THREAD]
+
+ dialog, _ = open_editor(page, card(page))
+ dialog.get_by_role("button", name="Ask AI", exact=True).click()
+ panel = side_panel(dialog)
+ box = panel.get_by_role("textbox", name="Message Ask AI", exact=True)
+ box.fill("Say that it flags anything urgent.")
+ box.press("Enter")
+ body = stub.wait_for_requests(page, 1)
+ found = panel.locator(f"[data-workflow-assist-card='{body['submission_id']}']")
+ expect(found).to_have_attribute("data-state", "applied")
+
+ # The editor's thread is in its own conversation, and recording the turn swept nothing.
+ keys = thread_keys(page)
+ workflow_keys = [key for key in keys if key.startswith("workflow:new:")]
+ assert len(workflow_keys) == 1 and IMAGE_THREAD in keys, keys
+
+ dialog.get_by_role("button", name="Cancel", exact=True).click()
+ page.get_by_role("dialog", name="Discard unsaved workflow changes?").get_by_role(
+ "button", name="Discard changes", exact=True).click()
+ expect(dialog).to_have_count(0)
+ # Closing the editor sweeps nothing either.
+ assert thread_keys(page) == sorted([IMAGE_THREAD, workflow_keys[0]])
+
+ # Once the editor has let its thread go, the chat prunes as before: a touch in another
+ # conversation sweeps both idle threads.
+ touch_thread(page, OTHER_THREAD, OTHER_CONVERSATION, "Add a legend.")
+ assert thread_keys(page) == [OTHER_THREAD]