diff --git a/application/single_app/config.py b/application/single_app/config.py
index b902ea3da..a3413ac05 100644
--- a/application/single_app/config.py
+++ b/application/single_app/config.py
@@ -101,7 +101,7 @@
EXECUTOR_TYPE = 'thread'
EXECUTOR_MAX_WORKERS = 30
SESSION_TYPE = 'filesystem'
-VERSION = "0.261.250"
+VERSION = "0.261.251"
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/App.tsx b/application/v2_ui/src/App.tsx
index fe00d0f2e..5c3ac4e54 100644
--- a/application/v2_ui/src/App.tsx
+++ b/application/v2_ui/src/App.tsx
@@ -13,6 +13,8 @@ import { initializeTheme, hydrateUiPreferences } from './stores/uiStore';
import { startImageApprovalTracking } from './lib/imageProposalResume';
import { useNotificationRuntime } from './lib/useNotificationRuntime';
import { useWorkflowAlertRuntime } from './lib/useWorkflowAlertRuntime';
+import { useWorkflowRunTracker } from './lib/useWorkflowRunTracker';
+import { workflowRunTrackerShouldRun } from './lib/workflowRunTracker';
import { restorePersistedRuns } from './stores/orchestrationStore';
import { ChatPage } from './pages/ChatPage';
import { HomePage } from './pages/HomePage';
@@ -182,6 +184,9 @@ export function App() {
useNotificationRuntime(Boolean(data) && !error);
// Workflow alerts that ask to pop up. It listens to the bell's count rather than polling.
useWorkflowAlertRuntime(Boolean(data) && !error);
+ // The saved workflows chats started: one tracker for the tab, for the run cards, the chat
+ // list's running tag and the results each run posts back to its chat.
+ useWorkflowRunTracker(Boolean(data) && !error && workflowRunTrackerShouldRun(data?.features));
if (loading) {
return ;
diff --git a/application/v2_ui/src/components/chat/ConversationRail.tsx b/application/v2_ui/src/components/chat/ConversationRail.tsx
index 81bcd19d0..afc2ec29b 100644
--- a/application/v2_ui/src/components/chat/ConversationRail.tsx
+++ b/application/v2_ui/src/components/chat/ConversationRail.tsx
@@ -53,6 +53,7 @@ import {
import { ConfirmDialog } from '../ui/ConfirmDialog';
import { Skeleton } from '../ui/primitives';
import { ConversationExportDialog } from './ConversationExportDialog';
+import { WorkflowRunningTag } from './WorkflowRunningTag';
import type { FocusEvent, ReactNode, RefObject } from 'react';
import type { Conversation } from '../../lib/types';
@@ -316,6 +317,7 @@ function ConversationRow({
/>
)}
+
diff --git a/application/v2_ui/src/components/chat/MessageActions.tsx b/application/v2_ui/src/components/chat/MessageActions.tsx
index 65efa0f2c..e2b4eab44 100644
--- a/application/v2_ui/src/components/chat/MessageActions.tsx
+++ b/application/v2_ui/src/components/chat/MessageActions.tsx
@@ -44,6 +44,7 @@ import { readSources } from '../../lib/messageDetails';
import { messageToPlainText } from '../../lib/messageText';
import { canMask, readMaskState } from '../../lib/masking';
import { buildReplyPreview, messageAuthorName } from '../../lib/sharedMessage';
+import { isWorkflowDeliveryMessage } from '../../lib/workflowDelivery';
import type { InspectorSection } from './MessageInspector';
import type { ChatMessage, Json } from '../../lib/types';
import {
@@ -483,6 +484,9 @@ export function MessageActions({
const [copied, setCopied] = useState(false);
const isUser = message.role === 'user';
+ // A workflow run posted this message: the server refuses to retry it, so it isn't offered.
+ // The message's own footer offers Retry workflow run when the run can still be resumed.
+ const workflowDelivery = isWorkflowDeliveryMessage(message);
const attempts = attemptState(message, attemptsByThread);
const sources = readSources(message as unknown as Json);
const masks = readMaskState(message);
@@ -616,7 +620,7 @@ export function MessageActions({
)
- ) : (
+ ) : workflowDelivery ? null : (
void retryMessage(message.id)}
diff --git a/application/v2_ui/src/components/chat/MessageList.tsx b/application/v2_ui/src/components/chat/MessageList.tsx
index 30d2170df..00330207b 100644
--- a/application/v2_ui/src/components/chat/MessageList.tsx
+++ b/application/v2_ui/src/components/chat/MessageList.tsx
@@ -38,6 +38,8 @@ import { OrchestrationMessageRecovery } from './OrchestrationRecoveryNotice';
import { OrchestrationOutputs } from './OrchestrationOutputs';
import { WorkflowProposalCards } from './WorkflowProposalCard';
import { WorkflowRunLinks } from './WorkflowRunLinks';
+import { WorkflowRunCard } from './WorkflowRunCard';
+import { WorkflowDeliveryFooter } from './WorkflowDeliveryFooter';
import { MessageInspector, type InspectorSection } from './MessageInspector';
import { ThoughtsList, ThoughtsProgressCard } from './ThoughtsList';
import { OrchestrationPlanCard } from './OrchestrationPlanCard';
@@ -78,6 +80,8 @@ import { normalizeOrchestrationAttempt } from '../../lib/orchestration';
import { isOrchestrationOutputArtifact } from '../../lib/orchestrationOutputs';
import { orchestrationProposedWorkflow } from '../../lib/workflowProposals';
import { orchestrationStartedWorkflow } from '../../lib/orchestrationWorkflowRuns';
+import { readWorkflowDelivery } from '../../lib/workflowDelivery';
+import { workflowRunTrackerShouldRun } from '../../lib/workflowRunTracker';
import { analysisUnavailableMessage, readSavedAnalysis, sameAnalysis } from '../../lib/savedAnalysis';
import { readMessagePrompt } from '../../lib/messagePrompt';
import { PromptCard } from './PromptCard';
@@ -797,6 +801,10 @@ function MessageBubbleInner({
const messages = useChatStore((state) => state.messages);
const activeConversationId = useChatStore((state) => state.activeConversationId);
const personalConversation = useChatStore((state) => state.activeConversationKind === 'personal');
+ // Live run status comes from the tab's workflow run tracker, which runs only while both flags are on.
+ const liveRunStatus = useBootstrapStore((state) => workflowRunTrackerShouldRun(state.data?.features));
+ // A result or note a chat-started workflow run posted back to this conversation.
+ const workflowDelivery = useMemo(() => readWorkflowDelivery(message), [message]);
// the thread, which is often: the list re-renders on each streaming token.
const masks = useMemo(() => readMaskState(message), [message]);
// A stable list, so the image cards' shared scope is not rebuilt on every render.
@@ -1122,11 +1130,14 @@ function MessageBubbleInner({
&& orchestrationProposedWorkflow(message.metadata?.orchestration) ? (
) : null}
- {/* A started workflow's run links only for the person who asked, in their own conversation. */}
+ {/* A started workflow's runs only for the person who asked, in their own conversation:
+ live while the tab keeps a run tracker, else as they stood when the answer loaded. */}
{orchestration.run_id && masks.ranges.length === 0 && personalConversation
&& message.conversation_id === activeConversationId
&& orchestrationStartedWorkflow(message.metadata?.orchestration) ? (
-
+ liveRunStatus
+ ?
+ :
) : null}
{/* Inside the bubble, because a generated file belongs to the reply
that produced it rather than sitting loose in the thread. */}
@@ -1149,6 +1160,16 @@ function MessageBubbleInner({
) : artifactCards
)}
+ {/* What the requester can do next with a result a workflow run posted back here. */}
+ {workflowDelivery && message.role === 'assistant' && masks.ranges.length === 0
+ && personalConversation && message.conversation_id === activeConversationId ? (
+
+ ) : null}
>
)}
diff --git a/application/v2_ui/src/components/chat/WorkflowDeliveryFooter.tsx b/application/v2_ui/src/components/chat/WorkflowDeliveryFooter.tsx
new file mode 100644
index 000000000..339b047ab
--- /dev/null
+++ b/application/v2_ui/src/components/chat/WorkflowDeliveryFooter.tsx
@@ -0,0 +1,130 @@
+// WorkflowDeliveryFooter.tsx
+// The actions under a message a chat-started workflow run posted back to its chat (phase 6b).
+//
+// The message itself, a label line naming the workflow and when it was asked for and then the
+// result or a note on how the run ended, is the server's text and renders like any other answer.
+// This footer adds what the chat can do next with it:
+//
+// - Follow up, on a result, makes that result the composer's source in this chat.
+// - Open run opens the run in Workflows.
+// - Retry workflow run, on a failed run's note, resumes that same run. It shows only while the
+// tab's run tracker has just read the run and the server says it would still resume it, for the
+// generation this note reported. It never starts a new run.
+//
+// The chat's own Retry and Edit are hidden on these messages: the server refuses both.
+
+import { useEffect, useMemo, useRef, useState } from 'react';
+import { Link } from 'react-router-dom';
+import { Loader2 } from 'lucide-react';
+import { GlassButton } from '../ui/primitives';
+import { useFeature } from '../../stores/bootstrapStore';
+import { useChatStore } from '../../stores/chatStore';
+import { useWorkflowRunTrackerStore } from '../../stores/workflowRunTrackerStore';
+import { useFocusFallback } from '../../lib/useFocusFallback';
+import { useWorkflowRunAction } from '../../lib/useWorkflowRunAction';
+import { requestWorkflowConversationRuns } from '../../lib/useWorkflowRunTracker';
+import {
+ workflowDeliveryCanRetry,
+ workflowDeliveryFollowUp,
+ workflowDeliveryFooterId,
+ workflowDeliveryRun,
+ WORKFLOW_DELIVERY_FOLLOW_UP_UNAVAILABLE_TEXT,
+ type WorkflowDeliveryMetadata,
+} from '../../lib/workflowDelivery';
+import { workflowRunHref } from '../../lib/workflowRunLink';
+
+const LINK_CLASS = 'inline-flex h-8 items-center rounded-xl px-3 text-sm font-medium text-accent hover:bg-surface-3 focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent';
+
+export function WorkflowDeliveryFooter({
+ messageId,
+ conversationId,
+ delivery,
+ metadata,
+}: {
+ messageId: string;
+ conversationId: string;
+ delivery: WorkflowDeliveryMetadata;
+ /** The message's whole metadata, which carries the result's descriptor. */
+ metadata: unknown;
+}) {
+ const resultsInChat = useFeature('enable_chat_workflow_results');
+ const workflowsOn = useFeature('allow_user_workflows');
+ const descriptor = useMemo(
+ () => (resultsInChat ? workflowDeliveryFollowUp(delivery, metadata) : null),
+ [resultsInChat, delivery, metadata],
+ );
+ const canRetry = useWorkflowRunTrackerStore((state) =>
+ workflowDeliveryCanRetry(state.snapshot, delivery, conversationId));
+ // The tracker's name for the run, when it has read it; the message's label names it either way.
+ const name = useWorkflowRunTrackerStore((state) =>
+ workflowDeliveryRun(state.snapshot, delivery, conversationId)?.row.workflow_name ?? '');
+ const { pending, outcome, run } = useWorkflowRunAction(conversationId, delivery.workflow_id, delivery.run_id);
+ const [followUpNote, setFollowUpNote] = useState('');
+ const section = useRef(null);
+ const armFocus = useFocusFallback(section, canRetry, pending !== null);
+ const failed = delivery.kind === 'failed';
+
+ // A failed run's note offers Retry only after a fresh read says the run can still be resumed.
+ useEffect(() => {
+ if (failed) {
+ void requestWorkflowConversationRuns(conversationId);
+ }
+ }, [failed, conversationId]);
+
+ if (!descriptor && !workflowsOn) {
+ return null;
+ }
+
+ const followUp = () => {
+ if (!descriptor) return;
+ if (useChatStore.getState().selectWorkflowResult(descriptor, conversationId)) {
+ setFollowUpNote('');
+ document.getElementById('composer-input')?.focus();
+ } else {
+ setFollowUpNote(WORKFLOW_DELIVERY_FOLLOW_UP_UNAVAILABLE_TEXT);
+ }
+ };
+ const note = outcome || followUpNote;
+
+ return (
+
+
+ );
+}
diff --git a/application/v2_ui/src/components/chat/WorkflowRunCard.tsx b/application/v2_ui/src/components/chat/WorkflowRunCard.tsx
new file mode 100644
index 000000000..1049b7dbf
--- /dev/null
+++ b/application/v2_ui/src/components/chat/WorkflowRunCard.tsx
@@ -0,0 +1,395 @@
+// WorkflowRunCard.tsx
+// The live status of the saved workflows a plan started, under the answer that started them.
+//
+// Phase 5's WorkflowRunLinks shows each run as it stood when the answer loaded. With live status
+// on, this card shows the same runs as the tab's one workflow run tracker last read them: how far a
+// running run has got, what a run waiting on the reader needs, where a finished run's results went,
+// and why a run stopped. The tracker owns every request. The card only asks it to read this chat's
+// runs, once on mount and again from Check now, and a run it hasn't read yet keeps its static link.
+// A run the tracker read for this answer that the list doesn't name, as when the list couldn't be
+// read, follows the list's runs, oldest request first. One whose run or step the list already names
+// is left out rather than shown twice.
+//
+// Every control comes from the row's server-computed actions, never from its status alone, and a
+// row this client can't read says "Status unavailable" and offers only Open run. Approving never
+// happens here: it opens the run, where the gate's own prompt and choices are shown. Workflow names
+// and every other server string render as plain text.
+
+import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
+import { clsx } from 'clsx';
+import { Link } from 'react-router-dom';
+import { Loader2 } from 'lucide-react';
+import { ConfirmDialog } from '../ui/ConfirmDialog';
+import { GlassButton } from '../ui/primitives';
+import { RunLink, useWorkflowRunLinkList } from './WorkflowRunLinks';
+import { M365_CONNECT_HREF } from '../../lib/m365Links';
+import { workflowRunDisplayName } from '../../lib/orchestrationWorkflowRuns';
+import { useFocusFallback } from '../../lib/useFocusFallback';
+import { useWorkflowRunAction } from '../../lib/useWorkflowRunAction';
+import { kickWorkflowRunTracker, requestWorkflowConversationRuns } from '../../lib/useWorkflowRunTracker';
+import { workflowDeliveryFooterId } from '../../lib/workflowDelivery';
+import { workflowRunHref } from '../../lib/workflowRunLink';
+import {
+ formatCheckedTime,
+ formatWorkflowElapsed,
+ workflowRetryBlockedText,
+ workflowRunRowControls,
+ workflowRunStatusLabel,
+ workflowRunStepLabel,
+ workflowRunStepText,
+ workflowWaitingText,
+ WORKFLOW_RESULTS_IN_HISTORY_TEXT,
+ WORKFLOW_RESULTS_POSTED_ELSEWHERE_TEXT,
+ WORKFLOW_RESULTS_POSTED_TEXT,
+ WORKFLOW_RESULTS_POSTING_TEXT,
+ WORKFLOW_RETRY_TURNED_OFF_TEXT,
+ WORKFLOW_RUN_CANCELLED_TEXT,
+ WORKFLOW_RUN_STATUS_UNAVAILABLE,
+ WORKFLOW_STATUS_HALTED_TEXT,
+ type WorkflowRunStatusRow,
+} from '../../lib/workflowRunStatus';
+import type { TrackedWorkflowRun } from '../../lib/workflowRunTracker';
+import { useChatStore } from '../../stores/chatStore';
+import {
+ useWorkflowRunTrackerStore,
+ workflowRunConversationRead,
+ workflowRunForAnswerStep,
+ workflowRunsCheckedAt,
+ workflowRunsForAnswer,
+} from '../../stores/workflowRunTrackerStore';
+
+const STATIC_FOOTNOTE = 'Status when this message loaded. Open the run for its progress and results.';
+const LINK_CLASS = 'inline-flex h-8 items-center rounded-xl px-3 text-sm font-medium text-accent hover:bg-surface-3 focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent';
+const BUSY_CLASS = 'aria-disabled:cursor-not-allowed aria-disabled:opacity-50';
+const FLASH_CLASSES = ['ring-2', 'ring-accent', 'rounded-2xl'];
+const FLASH_MS = 1400;
+
+function statusTone(row: WorkflowRunStatusRow | null): string {
+ switch (row?.status) {
+ case 'completed':
+ return 'bg-ok-soft text-ok';
+ case 'failed':
+ case 'expired':
+ case 'cancelled':
+ case 'completed_partial':
+ return 'bg-warn-soft text-warn';
+ case 'waiting':
+ return 'bg-accent-soft text-accent';
+ default:
+ return 'bg-surface-3 text-text-2';
+ }
+}
+
+/** Bring a delivered message into view, flash it as the drawer's jump does, and move focus to its footer. */
+function showDeliveredMessage(messageId: string): void {
+ const message = document.getElementById(`message-${messageId}`);
+ if (!message) {
+ return;
+ }
+ message.scrollIntoView({ behavior: 'smooth', block: 'center' });
+ message.classList.add(...FLASH_CLASSES);
+ window.setTimeout(() => message.classList.remove(...FLASH_CLASSES), FLASH_MS);
+ document.getElementById(workflowDeliveryFooterId(messageId))?.focus({ preventScroll: true });
+}
+
+function OpenRunLink({ workflowId, runId, name }: { workflowId: string; runId: string; name: string }) {
+ return (
+
+ Open run
+
+ );
+}
+
+/** What a finished run's card says about its results. */
+function FinishedText({ row, conversationId }: { row: WorkflowRunStatusRow; conversationId: string }) {
+ const { status, message_id: messageId } = row.delivery;
+ const delivered = status === 'delivered' && messageId !== null;
+ // Only a message on screen in this chat can be jumped to.
+ const shown = useChatStore((state) =>
+ delivered
+ && state.activeConversationId === conversationId
+ && state.messages.some((message) => message.id === messageId));
+ if (delivered && shown) {
+ return (
+ showDeliveredMessage(messageId)}>
+ {WORKFLOW_RESULTS_POSTED_TEXT}
+
+ );
+ }
+ let text = WORKFLOW_RESULTS_IN_HISTORY_TEXT;
+ if (status === 'delivered') {
+ text = WORKFLOW_RESULTS_POSTED_ELSEWHERE_TEXT;
+ } else if (status === 'pending' || status === 'delivering') {
+ text = WORKFLOW_RESULTS_POSTING_TEXT;
+ }
+ return
{text}
;
+}
+
+function LiveRunRow({
+ conversationId,
+ name,
+ run,
+ tracked,
+ live,
+ available,
+}: {
+ conversationId: string;
+ /** The workflow's name, rendered as plain text. */
+ name: string;
+ run: { workflowId: string; runId: string };
+ tracked: TrackedWorkflowRun;
+ /** The tracker is running and not halted, so the row's actions reflect a current read. */
+ live: boolean;
+ available: boolean;
+}) {
+ const { pending, outcome, run: act } = useWorkflowRunAction(conversationId, run.workflowId, run.runId);
+ const [confirmingCancel, setConfirmingCancel] = useState(false);
+ // A run that dropped out of the tracker's complete read has stopped, and how isn't known yet.
+ const row = !tracked.retired && tracked.row.kind === 'status' ? tracked.row : null;
+ const controls = row ? workflowRunRowControls(row, available) : null;
+ const canCancel = Boolean(live && controls?.cancel);
+ const canRetry = Boolean(live && controls?.retry);
+ const rowRef = useRef(null);
+ const armRetryFocus = useFocusFallback(rowRef, canRetry, pending !== null);
+ const armCancelFocus = useFocusFallback(rowRef, canCancel, confirmingCancel || pending !== null);
+
+ const details: ReactNode[] = [];
+ const actions: ReactNode[] = [];
+ let openRun = true;
+ if (row?.phase === 'running') {
+ // A queued run hasn't started a step yet.
+ const elapsed = formatWorkflowElapsed(row.elapsed_seconds);
+ const progress = [
+ row.status === 'running' ? workflowRunStepText(row) : '',
+ workflowRunStepLabel(row),
+ elapsed ? `${elapsed} elapsed` : '',
+ ].filter(Boolean);
+ if (progress.length > 0) {
+ details.push(
);
+ if (controls?.approve) {
+ // The run's own page shows the gate's prompt and choices; nothing is approved from here.
+ // Like Cancel and Retry, it waits while another action on this run is in flight.
+ const busy = pending !== null;
+ openRun = false;
+ actions.push(
+ {
+ if (busy) event.preventDefault();
+ }}>
+ Review and approve
+ ,
+ );
+ } else if (controls?.reconnect) {
+ actions.push(
+
+ Reconnect Microsoft 365
+ ,
+ );
+ }
+ } else if (row?.phase === 'finished') {
+ details.push();
+ } else if (row?.phase === 'failed') {
+ details.push(
{row.error}
);
+ if (row.retry_blocked) {
+ details.push(
{workflowRetryBlockedText(row.retry_blocked)}
);
+ }
+ if (controls?.retryTurnedOff) {
+ details.push(
+ >
+ ) : null}
+
+ );
+}
diff --git a/application/v2_ui/src/components/chat/WorkflowRunLinks.tsx b/application/v2_ui/src/components/chat/WorkflowRunLinks.tsx
index 84b67c718..ff85fb327 100644
--- a/application/v2_ui/src/components/chat/WorkflowRunLinks.tsx
+++ b/application/v2_ui/src/components/chat/WorkflowRunLinks.tsx
@@ -5,6 +5,9 @@
// run, which opens the run in Workflows with its run history open. Nothing polls: a reload reads
// the status again, and the run page shows live progress and results. A run that cannot be
// opened says why instead. Workflow names are the requester's own text, rendered as plain text.
+//
+// This is what shows while live run status is off. With it on, WorkflowRunCard shows the same
+// runs with their live status, and reuses the list read and the static link from here.
import { useCallback, useEffect, useRef, useState } from 'react';
import { clsx } from 'clsx';
@@ -31,7 +34,7 @@ function stateTone(state: WorkflowRunLinkState): string {
return 'bg-surface-3 text-text-2';
}
-function RunLink({ item }: { item: WorkflowRunLinkItem }) {
+export function RunLink({ item }: { item: WorkflowRunLinkItem }) {
const name = workflowRunDisplayName(item);
return (
@@ -57,13 +60,17 @@ function RunLink({ item }: { item: WorkflowRunLinkItem }) {
);
}
-/**
- * The saved workflow runs an answer's plan started, for its requester, in a personal conversation.
- *
- * The caller mounts this only when the answer's run completed a workflow_run step. The link route
- * decides what each link may show; a run the reader cannot open renders nothing.
- */
-export function WorkflowRunLinks({ conversationId, runId }: { conversationId: string; runId: string }) {
+export interface WorkflowRunLinkListState {
+ /** The runs the plan started, or null until the first read succeeds. */
+ items: WorkflowRunLinkItem[] | null;
+ loadError: string;
+ /** The plan's run is gone, or isn't the reader's: nothing to show. */
+ missing: boolean;
+ reload: () => Promise;
+}
+
+/** Read, once per answer, the runs its plan started. */
+export function useWorkflowRunLinkList(conversationId: string, runId: string): WorkflowRunLinkListState {
const [items, setItems] = useState(null);
const [loadError, setLoadError] = useState('');
const [missing, setMissing] = useState(false);
@@ -95,6 +102,18 @@ export function WorkflowRunLinks({ conversationId, runId }: { conversationId: st
return () => request.current?.abort();
}, [load]);
+ return { items, loadError, missing, reload: load };
+}
+
+/**
+ * The saved workflow runs an answer's plan started, for its requester, in a personal conversation.
+ *
+ * The caller mounts this only when the answer's run completed a workflow_run step. The link route
+ * decides what each link may show; a run the reader cannot open renders nothing.
+ */
+export function WorkflowRunLinks({ conversationId, runId }: { conversationId: string; runId: string }) {
+ const { items, loadError, missing, reload: load } = useWorkflowRunLinkList(conversationId, runId);
+
if (missing || (!loadError && !items?.length)) return null;
return (
diff --git a/application/v2_ui/src/components/chat/WorkflowRunningTag.tsx b/application/v2_ui/src/components/chat/WorkflowRunningTag.tsx
new file mode 100644
index 000000000..30f4d8202
--- /dev/null
+++ b/application/v2_ui/src/components/chat/WorkflowRunningTag.tsx
@@ -0,0 +1,26 @@
+// WorkflowRunningTag.tsx
+// A spinner in the chat list while a saved workflow run a chat started is still going, or its
+// results are still on their way back to that chat (phase 6b).
+//
+// It reads only what the tab's workflow run tracker already knows, so the list sends no request of
+// its own. It gives way to the unread dot: once a run's results are posted the chat is marked
+// unread, and the dot is what says there's something new to read. The workflow's name is
+// user-authored and is only ever rendered as text.
+
+import { Loader2 } from 'lucide-react';
+import { useWorkflowRunTrackerStore, workflowRunningTagLabel } from '../../stores/workflowRunTrackerStore';
+import type { Conversation } from '../../lib/types';
+
+export function WorkflowRunningTag({ conversation }: { conversation: Conversation }) {
+ const label = useWorkflowRunTrackerStore((state) => workflowRunningTagLabel(state.snapshot, conversation.id));
+
+ if (!label || conversation.has_unread_assistant_response) {
+ return null;
+ }
+
+ return (
+
+
+
+ );
+}
diff --git a/application/v2_ui/src/lib/m365Links.ts b/application/v2_ui/src/lib/m365Links.ts
new file mode 100644
index 000000000..1cd9df9be
--- /dev/null
+++ b/application/v2_ui/src/lib/m365Links.ts
@@ -0,0 +1,7 @@
+// m365Links.ts
+// Where V2 sends someone to connect or reconnect Microsoft 365.
+//
+// A classic page: V2 has no Microsoft 365 connection page of its own. The workflow proposal card
+// and the workflow run card both link here.
+
+export const M365_CONNECT_HREF = '/profile?tab=settings#m365-connection-status';
diff --git a/application/v2_ui/src/lib/notificationLinks.ts b/application/v2_ui/src/lib/notificationLinks.ts
index c8acbc2d9..09f06db80 100644
--- a/application/v2_ui/src/lib/notificationLinks.ts
+++ b/application/v2_ui/src/lib/notificationLinks.ts
@@ -19,9 +19,11 @@
// read back before it is used, and read back again just before the page is left.
import type { AppNotification } from './notifications';
+import type { WorkflowScope } from './workflowEditor';
import { readConversationParam } from './conversationUrl';
import { groupWorkspaceDocumentPath, groupWorkspacePath } from './groupWorkspaceNavigation';
import { publicWorkspacePath } from './publicWorkspaceNavigation';
+import { workflowRunHref } from './workflowRunLink';
import { requireWorkspaceId } from './workspaceContext';
export type NotificationTarget =
@@ -69,19 +71,123 @@ function route(path: string): ResolvedNotificationLink {
}
/**
- * The V2 route for one workflow run, once there is one.
+ * The V2 route for one workflow run: the Workflows section of the workspace the workflow lives
+ * in, with that workflow's run history open and the run expanded (workflowRunLink.ts). There
+ * is no separate `/runs/:runId` route; the section reads the run from its query, in personal
+ * and group workspaces alike.
*
- * Phase 6b adds the run page at `/workspace/workflows/:workflowId/runs/:runId`. Until then
- * this returns null and a workflow-activity link keeps opening the classic page. Filling
- * this in is the whole change needed to move those links, and a workflow notice with a run
- * but no link at all, into V2.
+ * Null when the workspace, the workflow or the run is unknown, or an id is one a link must not
+ * carry. The caller then keeps the classic link, or offers none, rather than guessing where
+ * the run lives.
*/
-export function v2WorkflowRunPath(workflowId: string | null, runId: string | null): string | null {
- void workflowId;
- void runId;
+export function v2WorkflowRunPath(
+ scope: WorkflowScope | null,
+ workflowId: string | null,
+ runId: string | null,
+): string | null {
+ const workflow = safeId(workflowId);
+ const run = safeId(runId);
+ // Checked here as well as typed: workflowRunHref reads anything that is not a group as personal.
+ if (!scope || (scope.type !== 'personal' && scope.type !== 'group') || !workflow || !run) {
+ return null;
+ }
+ try {
+ return workflowRunHref(workflow, run, scope);
+ } catch {
+ return null;
+ }
+}
+
+/**
+ * The notice types written about one workflow run: a workflow's own alerts
+ * (functions_workflow_runner.py) and the notices about a chat-started run whose results could
+ * not be posted to its chat (functions_workflow_chat_delivery.py). Only these open the run when
+ * they carry no link of their own; any other notice that happens to name a run does not.
+ */
+const WORKFLOW_NOTIFICATION_TYPES: ReadonlySet = new Set([
+ 'workflow_priority_alert',
+ 'workflow_chat_delivery',
+]);
+
+/**
+ * Whether the notice is about a Microsoft 365 action. Those are resolved on classic pages,
+ * which are the only ones that render the pending action, wherever the action id was written.
+ */
+function isMicrosoft365Notice(notification: AppNotification): boolean {
+ return 'm365_pending_action_id' in notification.metadata
+ || 'm365_pending_action_id' in notification.link_context;
+}
+
+function scopeOf(type: unknown, groupId: unknown): WorkflowScope | null {
+ if (type === 'personal') {
+ return { type: 'personal' };
+ }
+ if (type === 'group') {
+ const id = safeId(groupId);
+ return id ? { type: 'group', groupId: id } : null;
+ }
return null;
}
+/**
+ * The workspace the notice says its workflow lives in (`workflow_scope` and
+ * `workflow_group_id`). Never `group_id`: that is the group classic makes active when the
+ * notice opens, not a claim about where the workflow lives.
+ */
+function noticeWorkflowScope(notification: AppNotification): WorkflowScope | null {
+ return scopeOf(notification.metadata.workflow_scope, notification.metadata.workflow_group_id);
+}
+
+function linkWorkflowScope(url: URL): WorkflowScope | null {
+ return scopeOf(url.searchParams.get('scope'), url.searchParams.get('groupId'));
+}
+
+function sameScope(left: WorkflowScope, right: WorkflowScope): boolean {
+ if (left.type === 'group' || right.type === 'group') {
+ return left.type === 'group' && right.type === 'group' && left.groupId === right.groupId;
+ }
+ return true;
+}
+
+/** Whether the notice's own id, when it wrote one, agrees with the id its link names. */
+function agreesWithNotice(written: unknown, linked: string): boolean {
+ return written === undefined || written === null || written === '' || written === linked;
+}
+
+/**
+ * The V2 run a classic workflow-activity link names, or null to keep the classic page.
+ *
+ * Opened in V2 only when the link and the notice agree on everything: the workspace the
+ * workflow lives in, the workflow and the run. A Microsoft 365 notice stays classic.
+ */
+function workflowActivityRunPath(url: URL, notification: AppNotification): string | null {
+ if (isMicrosoft365Notice(notification)) {
+ return null;
+ }
+ const linked = linkWorkflowScope(url);
+ const written = noticeWorkflowScope(notification);
+ const workflowId = safeId(url.searchParams.get('workflowId'));
+ const runId = safeId(url.searchParams.get('runId'));
+ if (!linked || !written || !sameScope(linked, written) || !workflowId || !runId
+ || !agreesWithNotice(notification.metadata.workflow_id, workflowId)
+ || !agreesWithNotice(notification.metadata.run_id, runId)) {
+ return null;
+ }
+ return v2WorkflowRunPath(linked, workflowId, runId);
+}
+
+/** The run a workflow notice without a link of its own is about, or null for no link. */
+function unlinkedRunPath(notification: AppNotification): string | null {
+ if (!WORKFLOW_NOTIFICATION_TYPES.has(notification.notification_type) || isMicrosoft365Notice(notification)) {
+ return null;
+ }
+ return v2WorkflowRunPath(
+ noticeWorkflowScope(notification),
+ safeId(notification.metadata.workflow_id),
+ safeId(notification.metadata.run_id),
+ );
+}
+
function groupIdFor(notification: AppNotification): string | null {
return safeId(notification.link_context.group_id) ?? safeId(notification.metadata.group_id);
}
@@ -222,10 +328,7 @@ export function resolveNotificationLink(
): ResolvedNotificationLink {
const raw = notification.link_url;
if (!raw) {
- const runPath = v2WorkflowRunPath(
- safeId(notification.metadata.workflow_id),
- safeId(notification.metadata.run_id),
- );
+ const runPath = unlinkedRunPath(notification);
return runPath ? route(runPath) : NO_LINK;
}
if (!raw.trim()) {
@@ -269,10 +372,7 @@ export function resolveNotificationLink(
}
if (path === '/workflow-activity') {
- const runPath = v2WorkflowRunPath(
- safeId(url.searchParams.get('workflowId')),
- safeId(url.searchParams.get('runId')),
- );
+ const runPath = workflowActivityRunPath(url, notification);
return runPath ? route(runPath) : classic(linkHref(url), notification, origin);
}
diff --git a/application/v2_ui/src/lib/notifications.ts b/application/v2_ui/src/lib/notifications.ts
index 9c5b74303..509654c30 100644
--- a/application/v2_ui/src/lib/notifications.ts
+++ b/application/v2_ui/src/lib/notifications.ts
@@ -207,6 +207,10 @@ function describeType(type: string, category: string | undefined): Omit,
+ controlShown: boolean,
+ busy: boolean,
+): () => void {
+ const armed = useRef(false);
+
+ useEffect(() => {
+ if (!armed.current || busy) {
+ return;
+ }
+ armed.current = false;
+ if (controlShown) {
+ return;
+ }
+ const active = document.activeElement;
+ if (!active || active === document.body) {
+ container.current?.focus({ preventScroll: true });
+ }
+ }, [container, controlShown, busy]);
+
+ return useCallback(() => {
+ armed.current = true;
+ }, []);
+}
diff --git a/application/v2_ui/src/lib/useWorkflowRunAction.ts b/application/v2_ui/src/lib/useWorkflowRunAction.ts
new file mode 100644
index 000000000..36c5cfd5d
--- /dev/null
+++ b/application/v2_ui/src/lib/useWorkflowRunAction.ts
@@ -0,0 +1,112 @@
+// useWorkflowRunAction.ts
+// Cancel or Retry one run a chat started, from its row on the run card or its delivered note.
+//
+// A run has one action under way at most, wherever it was asked for: while the card's Cancel is
+// waiting on the server, the note's Retry for the same run waits too. After every outcome the
+// tracker is told the run may have changed and the chat's runs are read again, so what shows next
+// comes from the server, never from the click. The outcome's sentence stays until the run's status
+// changes, so "Retry requested." gives way once the run has moved on.
+
+import { useCallback, useEffect, useRef, useState } from 'react';
+import { create } from 'zustand';
+import {
+ cancelWorkflowRun,
+ retryWorkflowRun,
+ WORKFLOW_CANCEL_FAILED_TEXT,
+ WORKFLOW_RETRY_FAILED_TEXT,
+} from './workflowRunActions';
+import { kickWorkflowRunTracker, requestWorkflowConversationRuns } from './useWorkflowRunTracker';
+import type { TrackedWorkflowRun } from './workflowRunTracker';
+import { useWorkflowRunTrackerStore } from '../stores/workflowRunTrackerStore';
+
+export type WorkflowRunActionKind = 'cancel' | 'retry';
+
+interface PendingRunActions {
+ /** The action under way for each run, by run id. */
+ pending: Readonly>;
+}
+
+const usePendingRunActions = create(() => ({ pending: {} }));
+
+function claimRun(runId: string, kind: WorkflowRunActionKind): boolean {
+ const { pending } = usePendingRunActions.getState();
+ if (pending[runId]) {
+ return false;
+ }
+ usePendingRunActions.setState({ pending: { ...pending, [runId]: kind } });
+ return true;
+}
+
+function releaseRun(runId: string): void {
+ const { pending } = usePendingRunActions.getState();
+ if (!pending[runId]) {
+ return;
+ }
+ const next = { ...pending };
+ delete next[runId];
+ usePendingRunActions.setState({ pending: next });
+}
+
+/** What the tracker last said about a run, reduced to whether its status has changed. */
+function runStatusKey(tracked: TrackedWorkflowRun | undefined): string {
+ if (!tracked) return 'none';
+ if (tracked.retired) return 'retired';
+ return tracked.row.kind === 'status' ? tracked.row.status : 'unavailable';
+}
+
+export interface WorkflowRunActionState {
+ /** The action under way for this run, from here or anywhere else, or null. */
+ pending: WorkflowRunActionKind | null;
+ /** The sentence the last outcome asked for here, or empty. */
+ outcome: string;
+ run: (kind: WorkflowRunActionKind) => Promise;
+}
+
+export function useWorkflowRunAction(conversationId: string, workflowId: string, runId: string): WorkflowRunActionState {
+ const pending = usePendingRunActions((state) => state.pending[runId] ?? null);
+ const statusKey = useWorkflowRunTrackerStore((state) => runStatusKey(state.snapshot.runs[runId]));
+ // The status the run had when the outcome was set, so the outcome clears once it changes.
+ const [outcome, setOutcome] = useState<{ text: string; statusKey: string } | null>(null);
+ const mounted = useRef(true);
+
+ useEffect(() => {
+ mounted.current = true;
+ return () => {
+ mounted.current = false;
+ };
+ }, []);
+
+ useEffect(() => {
+ setOutcome((previous) => (previous && previous.statusKey !== statusKey ? null : previous));
+ }, [statusKey]);
+
+ const run = useCallback(async (kind: WorkflowRunActionKind) => {
+ if (!claimRun(runId, kind)) {
+ return;
+ }
+ setOutcome(null);
+ let text: string;
+ try {
+ const target = { workflowId, runId };
+ text = (kind === 'cancel' ? await cancelWorkflowRun(target) : await retryWorkflowRun(target)).text;
+ } catch {
+ text = kind === 'cancel' ? WORKFLOW_CANCEL_FAILED_TEXT : WORKFLOW_RETRY_FAILED_TEXT;
+ }
+ try {
+ kickWorkflowRunTracker();
+ await requestWorkflowConversationRuns(conversationId, { force: true });
+ } catch {
+ /* A failed read shows on the card; the outcome stands either way. */
+ } finally {
+ releaseRun(runId);
+ }
+ if (mounted.current) {
+ setOutcome({
+ text,
+ statusKey: runStatusKey(useWorkflowRunTrackerStore.getState().snapshot.runs[runId]),
+ });
+ }
+ }, [conversationId, workflowId, runId]);
+
+ return { pending, outcome: outcome?.text ?? '', run };
+}
diff --git a/application/v2_ui/src/lib/useWorkflowRunTracker.ts b/application/v2_ui/src/lib/useWorkflowRunTracker.ts
new file mode 100644
index 000000000..f85389f0c
--- /dev/null
+++ b/application/v2_ui/src/lib/useWorkflowRunTracker.ts
@@ -0,0 +1,272 @@
+// useWorkflowRunTracker.ts
+// Runs the tab's one workflow run tracker, and lands each result it sees posted back to a chat.
+//
+// Called once, from the application root, like useNotificationRuntime, rather than from the chat
+// page: the chat list's running tag shows wherever the list does, and a result can be posted while
+// the reader is anywhere in the app. The tracker is a module-level singleton and its start is
+// idempotent, so a remount or a change of page never adds a second one.
+//
+// A posted result lands as a streamed reply does. In the open chat the messages are re-read through
+// the store's normal path, never while a reply is still streaming, and the unread marker the server
+// set is then settled by the same watched-or-deferred rules. The re-read is dropped if the reader
+// sends a message while it is out, so it can't replace their question, and tried again once the
+// chat is quiet. In any other chat the marker shows in the list and the bell's count is refreshed.
+// The server has already marked the chat unread and added the bell notice, so nothing here creates
+// another.
+
+import { useEffect } from 'react';
+import { desktopNotificationPermission, desktopNotificationsEnabled } from './desktopNotifications';
+import { hasActiveOrchestration } from './orchestrationController';
+import { subscribeCompletedReplies, type CompletedReply } from './replyEvents';
+import { planWorkflowDeliveryLanding, workflowDeliveryMustWait, workflowDeliveryReply } from './workflowDelivery';
+import { createWorkflowRunTracker, type WorkflowRunTracker } from './workflowRunTracker';
+import { fetchWorkflowRunStatus, type WorkflowRunRow, type WorkflowRunStatusRow } from './workflowRunStatus';
+import { settleCompletedReply, useChatStore } from '../stores/chatStore';
+import { refreshNotificationCount } from '../stores/notificationStore';
+import { useOrchestrationStore } from '../stores/orchestrationStore';
+import { useWorkflowRunTrackerStore } from '../stores/workflowRunTrackerStore';
+
+/** How often a result waiting for the open chat to go quiet re-checks it, without a request. */
+const CHAT_QUIET_RECHECK_MS = 2_000;
+
+let tracker: WorkflowRunTracker | null = null;
+
+// Results posted to the open chat while it was busy, oldest first.
+const waitingDeliveries: WorkflowRunStatusRow[] = [];
+let reloadingMessages = false;
+let stopWaitingForChat: (() => void) | null = null;
+// Moves on each time the tracker stops, so a re-read that was out when it stopped doesn't put its
+// results back to wait.
+let deliveryEpoch = 0;
+
+// Runs that dropped out of the read being applied, settled together right after it.
+const retiredRows: WorkflowRunRow[] = [];
+let retiredQueued = false;
+
+function trackerInstance(): WorkflowRunTracker {
+ if (tracker === null) {
+ tracker = createWorkflowRunTracker({
+ fetchStatus: fetchWorkflowRunStatus,
+ setTimer: (callback, delayMs) => window.setTimeout(callback, delayMs),
+ clearTimer: (handle) => window.clearTimeout(handle as number),
+ now: () => Date.now(),
+ isVisible: () => document.visibilityState === 'visible',
+ subscribeVisibility: (listener) => {
+ document.addEventListener('visibilitychange', listener);
+ return () => document.removeEventListener('visibilitychange', listener);
+ },
+ // The same two checks the desktop notifier makes before it shows anything.
+ desktopNotificationsOn: () =>
+ desktopNotificationsEnabled() && desktopNotificationPermission() === 'granted',
+ onState: (snapshot) => useWorkflowRunTrackerStore.getState().publish(snapshot),
+ onDelivered: landDelivery,
+ // The server's notice for a result that couldn't be posted is the whole report.
+ onClosed: () => {
+ void refreshNotificationCount('action');
+ },
+ onRetired: settleRetiredRun,
+ });
+ }
+ return tracker;
+}
+
+/**
+ * Read one chat's runs through the tab's tracker. Resolves false when the tracker isn't running,
+ * has stopped for the page session, or the read failed.
+ */
+export function requestWorkflowConversationRuns(
+ conversationId: string,
+ options?: { force?: boolean },
+): Promise {
+ return tracker ? tracker.requestConversationRuns(conversationId, options) : Promise.resolve(false);
+}
+
+/** Tell the tracker a run may have started or changed, so it checks again soon. */
+export function kickWorkflowRunTracker(options?: { immediate?: boolean }): void {
+ tracker?.kick(options);
+}
+
+function chatIsBusy(conversationId: string): boolean {
+ const { streaming, messagesLoading } = useChatStore.getState();
+ return workflowDeliveryMustWait({
+ streaming,
+ messagesLoading,
+ orchestrationActive: hasActiveOrchestration(conversationId),
+ });
+}
+
+/** Reload the chat list from its first page, unless it hasn't loaded yet, is loading or is filtered. */
+function reloadConversationList(): void {
+ const { conversations, conversationsLoading, searchTerm, loadConversations } = useChatStore.getState();
+ if (conversations.length === 0 || conversationsLoading || searchTerm) {
+ return;
+ }
+ void loadConversations({ reset: true });
+}
+
+function settleDelivery(row: WorkflowRunStatusRow, current: boolean): void {
+ const listed = useChatStore.getState().conversations.find((item) => item.id === row.conversation_id);
+ // The server marks a chat unread whenever it posts a result to it.
+ settleCompletedReply(workflowDeliveryReply(row, listed?.title || null), { current, serverMarksUnread: true });
+}
+
+function stopWaiting(): void {
+ stopWaitingForChat?.();
+ stopWaitingForChat = null;
+}
+
+function waitForQuietChat(): void {
+ if (stopWaitingForChat) {
+ return;
+ }
+ const recheck = () => landWaitingDeliveries();
+ const stopChat = useChatStore.subscribe((state, previous) => {
+ if (
+ state.streaming !== previous.streaming
+ || state.messagesLoading !== previous.messagesLoading
+ || state.activeConversationId !== previous.activeConversationId
+ ) {
+ recheck();
+ }
+ });
+ const stopOrchestration = useOrchestrationStore.subscribe((state, previous) => {
+ if (state.inFlight !== previous.inFlight) {
+ recheck();
+ }
+ });
+ // A plan's stream ends in the orchestration controller, which no store reports directly.
+ const interval = window.setInterval(recheck, CHAT_QUIET_RECHECK_MS);
+ stopWaitingForChat = () => {
+ stopChat();
+ stopOrchestration();
+ window.clearInterval(interval);
+ };
+}
+
+/**
+ * Re-read the open chat, then settle the results that were waiting for it.
+ *
+ * The re-read is dropped if a reply started or the messages changed while it was out, such as a
+ * question the reader sent meanwhile. Its results then go back to wait for the next quiet moment.
+ */
+async function reloadAndSettle(rows: WorkflowRunStatusRow[]): Promise {
+ const epoch = deliveryEpoch;
+ let superseded = false;
+ try {
+ const outcome = await useChatStore.getState().reloadMessages({ onlyIfUnchanged: true });
+ superseded = outcome === 'superseded';
+ } finally {
+ reloadingMessages = false;
+ if (superseded) {
+ if (epoch === deliveryEpoch) {
+ waitingDeliveries.unshift(...rows);
+ }
+ } else {
+ const { activeConversationId, messages } = useChatStore.getState();
+ for (const row of rows) {
+ // Only a result now on screen counts as seen; one the re-read missed stays unread.
+ const shown = row.conversation_id === activeConversationId
+ && messages.some((message) => message.id === row.delivery.message_id);
+ settleDelivery(row, shown);
+ }
+ }
+ landWaitingDeliveries();
+ }
+}
+
+/**
+ * Land what is waiting: results for any other chat straight away, results for the open chat once
+ * it is quiet, with one re-read for all of them.
+ */
+function landWaitingDeliveries(): void {
+ if (reloadingMessages) {
+ return;
+ }
+ if (waitingDeliveries.length === 0) {
+ stopWaiting();
+ return;
+ }
+ const openId = useChatStore.getState().activeConversationId;
+ const landing = planWorkflowDeliveryLanding(waitingDeliveries, openId, openId !== null && chatIsBusy(openId));
+ waitingDeliveries.splice(0, waitingDeliveries.length, ...landing.waiting);
+ // Set before anything is settled, so a store change made while settling can't start a second
+ // re-read of the same results.
+ reloadingMessages = landing.reloadNow.length > 0;
+ if (waitingDeliveries.length === 0) {
+ stopWaiting();
+ } else {
+ waitForQuietChat();
+ }
+ for (const row of landing.elsewhere) {
+ settleDelivery(row, false);
+ if (!useChatStore.getState().conversations.some((item) => item.id === row.conversation_id)) {
+ reloadConversationList();
+ }
+ }
+ if (landing.reloadNow.length > 0) {
+ void reloadAndSettle(landing.reloadNow);
+ }
+}
+
+function landDelivery(row: WorkflowRunStatusRow): void {
+ waitingDeliveries.push(row);
+ landWaitingDeliveries();
+}
+
+/**
+ * Runs that were in flight and dropped out of one complete read, so they have stopped and how is
+ * not known. They are settled together once that read has been applied: the open chat's runs are
+ * read again, forced past the dedupe because the card's last read predates the drop; for any other
+ * chat the list and the bell are refreshed, because a result may have been posted while this tab
+ * wasn't looking. Each happens at most once per read, however many runs dropped out.
+ */
+function settleRetiredRuns(): void {
+ retiredQueued = false;
+ const rows = retiredRows.splice(0, retiredRows.length);
+ const openId = useChatStore.getState().activeConversationId;
+ if (openId !== null && rows.some((row) => row.conversation_id === openId)) {
+ void tracker?.requestConversationRuns(openId, { force: true });
+ }
+ if (rows.some((row) => row.conversation_id !== openId)) {
+ reloadConversationList();
+ void refreshNotificationCount('action');
+ }
+}
+
+function settleRetiredRun(row: WorkflowRunRow): void {
+ retiredRows.push(row);
+ if (!retiredQueued) {
+ retiredQueued = true;
+ queueMicrotask(settleRetiredRuns);
+ }
+}
+
+function checkAfterPlanAnswer(reply: CompletedReply): void {
+ // A plan's answer may have started workflows.
+ if (reply.source === 'orchestration') {
+ tracker?.kick({ immediate: true });
+ }
+}
+
+/**
+ * `ready` is whether the signed-in session has loaded and the user can use saved workflows that
+ * chats start (`workflowRunTrackerShouldRun`). The tracker stops when it turns false.
+ */
+export function useWorkflowRunTracker(ready: boolean): void {
+ useEffect(() => {
+ if (!ready) {
+ return undefined;
+ }
+ const instance = trackerInstance();
+ instance.start();
+ const stopListening = subscribeCompletedReplies(checkAfterPlanAnswer);
+ return () => {
+ stopListening();
+ instance.stop();
+ stopWaiting();
+ deliveryEpoch += 1;
+ waitingDeliveries.length = 0;
+ retiredRows.length = 0;
+ };
+ }, [ready]);
+}
diff --git a/application/v2_ui/src/lib/workflowAlertNotices.ts b/application/v2_ui/src/lib/workflowAlertNotices.ts
index 93d070108..d81628713 100644
--- a/application/v2_ui/src/lib/workflowAlertNotices.ts
+++ b/application/v2_ui/src/lib/workflowAlertNotices.ts
@@ -21,6 +21,7 @@ import { v2WorkflowRunPath } from './notificationLinks';
import { groupWorkspacePath } from './groupWorkspaceNavigation';
import { requireWorkspaceId } from './workspaceContext';
import { WORKFLOW_ALERT_SEVERITIES, type WorkflowAlertSeverity } from './workflowAlerts';
+import type { WorkflowScope } from './workflowEditor';
import { WORKFLOW_LINK_PARAM, WORKFLOW_RUN_LINK_PARAM } from './workflowRunLink';
import { isWorkflowResultIdentifier, isWorkflowResultReadableStatus } from './workflowResults';
@@ -330,7 +331,7 @@ function readLinks(metadata: Record, notification: AppNotificat
* `workflow_group_id` for a group workflow. It is never read from `metadata.group_id`, which
* classic takes as the group to make active when a notification opens. An alert from before
* the runner wrote them is placed by the conversation it posted into, and one that cannot be
- * placed offers no Open workflow rather than a guess.
+ * placed offers neither Open run nor Open workflow rather than a guess.
*/
function readScope(metadata: Record): WorkflowAlertScope | null {
const written = oneLine(metadata.workflow_scope).toLowerCase();
@@ -581,13 +582,16 @@ export function workflowAlertWorkflowPath(alert: WorkflowAlert): string | null {
}
/**
- * The run the alert is about, once runs have a V2 page.
- *
- * Null until the run deep link lands; the card then offers Open run in place of Open
- * workflow without any change here beyond the shared helper it calls.
+ * Where Open run goes: the run that raised the alert, opened in its workflow's run history in
+ * the workspace the workflow lives in (notificationLinks.ts v2WorkflowRunPath). It is the same
+ * address Open workflow names for an alert with a run. Null when the alert names no run or
+ * cannot be placed, and the card then offers Open workflow, or neither.
*/
export function workflowAlertOpenRunPath(alert: WorkflowAlert): string | null {
- return v2WorkflowRunPath(alert.workflowId, alert.runId);
+ const scope: WorkflowScope | null = alert.scope
+ ? (alert.scope.kind === 'group' ? { type: 'group', groupId: alert.scope.groupId } : { type: 'personal' })
+ : null;
+ return v2WorkflowRunPath(scope, alert.workflowId, alert.runId);
}
export interface WorkflowAlertFollowUp {
diff --git a/application/v2_ui/src/lib/workflowDelivery.ts b/application/v2_ui/src/lib/workflowDelivery.ts
new file mode 100644
index 000000000..83275582a
--- /dev/null
+++ b/application/v2_ui/src/lib/workflowDelivery.ts
@@ -0,0 +1,247 @@
+// workflowDelivery.ts
+// The messages a chat-started workflow run posts back to the chat that started it (phase 6b).
+//
+// When a plan starts one of the requester's saved workflows, the server later posts the run's
+// result, or a note saying how it ended, into that chat: once per generation of the run. Each
+// such message carries `metadata.workflow_delivery`, and its id starts with
+// `assistant_workflow_delivery_`. Either one marks it. The server refuses to retry or edit such a
+// message, so the chat hides its plain Retry. The message gets a footer instead: Follow up on a
+// result, Open run, and Retry on a failed run, only while the tracker's latest read of that run
+// says the server would still resume it.
+//
+// Everything here is pure, so it is checked in isolation by
+// functional_tests/test_v2_workflow_delivery_messages.mjs.
+
+import type { CompletedReply } from './replyEvents';
+import type { ChatMessage } from './types';
+import { readWorkflowResult } from './workflowResults';
+import type { WorkflowResultDescriptor } from './types';
+import {
+ isWorkflowRunIdentifier,
+ workflowRunRowControls,
+ WORKFLOW_DELIVERY_MESSAGE_PREFIX,
+ type WorkflowRunStatusRow,
+} from './workflowRunStatus';
+import type { TrackedWorkflowRun, WorkflowRunTrackerSnapshot } from './workflowRunTracker';
+
+export const WORKFLOW_DELIVERY_VERSION = 1;
+
+/** Said when Follow up couldn't make a delivered result the composer's source. */
+export const WORKFLOW_DELIVERY_FOLLOW_UP_UNAVAILABLE_TEXT = 'This result can\'t be used as a source here right now.';
+
+/** The kinds of message the server posts. `expired` is only ever a bell notice, never a message. */
+export const WORKFLOW_DELIVERY_KINDS = [
+ 'result', 'analysis', 'failed', 'cancelled', 'skipped', 'status', 'content_blocked',
+] as const;
+export type WorkflowDeliveryKind = typeof WORKFLOW_DELIVERY_KINDS[number];
+
+export interface WorkflowDeliveryMetadata {
+ version: typeof WORKFLOW_DELIVERY_VERSION;
+ /** A kind this client doesn't know reads as `unknown`, which offers Open run only. */
+ kind: WorkflowDeliveryKind | 'unknown';
+ workflow_id: string;
+ workflow_scope: 'personal';
+ run_id: string;
+ /** The run's control version when this message was composed; null when the server had none. */
+ generation: number | null;
+ run_status: string | null;
+ orchestration_run_id: string | null;
+ step_id: string | null;
+ requested_at: string | null;
+}
+
+/** The kinds whose message carries the run's result, so the chat can ask about it. */
+const FOLLOW_UP_KINDS: readonly WorkflowDeliveryKind[] = ['result', 'analysis'];
+
+function isRecord(value: unknown): value is Record {
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
+}
+
+function idOrNull(value: unknown): string | null | undefined {
+ if (value === null || value === undefined) return null;
+ return isWorkflowRunIdentifier(value) ? value : undefined;
+}
+
+function textOrNull(value: unknown): string | null | undefined {
+ if (value === null || value === undefined) return null;
+ return typeof value === 'string' ? value : undefined;
+}
+
+/**
+ * Whether a workflow run posted this message. The server's own test: the id prefix, or a
+ * `workflow_delivery` object in the metadata, whatever that object holds.
+ */
+export function isWorkflowDeliveryMessage(message: Pick | null | undefined): boolean {
+ if (!message) return false;
+ return (typeof message.id === 'string' && message.id.startsWith(WORKFLOW_DELIVERY_MESSAGE_PREFIX))
+ || isRecord(isRecord(message.metadata) ? message.metadata.workflow_delivery : null);
+}
+
+/**
+ * The delivery metadata a message carries, checked against what the server writes. Anything
+ * unexpected reads as none, so the message gets no footer; its plain Retry stays hidden anyway.
+ */
+export function parseWorkflowDeliveryMetadata(value: unknown): WorkflowDeliveryMetadata | null {
+ if (
+ !isRecord(value) || value.version !== WORKFLOW_DELIVERY_VERSION
+ || typeof value.kind !== 'string' || !value.kind.trim()
+ || !isWorkflowRunIdentifier(value.workflow_id) || !isWorkflowRunIdentifier(value.run_id)
+ || value.workflow_scope !== 'personal'
+ ) {
+ return null;
+ }
+ const generation = value.generation;
+ if (generation !== null && generation !== undefined
+ && !(typeof generation === 'number' && Number.isInteger(generation) && generation >= 0)) {
+ return null;
+ }
+ const orchestrationRunId = idOrNull(value.orchestration_run_id);
+ const stepId = idOrNull(value.step_id);
+ const runStatus = textOrNull(value.run_status);
+ const requestedAt = textOrNull(value.requested_at);
+ if (orchestrationRunId === undefined || stepId === undefined || runStatus === undefined
+ || requestedAt === undefined) {
+ return null;
+ }
+ const kind = (WORKFLOW_DELIVERY_KINDS as readonly string[]).includes(value.kind)
+ ? value.kind as WorkflowDeliveryKind
+ : 'unknown';
+ return {
+ version: WORKFLOW_DELIVERY_VERSION,
+ kind,
+ workflow_id: value.workflow_id,
+ workflow_scope: 'personal',
+ run_id: value.run_id,
+ generation: typeof generation === 'number' ? generation : null,
+ run_status: runStatus,
+ orchestration_run_id: orchestrationRunId,
+ step_id: stepId,
+ requested_at: requestedAt,
+ };
+}
+
+/** The delivery metadata of a message, or null when it carries none the client can read. */
+export function readWorkflowDelivery(message: Pick | null | undefined): WorkflowDeliveryMetadata | null {
+ return parseWorkflowDeliveryMetadata(isRecord(message?.metadata) ? message.metadata.workflow_delivery : null);
+}
+
+/**
+ * The result a delivered message lets the chat ask about: its own descriptor, only for a result
+ * or analysis, only while the server says it can still be read, and only for the same run.
+ */
+export function workflowDeliveryFollowUp(
+ delivery: WorkflowDeliveryMetadata,
+ metadata: unknown,
+): WorkflowResultDescriptor | null {
+ if (!FOLLOW_UP_KINDS.includes(delivery.kind as WorkflowDeliveryKind)) {
+ return null;
+ }
+ const descriptor = readWorkflowResult(metadata);
+ if (!descriptor || descriptor.available === false
+ || descriptor.workflow_id !== delivery.workflow_id || descriptor.run_id !== delivery.run_id) {
+ return null;
+ }
+ return descriptor;
+}
+
+/**
+ * The tracker's row for the run that posted a message: the same run, started by the same plan
+ * step, in the same chat. Undefined when the tracker hasn't read it, or it doesn't match.
+ */
+export function workflowDeliveryRun(
+ snapshot: WorkflowRunTrackerSnapshot,
+ delivery: WorkflowDeliveryMetadata,
+ conversationId: string,
+): TrackedWorkflowRun | undefined {
+ const tracked = snapshot.runs[delivery.run_id];
+ if (
+ !tracked || tracked.row.conversation_id !== conversationId
+ || tracked.row.workflow_id !== delivery.workflow_id
+ || tracked.row.orchestration_run_id !== delivery.orchestration_run_id
+ || tracked.row.step_id !== delivery.step_id
+ ) {
+ return undefined;
+ }
+ return tracked;
+}
+
+/**
+ * Whether a failed run's note may offer Retry: the tracker is reading, chats can start workflows,
+ * and its newest read of the same run says the server would resume it, for the generation this
+ * note reported. A note from an earlier generation never retries a run that has since moved on.
+ */
+export function workflowDeliveryCanRetry(
+ snapshot: WorkflowRunTrackerSnapshot,
+ delivery: WorkflowDeliveryMetadata,
+ conversationId: string,
+): boolean {
+ if (delivery.kind !== 'failed' || delivery.generation === null || !snapshot.running || snapshot.halted
+ || snapshot.available !== true) {
+ return false;
+ }
+ const tracked = workflowDeliveryRun(snapshot, delivery, conversationId);
+ if (!tracked || tracked.retired || tracked.row.kind !== 'status') {
+ return false;
+ }
+ return workflowRunRowControls(tracked.row, true).retry
+ && tracked.row.delivery.generation === delivery.generation;
+}
+
+/** The DOM id of a delivered message's footer, so the run card can move focus to it. */
+export function workflowDeliveryFooterId(messageId: string): string {
+ return `workflow-delivery-${messageId}`;
+}
+
+/**
+ * The reply a posted result settles as: a workflow's, so the desktop notice and the unread rules
+ * treat it as one, and keyed by the posted message, so one result is never announced twice.
+ */
+export function workflowDeliveryReply(row: WorkflowRunStatusRow, conversationTitle: string | null): CompletedReply {
+ return {
+ conversationId: row.conversation_id,
+ messageId: row.delivery.message_id,
+ runId: row.run_id,
+ conversationTitle,
+ blocked: false,
+ source: 'workflow',
+ };
+}
+
+/** What the open chat is doing, as far as re-reading its messages is concerned. */
+export interface OpenChatActivity {
+ /** A reply is streaming into it. */
+ streaming: boolean;
+ /** Its messages are being read. */
+ messagesLoading: boolean;
+ /** A plan is still running in it, whose stream the chat store doesn't report. */
+ orchestrationActive: boolean;
+}
+
+/** Whether a result posted to the open chat must wait before its messages are re-read. */
+export function workflowDeliveryMustWait(activity: OpenChatActivity): boolean {
+ return activity.streaming || activity.messagesLoading || activity.orchestrationActive;
+}
+
+export interface WorkflowDeliveryLanding {
+ /** Results for any other chat, settled straight away. */
+ elsewhere: T[];
+ /** Results for the open chat, settled after one re-read of its messages. */
+ reloadNow: T[];
+ /** Results for the open chat that wait until it is quiet. */
+ waiting: T[];
+}
+
+/**
+ * Where each posted result lands: another chat's straight away; the open chat's after one re-read,
+ * or later when the open chat is busy. `openConversationId` is null when no chat is open.
+ */
+export function planWorkflowDeliveryLanding(
+ rows: readonly T[],
+ openConversationId: string | null,
+ openChatBusy: boolean,
+): WorkflowDeliveryLanding {
+ const elsewhere = rows.filter((row) => row.conversation_id !== openConversationId);
+ const here = rows.filter((row) => row.conversation_id === openConversationId);
+ const now = here.length > 0 && openConversationId !== null && !openChatBusy;
+ return { elsewhere, reloadNow: now ? here : [], waiting: now ? [] : here };
+}
diff --git a/application/v2_ui/src/lib/workflowEditor.ts b/application/v2_ui/src/lib/workflowEditor.ts
index 235779166..618af1754 100644
--- a/application/v2_ui/src/lib/workflowEditor.ts
+++ b/application/v2_ui/src/lib/workflowEditor.ts
@@ -959,6 +959,12 @@ function taskIdFallback(): string {
return `${hex.slice(0, 4).join('')}-${hex.slice(4, 6).join('')}-${hex.slice(6, 8).join('')}-${hex.slice(8, 10).join('')}-${hex.slice(10).join('')}`;
}
+// A fresh UUID for one runtime request. Each attempt gets its own, so a retry is never mistaken
+// for a replay of an earlier one.
+export function newWorkflowRequestId(): string {
+ return taskIdFallback();
+}
+
export function createWorkflowTask(index: number): WorkflowTask {
return {
id: taskIdFallback(),
@@ -2537,6 +2543,10 @@ export const startScopedWorkflowRun = (scope: WorkflowScope, workflowId: string)
export const cancelScopedWorkflow = (scope: WorkflowScope, workflowId: string) =>
api.post(workflowUrl(scope, workflowId, '/cancel'));
+// Asks one run to stop. The workflow-level cancel above stops whichever run is active instead.
+export const cancelScopedWorkflowRun = (scope: WorkflowScope, workflowId: string, runId: string) =>
+ api.post(workflowUrl(scope, workflowId, `/runs/${encodeURIComponent(runId)}/cancel`));
+
export const deleteScopedWorkflow = (scope: WorkflowScope, workflowId: string) =>
api.delete<{ success?: boolean }>(workflowUrl(scope, workflowId));
diff --git a/application/v2_ui/src/lib/workflowRunActions.ts b/application/v2_ui/src/lib/workflowRunActions.ts
new file mode 100644
index 000000000..e7825547f
--- /dev/null
+++ b/application/v2_ui/src/lib/workflowRunActions.ts
@@ -0,0 +1,149 @@
+// workflowRunActions.ts
+// Cancel and Retry for one run a chat started, and the plain sentence each outcome reads as.
+//
+// Every run a chat starts is a personal, durable run. Retry is that runtime's resume: it keeps the
+// run id, so the run's results still come back to the chat. It is never `/resume-failed`, which
+// refuses durable runs and would otherwise start a run the chat never hears from. Retry reads the
+// runtime first, for the version to resume from and to confirm the run can still be resumed, and
+// sends a fresh request id each attempt. Cancel is the run-level cancel, never the workflow-level one.
+//
+// Only fixed codes are read from a refusal, so a server sentence is never shown. The caller
+// re-reads the run's status after every outcome, whatever it was.
+
+import { ApiError } from './apiClient';
+import {
+ cancelScopedWorkflowRun,
+ fetchWorkflowRuntime,
+ newWorkflowRequestId,
+ resumeWorkflowRuntime,
+ workflowRuntimeCanResume,
+ type WorkflowScope,
+} from './workflowEditor';
+import {
+ WORKFLOW_RETRY_BLOCKED_CODES,
+ workflowRetryBlockedText,
+ type WorkflowRetryBlockedCode,
+} from './workflowRunStatus';
+
+const PERSONAL_SCOPE: WorkflowScope = { type: 'personal' };
+
+export interface WorkflowRunActionTarget {
+ workflowId: string;
+ runId: string;
+}
+
+export interface WorkflowRunActionOutcome {
+ ok: boolean;
+ text: string;
+}
+
+export const WORKFLOW_RETRY_REQUESTED_TEXT = 'Retry requested.';
+export const WORKFLOW_CANCEL_REQUESTED_TEXT = 'Cancel requested.';
+
+const RUN_GONE_TEXT = 'This run is no longer available.';
+const NO_ACCESS_TEXT = 'You don\'t have access to this run.';
+const WORKFLOWS_UNAVAILABLE_TEXT = 'Workflows aren\'t available right now. Try again later.';
+const RETRY_REJECTED_TEXT = 'The retry request wasn\'t accepted.';
+export const WORKFLOW_RETRY_FAILED_TEXT = 'Couldn\'t retry the run. Try again.';
+const RETRY_UNCONFIRMED_TEXT = 'Couldn\'t confirm the retry. Check its status.';
+const RETRY_NOT_RESUMABLE_TEXT = 'This run can\'t be retried anymore.';
+const CANCEL_CONFLICT_TEXT = 'This run already finished or changed. Check its status.';
+export const WORKFLOW_CANCEL_FAILED_TEXT = 'Couldn\'t cancel the run. Try again.';
+
+// The refusals only a resume returns. The ones a status row can also carry read the same either way.
+const RESUME_CONFLICT_TEXT = new Map([
+ ['workflow_deleting', 'The workflow is being deleted, so this run can\'t be retried.'],
+ ['stale_version', 'The run changed since it was checked. Check its status and try again.'],
+ ['invalid_state', 'This run can\'t be retried in its current state.'],
+ ['request_conflict', 'Another request changed this run. Check its status and try again.'],
+]);
+const RESUME_CONFLICT_FALLBACK_TEXT = 'This run can\'t be retried right now. Check its status.';
+
+function refusalCode(error: ApiError): string {
+ const payload = error.payload;
+ if (payload === null || typeof payload !== 'object' || Array.isArray(payload)) {
+ return '';
+ }
+ const code = (payload as Record).code;
+ return typeof code === 'string' ? code : '';
+}
+
+function resumeConflictText(code: string): string {
+ if ((WORKFLOW_RETRY_BLOCKED_CODES as readonly string[]).includes(code)) {
+ return workflowRetryBlockedText(code as WorkflowRetryBlockedCode);
+ }
+ return RESUME_CONFLICT_TEXT.get(code) ?? RESUME_CONFLICT_FALLBACK_TEXT;
+}
+
+// What a refused read or resume reads as, for the answers both can give.
+function sharedRefusalText(status: number): string | null {
+ if (status === 403) return NO_ACCESS_TEXT;
+ if (status === 404) return RUN_GONE_TEXT;
+ if (status === 503) return WORKFLOWS_UNAVAILABLE_TEXT;
+ return null;
+}
+
+function resumeFailureText(error: unknown): string {
+ if (error instanceof ApiError) {
+ if (error.status === 409) return resumeConflictText(refusalCode(error));
+ if (error.status === 400) return RETRY_REJECTED_TEXT;
+ return sharedRefusalText(error.status) ?? WORKFLOW_RETRY_FAILED_TEXT;
+ }
+ // fetch rejects with a TypeError when no answer came back. Anything else failed after a success
+ // status, while reading the answer, so the retry may well have gone through.
+ return error instanceof TypeError ? WORKFLOW_RETRY_FAILED_TEXT : RETRY_UNCONFIRMED_TEXT;
+}
+
+/**
+ * Resume a failed run where it stopped. The runtime is read fresh for the version to resume from;
+ * a run that can no longer be resumed, or a read that fails, sends nothing.
+ */
+export async function retryWorkflowRun(target: WorkflowRunActionTarget): Promise {
+ let expectedVersion: number;
+ try {
+ const { runtime } = await fetchWorkflowRuntime(PERSONAL_SCOPE, target.workflowId, target.runId);
+ if (!workflowRuntimeCanResume(runtime)) {
+ return { ok: false, text: RETRY_NOT_RESUMABLE_TEXT };
+ }
+ if (!Number.isSafeInteger(runtime.version) || runtime.version < 0) {
+ return { ok: false, text: WORKFLOW_RETRY_FAILED_TEXT };
+ }
+ expectedVersion = runtime.version;
+ } catch (error) {
+ return {
+ ok: false,
+ text: (error instanceof ApiError ? sharedRefusalText(error.status) : null) ?? WORKFLOW_RETRY_FAILED_TEXT,
+ };
+ }
+ let requestId: string;
+ try {
+ requestId = newWorkflowRequestId();
+ } catch {
+ return { ok: false, text: WORKFLOW_RETRY_FAILED_TEXT };
+ }
+ try {
+ await resumeWorkflowRuntime(PERSONAL_SCOPE, target.workflowId, target.runId, {
+ expected_version: expectedVersion,
+ request_id: requestId,
+ });
+ return { ok: true, text: WORKFLOW_RETRY_REQUESTED_TEXT };
+ } catch (error) {
+ return { ok: false, text: resumeFailureText(error) };
+ }
+}
+
+/** Ask one run to stop. Whatever it already did stays done. */
+export async function cancelWorkflowRun(target: WorkflowRunActionTarget): Promise {
+ try {
+ await cancelScopedWorkflowRun(PERSONAL_SCOPE, target.workflowId, target.runId);
+ return { ok: true, text: WORKFLOW_CANCEL_REQUESTED_TEXT };
+ } catch (error) {
+ if (error instanceof ApiError && error.status === 404) {
+ return { ok: false, text: RUN_GONE_TEXT };
+ }
+ if (error instanceof ApiError && error.status === 409) {
+ return { ok: false, text: CANCEL_CONFLICT_TEXT };
+ }
+ return { ok: false, text: WORKFLOW_CANCEL_FAILED_TEXT };
+ }
+}
diff --git a/application/v2_ui/src/lib/workflowRunLink.ts b/application/v2_ui/src/lib/workflowRunLink.ts
index b1759031c..b380edbb4 100644
--- a/application/v2_ui/src/lib/workflowRunLink.ts
+++ b/application/v2_ui/src/lib/workflowRunLink.ts
@@ -4,21 +4,40 @@
// `?workflow_id=` has always opened that workflow. Adding `&run_id=` opens the
// workflow's run history instead, with that run expanded. Document provenance links use the
// run form; the server builds them (functions_document_provenance.workflow_origin_href). The
-// links to runs a chat plan started use it too, built by `workflowRunHref`. This module is the
-// one place the client builds or reads them.
+// links to runs a chat plan started use it too, built by `workflowRunHref`, and so do the
+// notices and alerts that name a run (notificationLinks.ts v2WorkflowRunPath). This module is
+// the one place the client builds or reads them.
+//
+// The run link is this query form on the workspace's Workflows section. There is no separate
+// `/runs/:runId` route: the section opens the run inspector from the query in both personal
+// and group workspaces (WorkflowsSection.tsx).
+
+import type { WorkflowScope } from './workflowEditor';
+import { groupWorkspacePath } from './groupWorkspaceNavigation';
export const WORKFLOW_LINK_PARAM = 'workflow_id';
export const WORKFLOW_RUN_LINK_PARAM = 'run_id';
+const PERSONAL_SCOPE: WorkflowScope = { type: 'personal' };
+
export interface WorkflowRunLink {
workflowId: string;
/** Null when the link names only the workflow. */
runId: string | null;
}
-/** The V2 Workflows page with one workflow's run history open and the run `runId` expanded. */
-export function workflowRunHref(workflowId: string, runId: string): string {
+/**
+ * The V2 Workflows page with one workflow's run history open and the run `runId` expanded.
+ *
+ * Personal by default, which is where every run a chat plan starts lives. A group run opens
+ * that group's Workflows section; the group id is checked there, and one a path segment cannot
+ * carry throws rather than building a different path.
+ */
+export function workflowRunHref(workflowId: string, runId: string, scope: WorkflowScope = PERSONAL_SCOPE): string {
const params = new URLSearchParams({ [WORKFLOW_LINK_PARAM]: workflowId, [WORKFLOW_RUN_LINK_PARAM]: runId });
+ if (scope.type === 'group') {
+ return `${groupWorkspacePath(scope.groupId, 'workflows')}?${params}`;
+ }
return `/workspace/workflows?${params}`;
}
diff --git a/application/v2_ui/src/lib/workflowRunStatus.ts b/application/v2_ui/src/lib/workflowRunStatus.ts
new file mode 100644
index 000000000..9ed0e2855
--- /dev/null
+++ b/application/v2_ui/src/lib/workflowRunStatus.ts
@@ -0,0 +1,532 @@
+// workflowRunStatus.ts
+// The live status of the saved workflow runs a user's chats started.
+//
+// One batched route reports every chat-started run: its status and phase, its steps, what it is
+// waiting for, how its results are being posted back to the chat, and which actions the server
+// allows on it (GET /api/v2/orchestration/workflow-runs/status). The app-shell tracker polls it;
+// the run card, the chat list's running tag and the delivered-message footers only read what the
+// tracker kept.
+//
+// Every row is checked against the closed sets the server sends. A row whose ids can't be used is
+// dropped. A row that is otherwise malformed, or that carries a value this client doesn't know,
+// becomes "Status unavailable" with only Open run: the card never guesses at a state, a reason or
+// an action. Every string kept here is shown as text, never as markup.
+
+import { api } from './apiClient';
+
+export const WORKFLOW_RUN_STATUS_PATH = '/api/v2/orchestration/workflow-runs/status';
+export const WORKFLOW_RUN_STATUS_INVALID_RESPONSE = 'The workflow run status returned an invalid response.';
+
+/** The id prefix of every message a workflow run posts back to its chat. */
+export const WORKFLOW_DELIVERY_MESSAGE_PREFIX = 'assistant_workflow_delivery_';
+
+export const WORKFLOW_RUN_ROW_STATUSES = [
+ 'queued', 'running', 'waiting', 'completed', 'completed_partial', 'failed', 'cancelled', 'expired',
+] as const;
+export type WorkflowRunRowStatus = typeof WORKFLOW_RUN_ROW_STATUSES[number];
+
+export const WORKFLOW_RUN_ROW_PHASES = ['running', 'needs_you', 'finished', 'failed', 'cancelled'] as const;
+export type WorkflowRunRowPhase = typeof WORKFLOW_RUN_ROW_PHASES[number];
+
+export const WORKFLOW_DELIVERY_ROW_STATUSES = [
+ 'pending', 'delivering', 'delivered', 'undeliverable', 'expired', 'not_applicable',
+] as const;
+export type WorkflowDeliveryRowStatus = typeof WORKFLOW_DELIVERY_ROW_STATUSES[number];
+
+export const WORKFLOW_DELIVERY_REASONS = [
+ 'chat_unavailable', 'access_lost', 'workflow_deleted', 'runtime_missing', 'delivery_failed',
+ 'expired_before_delivery', 'content_blocked', 'results_off', 'result_unavailable', 'deadline_exceeded',
+] as const;
+export type WorkflowDeliveryReason = typeof WORKFLOW_DELIVERY_REASONS[number];
+
+export const WORKFLOW_WAITING_REASONS = [
+ 'approval', 'microsoft_365_reconnect', 'microsoft_365_approval', 'output_review', 'recovery',
+ 'deadline_exceeded', 'paused',
+] as const;
+export type WorkflowWaitingReason = typeof WORKFLOW_WAITING_REASONS[number];
+
+export const WORKFLOW_WAITING_ACTIONS = ['approve', 'reconnect', 'open_run'] as const;
+export type WorkflowWaitingAction = typeof WORKFLOW_WAITING_ACTIONS[number];
+
+export const WORKFLOW_RETRY_BLOCKED_CODES = [
+ 'retry_unavailable', 'workflow_deleted', 'not_resumable', 'deadline_exceeded',
+ 'workflow_definition_changed', 'workflow_already_running',
+] as const;
+export type WorkflowRetryBlockedCode = typeof WORKFLOW_RETRY_BLOCKED_CODES[number];
+
+export const WORKFLOW_FAILURE_CODES = [
+ 'failed', 'invalid', 'incomplete', 'skipped', 'deadline_exceeded', 'execution_budget_exceeded',
+ 'repeat_iteration_limit', 'm365_authorization', 'authorization',
+] as const;
+export type WorkflowFailureCode = typeof WORKFLOW_FAILURE_CODES[number];
+
+// The one phase the server pairs with each status. Any other pairing is a row this client
+// can't read, and it is shown as unavailable rather than guessed at.
+const PHASE_FOR_STATUS: Record = {
+ queued: 'running',
+ running: 'running',
+ waiting: 'needs_you',
+ completed: 'finished',
+ completed_partial: 'finished',
+ failed: 'failed',
+ expired: 'failed',
+ cancelled: 'cancelled',
+};
+
+export interface WorkflowRunWaiting {
+ reason: WorkflowWaitingReason;
+ action: WorkflowWaitingAction;
+ gate_id: string | null;
+}
+
+export interface WorkflowRunDelivery {
+ status: WorkflowDeliveryRowStatus;
+ generation: number | null;
+ /** Only once delivered, and only an id with WORKFLOW_DELIVERY_MESSAGE_PREFIX. */
+ message_id: string | null;
+ delivered_at: string | null;
+ reason: WorkflowDeliveryReason | null;
+}
+
+export interface WorkflowRunActions {
+ cancel: boolean;
+ retry: boolean;
+ approve: boolean;
+ open_run: boolean;
+}
+
+interface WorkflowRunRowIdentity {
+ workflow_id: string;
+ workflow_scope: 'personal';
+ run_id: string;
+ conversation_id: string;
+ /** The plan run that started it. Null rows are tracked but join no answer. */
+ orchestration_run_id: string | null;
+ step_id: string | null;
+ /** One line, at most 80 characters. */
+ workflow_name: string;
+ requested_at: string | null;
+}
+
+export interface WorkflowRunStatusRow extends WorkflowRunRowIdentity {
+ kind: 'status';
+ status: WorkflowRunRowStatus;
+ phase: WorkflowRunRowPhase;
+ /** The `expected_version` a resume would need when the row was read; a resume reads it fresh. */
+ runtime_version: number | null;
+ /** Steps finished. */
+ step_index: number | null;
+ step_count: number | null;
+ step_label: string | null;
+ started_at: string | null;
+ completed_at: string | null;
+ elapsed_seconds: number | null;
+ waiting: WorkflowRunWaiting | null;
+ delivery: WorkflowRunDelivery;
+ error: string | null;
+ error_code: WorkflowFailureCode | null;
+ retry_blocked: WorkflowRetryBlockedCode | null;
+ actions: WorkflowRunActions;
+ live: boolean;
+}
+
+/** A row whose ids are good but whose status can't be read. It offers Open run and nothing else. */
+export interface WorkflowRunUnavailableRow extends WorkflowRunRowIdentity {
+ kind: 'unavailable';
+}
+
+export type WorkflowRunRow = WorkflowRunStatusRow | WorkflowRunUnavailableRow;
+
+export interface WorkflowRunStatusResponse {
+ /** Whether chats can start workflows now. Rows come back either way. */
+ available: boolean;
+ runs: WorkflowRunRow[];
+ checked_at: string;
+ /** More rows matched than the route returns. */
+ truncated: boolean;
+}
+
+const NAME_MAX_LENGTH = 80;
+const DEFAULT_NAME = 'Workflow';
+const ID_MAX_LENGTH = 256;
+// The server writes every time in this form, truncated to whole seconds.
+const SECONDS_TIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/;
+// The status route's own rule for a conversation id; anything else is refused with a 400.
+const STATUS_CONVERSATION_ID = /^[A-Za-z0-9_-]{1,128}$/;
+
+function isRecord(value: unknown): value is Record {
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
+}
+
+function invalid(): never {
+ throw new Error(WORKFLOW_RUN_STATUS_INVALID_RESPONSE);
+}
+
+function oneOf(values: readonly T[], value: unknown): value is T {
+ return typeof value === 'string' && (values as readonly string[]).includes(value);
+}
+
+/**
+ * An id the server would have kept: 1 to 256 characters, no surrounding space, and no control
+ * characters or lone surrogates. Mirrors `_identifier` in functions_workflow_chat_delivery_status.py.
+ */
+export function isWorkflowRunIdentifier(value: unknown): value is string {
+ if (typeof value !== 'string' || value !== value.trim()) {
+ return false;
+ }
+ let length = 0;
+ for (const character of value) {
+ const code = character.codePointAt(0) ?? 0;
+ if (code < 32 || (code >= 0xd800 && code <= 0xdfff)) {
+ return false;
+ }
+ length += 1;
+ }
+ return length > 0 && length <= ID_MAX_LENGTH;
+}
+
+/** Whether the status route accepts this conversation id; reads for any other are never sent. */
+export function isStatusConversationId(value: unknown): value is string {
+ return typeof value === 'string' && STATUS_CONVERSATION_ID.test(value);
+}
+
+function isSecondsTime(value: unknown): value is string {
+ return typeof value === 'string' && SECONDS_TIME.test(value) && !Number.isNaN(Date.parse(value));
+}
+
+function isCount(value: unknown): value is number {
+ return typeof value === 'number' && Number.isInteger(value) && value >= 0;
+}
+
+function countOrNull(value: unknown): number | null | undefined {
+ if (value === null) return null;
+ return isCount(value) ? value : undefined;
+}
+
+function timeOrNull(value: unknown): string | null | undefined {
+ if (value === null) return null;
+ return isSecondsTime(value) ? value : undefined;
+}
+
+function textOrNull(value: unknown): string | null | undefined {
+ if (value === null) return null;
+ return typeof value === 'string' ? value : undefined;
+}
+
+function oneLine(value: string): string {
+ return value.replace(/\s+/g, ' ').trim();
+}
+
+function displayName(value: unknown): string {
+ if (typeof value !== 'string') {
+ return DEFAULT_NAME;
+ }
+ return [...oneLine(value)].slice(0, NAME_MAX_LENGTH).join('').trim() || DEFAULT_NAME;
+}
+
+function waitingOf(value: unknown): WorkflowRunWaiting | null | undefined {
+ if (value === null) return null;
+ if (!isRecord(value) || !oneOf(WORKFLOW_WAITING_REASONS, value.reason)
+ || !oneOf(WORKFLOW_WAITING_ACTIONS, value.action)) {
+ return undefined;
+ }
+ if (value.gate_id !== null && !isWorkflowRunIdentifier(value.gate_id)) {
+ return undefined;
+ }
+ return { reason: value.reason, action: value.action, gate_id: value.gate_id };
+}
+
+function deliveryOf(value: unknown): WorkflowRunDelivery | undefined {
+ if (!isRecord(value) || !oneOf(WORKFLOW_DELIVERY_ROW_STATUSES, value.status)) {
+ return undefined;
+ }
+ const generation = countOrNull(value.generation);
+ const deliveredAt = timeOrNull(value.delivered_at);
+ const messageId = value.message_id;
+ if (generation === undefined || deliveredAt === undefined) {
+ return undefined;
+ }
+ if (messageId !== null && !(
+ isWorkflowRunIdentifier(messageId) && messageId.startsWith(WORKFLOW_DELIVERY_MESSAGE_PREFIX)
+ )) {
+ return undefined;
+ }
+ if (value.reason !== null && !oneOf(WORKFLOW_DELIVERY_REASONS, value.reason)) {
+ return undefined;
+ }
+ return {
+ status: value.status,
+ generation,
+ message_id: messageId,
+ delivered_at: deliveredAt,
+ reason: value.reason,
+ };
+}
+
+function actionsOf(value: unknown): WorkflowRunActions | undefined {
+ if (!isRecord(value)) return undefined;
+ const { cancel, retry, approve, open_run: openRun } = value;
+ if (typeof cancel !== 'boolean' || typeof retry !== 'boolean' || typeof approve !== 'boolean'
+ || typeof openRun !== 'boolean') {
+ return undefined;
+ }
+ return { cancel, retry, approve, open_run: openRun };
+}
+
+/** Everything but the identity, or null when any of it can't be read. */
+function statusOf(value: Record): Omit | null {
+ const { status, phase } = value;
+ if (!oneOf(WORKFLOW_RUN_ROW_STATUSES, status) || PHASE_FOR_STATUS[status] !== phase) {
+ return null;
+ }
+ const runtimeVersion = countOrNull(value.runtime_version);
+ const stepIndex = countOrNull(value.step_index);
+ const stepCount = countOrNull(value.step_count);
+ const elapsed = countOrNull(value.elapsed_seconds);
+ const stepLabel = textOrNull(value.step_label);
+ const startedAt = timeOrNull(value.started_at);
+ const completedAt = timeOrNull(value.completed_at);
+ const waiting = waitingOf(value.waiting);
+ const delivery = deliveryOf(value.delivery);
+ const error = textOrNull(value.error);
+ const actions = actionsOf(value.actions);
+ if (
+ runtimeVersion === undefined || stepIndex === undefined || stepCount === undefined
+ || elapsed === undefined || stepLabel === undefined || startedAt === undefined
+ || completedAt === undefined || waiting === undefined || delivery === undefined
+ || error === undefined || actions === undefined || typeof value.live !== 'boolean'
+ ) {
+ return null;
+ }
+ const errorCode = value.error_code;
+ if (errorCode !== null && !oneOf(WORKFLOW_FAILURE_CODES, errorCode)) {
+ return null;
+ }
+ const retryBlocked = value.retry_blocked;
+ if (retryBlocked !== null && !oneOf(WORKFLOW_RETRY_BLOCKED_CODES, retryBlocked)) {
+ return null;
+ }
+ // What the card has to say for each phase must be there to say.
+ if (phase === 'needs_you' && waiting === null) {
+ return null;
+ }
+ if (phase === 'failed' && (!error?.trim() || errorCode === null)) {
+ return null;
+ }
+ return {
+ status,
+ phase: PHASE_FOR_STATUS[status],
+ runtime_version: runtimeVersion,
+ step_index: stepIndex,
+ step_count: stepCount,
+ step_label: stepLabel,
+ started_at: startedAt,
+ completed_at: completedAt,
+ elapsed_seconds: elapsed,
+ waiting,
+ delivery,
+ error,
+ error_code: errorCode,
+ retry_blocked: retryBlocked,
+ actions,
+ live: value.live,
+ };
+}
+
+function rowOf(value: unknown): WorkflowRunRow | null {
+ if (!isRecord(value)) return null;
+ const { workflow_id: workflowId, run_id: runId, conversation_id: conversationId } = value;
+ const orchestrationRunId = value.orchestration_run_id;
+ const stepId = value.step_id;
+ if (
+ !isWorkflowRunIdentifier(workflowId) || !isWorkflowRunIdentifier(runId)
+ || !isWorkflowRunIdentifier(conversationId)
+ || (orchestrationRunId !== null && !isWorkflowRunIdentifier(orchestrationRunId))
+ || (stepId !== null && !isWorkflowRunIdentifier(stepId))
+ || value.workflow_scope !== 'personal'
+ ) {
+ return null;
+ }
+ const requestedAt = timeOrNull(value.requested_at);
+ const identity: WorkflowRunRowIdentity = {
+ workflow_id: workflowId,
+ workflow_scope: 'personal',
+ run_id: runId,
+ conversation_id: conversationId,
+ orchestration_run_id: orchestrationRunId,
+ step_id: stepId,
+ workflow_name: displayName(value.workflow_name),
+ requested_at: requestedAt ?? null,
+ };
+ const status = typeof value.workflow_name === 'string' && requestedAt !== undefined ? statusOf(value) : null;
+ return status ? { ...identity, kind: 'status', ...status } : { ...identity, kind: 'unavailable' };
+}
+
+/** Check a status response. Exported so a test can hold the checks against real responses. */
+export function parseWorkflowRunStatusResponse(value: unknown): WorkflowRunStatusResponse {
+ if (
+ !isRecord(value) || typeof value.available !== 'boolean' || !Array.isArray(value.runs)
+ || !isSecondsTime(value.checked_at) || typeof value.truncated !== 'boolean'
+ ) {
+ invalid();
+ }
+ const runs: WorkflowRunRow[] = [];
+ const seen = new Set();
+ for (const item of value.runs) {
+ const row = rowOf(item);
+ // Newest request first, so a repeated run keeps its first row.
+ if (row && !seen.has(row.run_id)) {
+ seen.add(row.run_id);
+ runs.push(row);
+ }
+ }
+ return { available: value.available, runs, checked_at: value.checked_at, truncated: value.truncated };
+}
+
+/**
+ * Read the status of the chat-started runs: one chat's (at most 20), or, with no conversation,
+ * every run still in flight, still being posted, or posted in the last ten minutes (at most 50).
+ */
+export async function fetchWorkflowRunStatus(
+ conversationId: string | null,
+ signal?: AbortSignal,
+): Promise {
+ const query = conversationId === null ? '' : `?${new URLSearchParams({ conversation_id: conversationId })}`;
+ return parseWorkflowRunStatusResponse(await api.get(`${WORKFLOW_RUN_STATUS_PATH}${query}`, signal));
+}
+
+const ACTIVE_STATUSES: readonly WorkflowRunRowStatus[] = ['queued', 'running', 'waiting'];
+const OPEN_DELIVERIES: readonly WorkflowDeliveryRowStatus[] = ['pending', 'delivering'];
+
+/**
+ * Whether a run is still going, or its result is still on its way to the chat. The tracker polls
+ * while any is; the running tag shows while one is. An unavailable row never counts.
+ */
+export function isWorkflowRunInFlight(row: WorkflowRunRow): boolean {
+ return row.kind === 'status'
+ && (ACTIVE_STATUSES.includes(row.status) || OPEN_DELIVERIES.includes(row.delivery.status));
+}
+
+/** Whether the run itself is still going, as opposed to only its result being posted. */
+export function isWorkflowRunActive(row: WorkflowRunRow): boolean {
+ return row.kind === 'status' && ACTIVE_STATUSES.includes(row.status);
+}
+
+/** The controls a row offers. Each comes from the server's `actions`, never from the status alone. */
+export interface WorkflowRunRowControls {
+ cancel: boolean;
+ retry: boolean;
+ /** Retry would be offered, but chats can't start workflows right now. */
+ retryTurnedOff: boolean;
+ approve: boolean;
+ reconnect: boolean;
+ openRun: true;
+}
+
+export function workflowRunRowControls(row: WorkflowRunRow, available: boolean): WorkflowRunRowControls {
+ if (row.kind !== 'status') {
+ return { cancel: false, retry: false, retryTurnedOff: false, approve: false, reconnect: false, openRun: true };
+ }
+ const retryAllowed = row.actions.retry && row.status === 'failed' && row.retry_blocked === null;
+ return {
+ cancel: row.actions.cancel && ACTIVE_STATUSES.includes(row.status),
+ retry: retryAllowed && available,
+ retryTurnedOff: retryAllowed && !available,
+ approve: row.actions.approve && row.status === 'waiting' && row.waiting?.action === 'approve'
+ && Boolean(row.waiting.gate_id),
+ reconnect: row.status === 'waiting' && row.waiting?.action === 'reconnect',
+ openRun: true,
+ };
+}
+
+const STATUS_LABELS: Record = {
+ queued: 'Queued',
+ running: 'Running',
+ waiting: 'Needs you',
+ completed: 'Completed',
+ completed_partial: 'Partly completed',
+ failed: 'Failed',
+ expired: 'Timed out',
+ cancelled: 'Cancelled',
+};
+
+export const WORKFLOW_RUN_STATUS_UNAVAILABLE = 'Status unavailable';
+
+export function workflowRunStatusLabel(row: WorkflowRunRow): string {
+ return row.kind === 'status' ? STATUS_LABELS[row.status] : WORKFLOW_RUN_STATUS_UNAVAILABLE;
+}
+
+const WAITING_TEXT: Record = {
+ approval: 'Waiting for your approval.',
+ microsoft_365_reconnect: 'Reconnect Microsoft 365 to continue.',
+ microsoft_365_approval: 'Waiting for a Microsoft 365 approval.',
+ output_review: 'Waiting for you to review its output.',
+ recovery: 'Recovering after an interruption.',
+ deadline_exceeded: 'Paused. Open the run to continue.',
+ paused: 'Paused. Open the run to continue.',
+};
+
+export function workflowWaitingText(reason: WorkflowWaitingReason): string {
+ return WAITING_TEXT[reason];
+}
+
+const RETRY_BLOCKED_TEXT: Record = {
+ retry_unavailable: 'Retry isn\'t available for this run right now.',
+ workflow_deleted: 'The workflow was deleted, so this run can\'t be retried.',
+ not_resumable: 'This run can\'t be retried.',
+ deadline_exceeded: 'This run reached its time limit, so it can\'t be retried.',
+ workflow_definition_changed: 'The workflow changed after this run started. Start a new run from Workflows.',
+ workflow_already_running: 'Another run of this workflow is in progress. Retry when it finishes.',
+};
+
+export function workflowRetryBlockedText(code: WorkflowRetryBlockedCode): string {
+ return RETRY_BLOCKED_TEXT[code];
+}
+
+export const WORKFLOW_RETRY_TURNED_OFF_TEXT =
+ 'Starting workflows from chat is turned off, so Retry isn\'t available here.';
+export const WORKFLOW_RUN_CANCELLED_TEXT = 'The run was cancelled.';
+export const WORKFLOW_RESULTS_POSTING_TEXT = 'Posting results…';
+export const WORKFLOW_RESULTS_POSTED_TEXT = 'Results posted below';
+export const WORKFLOW_RESULTS_POSTED_ELSEWHERE_TEXT = 'Results were posted to this chat.';
+export const WORKFLOW_RESULTS_IN_HISTORY_TEXT = 'The results are in the workflow\'s run history.';
+export const WORKFLOW_STATUS_READ_ERROR_TEXT = 'Couldn\'t check the run status right now. Try again.';
+export const WORKFLOW_STATUS_HALTED_TEXT = 'Live status isn\'t available right now.';
+
+/** "Step 2 of 5", once the server has counted the steps; empty until then. */
+export function workflowRunStepText(row: WorkflowRunStatusRow): string {
+ if (row.step_count === null || row.step_count <= 0 || row.step_index === null) {
+ return '';
+ }
+ return `Step ${Math.min(row.step_index + 1, row.step_count)} of ${row.step_count}`;
+}
+
+/** The step's own label, when the server sends one: one line, or empty. */
+export function workflowRunStepLabel(row: WorkflowRunStatusRow): string {
+ return row.step_label ? oneLine(row.step_label) : '';
+}
+
+/** "45 s", "3 min" or "1 h 5 min". */
+export function formatWorkflowElapsed(seconds: number | null): string {
+ if (seconds === null || !Number.isFinite(seconds) || seconds < 0) {
+ return '';
+ }
+ const whole = Math.floor(seconds);
+ if (whole < 60) {
+ return `${whole} s`;
+ }
+ if (whole < 3600) {
+ return `${Math.floor(whole / 60)} min`;
+ }
+ const hours = Math.floor(whole / 3600);
+ const minutes = Math.floor((whole % 3600) / 60);
+ return minutes > 0 ? `${hours} h ${minutes} min` : `${hours} h`;
+}
+
+/** The reader's local time of a server time, "9:07 AM", or empty for anything unreadable. */
+export function formatCheckedTime(checkedAt: string | null): string {
+ if (!checkedAt) return '';
+ const moment = new Date(checkedAt);
+ if (Number.isNaN(moment.getTime())) return '';
+ return moment.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
+}
diff --git a/application/v2_ui/src/lib/workflowRunTracker.ts b/application/v2_ui/src/lib/workflowRunTracker.ts
new file mode 100644
index 000000000..b6e195812
--- /dev/null
+++ b/application/v2_ui/src/lib/workflowRunTracker.ts
@@ -0,0 +1,591 @@
+// workflowRunTracker.ts
+// The one tracker a tab keeps for the saved workflow runs its user's chats started.
+//
+// A plan that starts a workflow ends straight away; the server posts the run's result back into the
+// chat later, marks the chat unread and adds one bell notice. This engine follows those runs through
+// the batched status route so the app can show them: the run card, the chat list's running tag and
+// the delivered-message footers read what it keeps, and nothing else polls.
+//
+// The engine is plain logic with every outside dependency passed in (the request, timers, the clock,
+// page visibility and the desktop-notification check), so its cadence and its baseline can be
+// tested without a browser. `useWorkflowRunTracker` owns the one instance a tab has.
+//
+// Cadence. While the tab is visible and a run is in flight it checks after 15 s, then 30 s, 1 min,
+// 2 min and every 5 min after that. A hidden tab is paused and checks as soon as it is shown again,
+// except that a reader with desktop notifications on keeps a check every 5 min while a run is in
+// flight. With nothing in flight it stops until something kicks it: a plan's answer, the run card
+// or Check now. Errors back off (30 s, 1 min, 2 min, then 5 min); 401 and 403, and a 400 on the
+// global read, halt it for the page session.
+//
+// Baseline. The first response in a page session that lists a chat's runs records every result
+// already posted there, silently. That is the first read of every chat's runs, or the chat's own
+// read when it comes back first. Only a posting seen later in the same page session is announced,
+// so a reload never announces a result twice, never marks a chat unread again and never shows a
+// second desktop notification.
+
+import {
+ isStatusConversationId,
+ isWorkflowRunInFlight,
+ WORKFLOW_DELIVERY_MESSAGE_PREFIX,
+ WORKFLOW_STATUS_READ_ERROR_TEXT,
+ type WorkflowRunRow,
+ type WorkflowRunStatusResponse,
+ type WorkflowRunStatusRow,
+} from './workflowRunStatus';
+
+/** Visible-tab delays while a run is in flight: 15 s easing to every 5 min. */
+export const WORKFLOW_RUN_POLL_DELAYS_MS: readonly number[] = [15_000, 30_000, 60_000, 120_000, 300_000];
+/** Delays after consecutive failed reads. */
+export const WORKFLOW_RUN_ERROR_DELAYS_MS: readonly number[] = [30_000, 60_000, 120_000, 300_000];
+/** A hidden tab's delay, only while desktop notifications are on and a run is in flight. */
+export const WORKFLOW_RUN_HIDDEN_DELAY_MS = 300_000;
+/** How long a chat's own read stands in for another one. */
+export const WORKFLOW_RUN_CONVERSATION_DEDUPE_MS = 10_000;
+
+export interface TrackedWorkflowRun {
+ row: WorkflowRunRow;
+ /** The `checked_at` of the response the row came from. */
+ checkedAt: string;
+ /**
+ * A complete read of every chat's runs no longer lists it although it was in flight: it has
+ * stopped, and how isn't known until its chat is read. A retired row is never in flight.
+ */
+ retired: boolean;
+}
+
+export interface WorkflowRunConversationRead {
+ /** The newest `checked_at` of this chat's own reads. */
+ checkedAt: string | null;
+ reading: boolean;
+ /** Why the last read of this chat failed, as fixed text; cleared by the next good read. */
+ error: string | null;
+}
+
+export interface WorkflowRunTrackerSnapshot {
+ running: boolean;
+ /** Stopped for the page session after the server refused the reads. */
+ halted: boolean;
+ /** Whether chats can start workflows now, from the newest response; null before the first. */
+ available: boolean | null;
+ runs: Readonly>;
+ /** The newest `checked_at` of a complete read of every chat's runs. */
+ globalCheckedAt: string | null;
+ /** The last read of every chat's runs failed. */
+ globalError: boolean;
+ conversations: Readonly>;
+}
+
+export interface WorkflowRunTrackerDeps {
+ /** One read of the status route: one chat's runs, or with null every chat's. */
+ fetchStatus: (conversationId: string | null, signal: AbortSignal) => Promise;
+ setTimer: (callback: () => void, delayMs: number) => unknown;
+ clearTimer: (handle: unknown) => void;
+ /** Milliseconds, for the conversation-read dedupe only. */
+ now: () => number;
+ isVisible: () => boolean;
+ subscribeVisibility: (listener: () => void) => () => void;
+ /** Desktop notifications are on and permitted, so a hidden tab keeps checking. */
+ desktopNotificationsOn: () => boolean;
+ onState?: (snapshot: WorkflowRunTrackerSnapshot) => void;
+ /** A run's result was posted to its chat during this page session. Once per posting. */
+ onDelivered?: (row: WorkflowRunStatusRow) => void;
+ /** A run seen in flight can no longer post to its chat (undeliverable or expired). */
+ onClosed?: (row: WorkflowRunStatusRow) => void;
+ /** A run seen in flight dropped out of a complete read. */
+ onRetired?: (row: WorkflowRunRow) => void;
+ onHalted?: () => void;
+}
+
+export interface WorkflowRunTracker {
+ /** Start tracking and check straight away. Starting a running tracker does nothing. */
+ start: () => void;
+ /** Stop every timer and request. What was already posted stays recorded. */
+ stop: () => void;
+ /**
+ * Something may have started a run: check again soon, from the start of the ladder.
+ * `immediate` checks now, as a plan's answer does.
+ */
+ kick: (options?: { immediate?: boolean }) => void;
+ /**
+ * Read one chat's runs. Deduped while a read is in flight and for 10 s after a good one,
+ * unless forced. Resolves whether the read succeeded.
+ */
+ requestConversationRuns: (conversationId: string, options?: { force?: boolean }) => Promise;
+ getSnapshot: () => WorkflowRunTrackerSnapshot;
+}
+
+const EMPTY_CONVERSATION: WorkflowRunConversationRead = { checkedAt: null, reading: false, error: null };
+
+function errorStatus(error: unknown): number | null {
+ if (error && typeof error === 'object' && typeof (error as { status?: unknown }).status === 'number') {
+ return (error as { status: number }).status;
+ }
+ return null;
+}
+
+/** Every posting of a run has its own key: a retried run posts again under a new generation. */
+function deliveryKey(row: WorkflowRunStatusRow): string {
+ return `${row.run_id}:${row.delivery.generation ?? 'none'}`;
+}
+
+function safely(callback: () => void): void {
+ try {
+ callback();
+ } catch {
+ /* A reader's failure never stops the tracker. */
+ }
+}
+
+export function isTrackedRunInFlight(tracked: TrackedWorkflowRun | undefined): boolean {
+ return tracked !== undefined && !tracked.retired && isWorkflowRunInFlight(tracked.row);
+}
+
+/**
+ * Whether a tab should keep a tracker at all: the user can use saved workflows and chats can start
+ * them. Both come from the bootstrap payload's role-aware feature flags. Results posting back to
+ * chat is not required, because a run's progress shows on its card either way.
+ */
+export function workflowRunTrackerShouldRun(features: Readonly> | null | undefined): boolean {
+ return features?.allow_user_workflows === true && features?.enable_chat_orchestration_workflow_runs === true;
+}
+
+export function createWorkflowRunTracker(deps: WorkflowRunTrackerDeps): WorkflowRunTracker {
+ let running = false;
+ let halted = false;
+ let available: boolean | null = null;
+ let availableAt = '';
+ const runs = new Map();
+ let globalCheckedAt: string | null = null;
+ let globalError = false;
+ const conversations = new Map();
+ const conversationReads = new Map }>();
+ const conversationReadAt = new Map();
+
+ // What this page session has already seen. Kept across stop and start, so a tracker that
+ // restarts (a flag flipped back on, or React mounting twice) announces nothing again.
+ // A chat's baseline is the first good read that listed its runs: every chat's, or that chat's
+ // own when it came back first. A chat's own read says nothing about the other chats, so it
+ // never sets their baseline.
+ let globalBaseline: string | null = null;
+ const conversationBaselines = new Map();
+ const deliveredKeys = new Set();
+ const seenUndelivered = new Set();
+ const seenInFlight = new Set();
+ const closedKeys = new Set();
+
+ let timer: unknown = null;
+ let timerDueAt = 0;
+ let globalRead: AbortController | null = null;
+ let ladderIndex = 0;
+ let failures = 0;
+ let kickPending = false;
+ let unsubscribeVisibility: (() => void) | null = null;
+ let snapshot = buildSnapshot();
+
+ function buildSnapshot(): WorkflowRunTrackerSnapshot {
+ return {
+ running,
+ halted,
+ available,
+ runs: Object.fromEntries(runs),
+ globalCheckedAt,
+ globalError,
+ conversations: Object.fromEntries(conversations),
+ };
+ }
+
+ function emit(): void {
+ snapshot = buildSnapshot();
+ safely(() => deps.onState?.(snapshot));
+ }
+
+ function somethingInFlight(): boolean {
+ for (const tracked of runs.values()) {
+ if (isTrackedRunInFlight(tracked)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ function clearTimer(): void {
+ if (timer !== null) {
+ deps.clearTimer(timer);
+ timer = null;
+ }
+ }
+
+ function setTimer(delay: number): void {
+ clearTimer();
+ timerDueAt = deps.now() + delay;
+ timer = deps.setTimer(() => {
+ timer = null;
+ onTimer();
+ }, delay);
+ }
+
+ /** When the next check is due under the current rules, or null to wait for a kick or for the tab. */
+ function nextDelay(): number | null {
+ const wanted = somethingInFlight() || kickPending;
+ if (!deps.isVisible()) {
+ // Paused while hidden, except for a reader who would be told by a desktop notification.
+ return wanted && deps.desktopNotificationsOn() ? WORKFLOW_RUN_HIDDEN_DELAY_MS : null;
+ }
+ if (failures > 0) {
+ return WORKFLOW_RUN_ERROR_DELAYS_MS[Math.min(failures - 1, WORKFLOW_RUN_ERROR_DELAYS_MS.length - 1)];
+ }
+ return wanted ? WORKFLOW_RUN_POLL_DELAYS_MS[ladderIndex] : null;
+ }
+
+ /** Replace the pending check with the one the rules call for now. */
+ function schedule(): void {
+ clearTimer();
+ if (!running || halted || globalRead) {
+ return;
+ }
+ const delay = nextDelay();
+ if (delay !== null) {
+ setTimer(delay);
+ }
+ }
+
+ /** Bring the next check forward when the rules now want it sooner; never push it back. */
+ function scheduleSooner(): void {
+ if (!running || halted || globalRead) {
+ return;
+ }
+ const delay = nextDelay();
+ if (delay !== null && (timer === null || deps.now() + delay < timerDueAt)) {
+ setTimer(delay);
+ }
+ }
+
+ function onTimer(): void {
+ if (!running || halted) {
+ return;
+ }
+ if (failures === 0) {
+ ladderIndex = Math.min(ladderIndex + 1, WORKFLOW_RUN_POLL_DELAYS_MS.length - 1);
+ }
+ void checkAll();
+ }
+
+ function onVisibilityChange(): void {
+ if (!running || halted) {
+ return;
+ }
+ if (!deps.isVisible()) {
+ schedule();
+ return;
+ }
+ if (globalRead) {
+ return;
+ }
+ if (somethingInFlight() || failures > 0 || kickPending) {
+ ladderIndex = 0;
+ void checkAll();
+ } else {
+ schedule();
+ }
+ }
+
+ function abortAll(): void {
+ globalRead?.abort();
+ globalRead = null;
+ for (const [conversationId, read] of conversationReads) {
+ read.controller.abort();
+ const state = conversations.get(conversationId);
+ if (state?.reading) {
+ conversations.set(conversationId, { ...state, reading: false });
+ }
+ }
+ conversationReads.clear();
+ }
+
+ function halt(): void {
+ halted = true;
+ clearTimer();
+ abortAll();
+ emit();
+ safely(() => deps.onHalted?.());
+ }
+
+ function baselineFor(conversationId: string): string | null {
+ return conversationBaselines.get(conversationId) ?? globalBaseline;
+ }
+
+ function isAnnounceable(row: WorkflowRunStatusRow): boolean {
+ const { generation, message_id: messageId, delivered_at: deliveredAt } = row.delivery;
+ if (generation === null || !messageId || !messageId.startsWith(WORKFLOW_DELIVERY_MESSAGE_PREFIX)) {
+ return false;
+ }
+ if (seenUndelivered.has(row.run_id)) {
+ return true;
+ }
+ // Both are server times in whole seconds, so a posting in the same second as the
+ // chat's first read, but missing from it, still counts as new.
+ const baseline = baselineFor(row.conversation_id);
+ return baseline !== null && deliveredAt !== null && deliveredAt >= baseline;
+ }
+
+ function noteTransitions(
+ row: WorkflowRunRow,
+ first: boolean,
+ delivered: WorkflowRunStatusRow[],
+ closed: WorkflowRunStatusRow[],
+ ): void {
+ if (row.kind !== 'status') {
+ return;
+ }
+ const { delivery } = row;
+ if (delivery.status === 'delivered') {
+ const key = deliveryKey(row);
+ if (deliveredKeys.has(key)) {
+ return;
+ }
+ deliveredKeys.add(key);
+ if (!first && isAnnounceable(row)) {
+ delivered.push(row);
+ }
+ return;
+ }
+ if (
+ !first && seenInFlight.has(row.run_id)
+ && (delivery.status === 'undeliverable' || delivery.status === 'expired')
+ ) {
+ const key = `${deliveryKey(row)}:${delivery.status}`;
+ if (!closedKeys.has(key)) {
+ closedKeys.add(key);
+ closed.push(row);
+ }
+ }
+ seenUndelivered.add(row.run_id);
+ if (isWorkflowRunInFlight(row)) {
+ seenInFlight.add(row.run_id);
+ }
+ }
+
+ function apply(response: WorkflowRunStatusResponse, conversationId: string | null): void {
+ const delivered: WorkflowRunStatusRow[] = [];
+ const closed: WorkflowRunStatusRow[] = [];
+ const retired: WorkflowRunRow[] = [];
+ const firstGlobal = conversationId === null && globalBaseline === null;
+ const firstForConversation = conversationId !== null && baselineFor(conversationId) === null;
+ if (response.checked_at >= availableAt) {
+ available = response.available;
+ availableAt = response.checked_at;
+ }
+ for (const row of response.runs) {
+ // A chat's own read speaks for that chat only.
+ if (conversationId !== null && row.conversation_id !== conversationId) {
+ continue;
+ }
+ const tracked = runs.get(row.run_id);
+ // A newer read already said more about this run.
+ if (tracked && tracked.checkedAt > response.checked_at) {
+ continue;
+ }
+ noteTransitions(row, baselineFor(row.conversation_id) === null, delivered, closed);
+ runs.set(row.run_id, { row, checkedAt: response.checked_at, retired: false });
+ }
+ if (firstGlobal) {
+ globalBaseline = response.checked_at;
+ }
+ if (firstForConversation && conversationId !== null) {
+ conversationBaselines.set(conversationId, response.checked_at);
+ }
+ // Only a complete read of every chat's runs can say a run has dropped out. A chat's own
+ // read is capped, and a truncated read leaves rows out.
+ if (conversationId === null && !response.truncated) {
+ const listed = new Set(response.runs.map((row) => row.run_id));
+ for (const [runId, tracked] of runs) {
+ if (
+ listed.has(runId) || !isTrackedRunInFlight(tracked)
+ || tracked.checkedAt >= response.checked_at
+ ) {
+ continue;
+ }
+ runs.set(runId, { ...tracked, retired: true });
+ retired.push(tracked.row);
+ }
+ if (globalCheckedAt === null || response.checked_at >= globalCheckedAt) {
+ globalCheckedAt = response.checked_at;
+ }
+ }
+ emit();
+ for (const row of delivered) {
+ safely(() => deps.onDelivered?.(row));
+ }
+ for (const row of closed) {
+ safely(() => deps.onClosed?.(row));
+ }
+ for (const row of retired) {
+ safely(() => deps.onRetired?.(row));
+ }
+ }
+
+ async function checkAll(): Promise {
+ if (!running || halted || globalRead) {
+ return;
+ }
+ clearTimer();
+ const controller = new AbortController();
+ globalRead = controller;
+ kickPending = false;
+ let response: WorkflowRunStatusResponse;
+ try {
+ response = await deps.fetchStatus(null, controller.signal);
+ } catch (error) {
+ if (globalRead !== controller) {
+ return;
+ }
+ globalRead = null;
+ const status = errorStatus(error);
+ // The global read sends nothing a 400 could be about, so a 400 means the route
+ // refuses this reader, as 401 and 403 do.
+ if (status === 401 || status === 403 || status === 400) {
+ halt();
+ return;
+ }
+ failures += 1;
+ globalError = true;
+ emit();
+ schedule();
+ return;
+ }
+ if (globalRead !== controller) {
+ return;
+ }
+ globalRead = null;
+ failures = 0;
+ globalError = false;
+ apply(response, null);
+ schedule();
+ }
+
+ function setConversation(conversationId: string, change: Partial): void {
+ conversations.set(conversationId, { ...(conversations.get(conversationId) ?? EMPTY_CONVERSATION), ...change });
+ }
+
+ async function readConversation(conversationId: string, controller: AbortController): Promise {
+ const current = () => conversationReads.get(conversationId)?.controller === controller;
+ setConversation(conversationId, { reading: true });
+ emit();
+ let response: WorkflowRunStatusResponse;
+ try {
+ response = await deps.fetchStatus(conversationId, controller.signal);
+ } catch (error) {
+ if (!current()) {
+ return false;
+ }
+ conversationReads.delete(conversationId);
+ setConversation(conversationId, { reading: false, error: WORKFLOW_STATUS_READ_ERROR_TEXT });
+ const status = errorStatus(error);
+ if (status === 401 || status === 403) {
+ halt();
+ return false;
+ }
+ emit();
+ return false;
+ }
+ if (!current()) {
+ return false;
+ }
+ conversationReads.delete(conversationId);
+ conversationReadAt.set(conversationId, deps.now());
+ const previous = conversations.get(conversationId)?.checkedAt ?? null;
+ setConversation(conversationId, {
+ reading: false,
+ error: null,
+ checkedAt: previous !== null && previous > response.checked_at ? previous : response.checked_at,
+ });
+ apply(response, conversationId);
+ if (response.runs.some((row) => row.conversation_id === conversationId && isWorkflowRunInFlight(row))) {
+ ladderIndex = 0;
+ scheduleSooner();
+ }
+ return true;
+ }
+
+ return {
+ start(): void {
+ if (running) {
+ return;
+ }
+ running = true;
+ halted = false;
+ ladderIndex = 0;
+ failures = 0;
+ kickPending = true;
+ unsubscribeVisibility = deps.subscribeVisibility(onVisibilityChange);
+ emit();
+ if (deps.isVisible() || deps.desktopNotificationsOn()) {
+ void checkAll();
+ }
+ },
+
+ stop(): void {
+ if (!running) {
+ return;
+ }
+ running = false;
+ clearTimer();
+ abortAll();
+ unsubscribeVisibility?.();
+ unsubscribeVisibility = null;
+ kickPending = false;
+ failures = 0;
+ globalError = false;
+ emit();
+ },
+
+ kick(options = {}): void {
+ if (!running || halted) {
+ return;
+ }
+ ladderIndex = 0;
+ kickPending = true;
+ if (globalRead) {
+ // The read under way may predate the new run; its completion schedules another.
+ return;
+ }
+ if (options.immediate && (deps.isVisible() || deps.desktopNotificationsOn())) {
+ void checkAll();
+ return;
+ }
+ if (failures > 0 && timer !== null) {
+ // Keep backing off.
+ return;
+ }
+ scheduleSooner();
+ },
+
+ requestConversationRuns(conversationId, options = {}): Promise {
+ if (!running || halted || !isStatusConversationId(conversationId)) {
+ return Promise.resolve(false);
+ }
+ const pending = conversationReads.get(conversationId);
+ if (pending && !options.force) {
+ return pending.promise;
+ }
+ if (!options.force) {
+ const readAt = conversationReadAt.get(conversationId);
+ if (readAt !== undefined && deps.now() - readAt < WORKFLOW_RUN_CONVERSATION_DEDUPE_MS) {
+ return Promise.resolve(true);
+ }
+ }
+ // A forced read replaces one under way, which may predate what forced it.
+ pending?.controller.abort();
+ const controller = new AbortController();
+ // Registered before the read starts, so even a read that fails at once is the current one.
+ const read = { controller, promise: Promise.resolve(false) };
+ conversationReads.set(conversationId, read);
+ read.promise = readConversation(conversationId, controller);
+ return read.promise;
+ },
+
+ getSnapshot(): WorkflowRunTrackerSnapshot {
+ return snapshot;
+ },
+ };
+}
diff --git a/application/v2_ui/src/stores/chatStore.ts b/application/v2_ui/src/stores/chatStore.ts
index 9cc14c587..582b9b178 100644
--- a/application/v2_ui/src/stores/chatStore.ts
+++ b/application/v2_ui/src/stores/chatStore.ts
@@ -134,7 +134,7 @@ import {
retryOrchestrationPlanning,
} from '../lib/orchestrationController';
import { foundryAuthUrl } from '../lib/foundryAuth';
-import { announceCompletedReply } from '../lib/replyEvents';
+import { announceCompletedReply, type CompletedReply } from '../lib/replyEvents';
import { getAppNavigator, subscribeRouteChanges, type AppRoute } from '../lib/appNavigation';
import { readConversationParam } from '../lib/conversationUrl';
import { refreshNotificationCount } from './notificationStore';
@@ -488,7 +488,15 @@ interface ChatState {
setDrawerMode: (mode: DrawerMode) => void;
loadMetadata: (conversationId: string) => Promise;
- reloadMessages: () => Promise;
+ /**
+ * Re-read the open chat's messages and show them.
+ *
+ * `onlyIfUnchanged` is for a re-read the reader didn't ask for, such as a workflow result
+ * landing: it is dropped, resolving 'superseded', if a reply started or the messages changed
+ * while it was out, so it can't replace a question sent in the meantime. Every other re-read
+ * resolves 'done', including one that showed nothing because the chat changed or it failed.
+ */
+ reloadMessages: (options?: { onlyIfUnchanged?: boolean }) => Promise<'done' | 'superseded'>;
removeMessage: (messageId: string, deleteThread?: boolean) => Promise;
retryMessage: (messageId: string, options?: ComposerOptions) => Promise;
editMessage: (messageId: string, content: string) => Promise;
@@ -1143,23 +1151,19 @@ function deferReplyRead(conversationId: string): void {
/**
* Act on a finished reply for the reader: announce it to the desktop notifier, whether or
* not it is on screen, and settle the unread marker the server gave it.
+ *
+ * `current` is whether the reply landed in the open conversation, and `serverMarksUnread`
+ * whether the server marked that conversation unread for it. Exported for replies that
+ * arrive without a stream: a saved workflow's results, which the server posts back to the
+ * chat that started the run, are settled exactly as a streamed reply is.
*/
-function settleFinishedReply(
- conversationId: string,
- kind: ConversationKind,
- event: ChatStreamEvent,
- current: boolean,
- getState: () => ChatState,
+export function settleCompletedReply(
+ reply: CompletedReply,
+ { current, serverMarksUnread }: { current: boolean; serverMarksUnread: boolean },
): void {
- const listed = getState().conversations.find((item) => item.id === conversationId);
- announceCompletedReply({
- conversationId,
- messageId: typeof event.message_id === 'string' && event.message_id ? event.message_id : null,
- conversationTitle: event.conversation_title || listed?.title || null,
- blocked: event.blocked === true || event.role === 'safety',
- source: 'chat',
- });
- if (!serverMarksReplyUnread(kind, event)) {
+ const { conversationId } = reply;
+ announceCompletedReply(reply);
+ if (!serverMarksUnread) {
return;
}
if (current && replyIsWatched()) {
@@ -1178,6 +1182,27 @@ function settleFinishedReply(
void refreshNotificationCount('action');
}
+/** Settle a reply that finished streaming. */
+function settleFinishedReply(
+ conversationId: string,
+ kind: ConversationKind,
+ event: ChatStreamEvent,
+ current: boolean,
+ getState: () => ChatState,
+): void {
+ const listed = getState().conversations.find((item) => item.id === conversationId);
+ settleCompletedReply(
+ {
+ conversationId,
+ messageId: typeof event.message_id === 'string' && event.message_id ? event.message_id : null,
+ conversationTitle: event.conversation_title || listed?.title || null,
+ blocked: event.blocked === true || event.role === 'safety',
+ source: 'chat',
+ },
+ { current, serverMarksUnread: serverMarksReplyUnread(kind, event) },
+ );
+}
+
/**
* Build the event handlers that fold a stream into the store.
*
@@ -3546,11 +3571,12 @@ export const useChatStore = create((set, get) => ({
}
},
- reloadMessages: async () => {
+ reloadMessages: async (options) => {
const conversationId = get().activeConversationId;
const analysisRevision = get().analysisContextRevision;
+ const shownBefore = get().messages;
if (!conversationId) {
- return;
+ return 'done';
}
try {
const { messages } =
@@ -3558,7 +3584,11 @@ export const useChatStore = create((set, get) => ({
? await fetchCollaborationMessages(conversationId)
: await fetchMessages(conversationId);
if (get().activeConversationId !== conversationId) {
- return;
+ return 'done';
+ }
+ // Checked before anything is written, so a dropped re-read changes nothing at all.
+ if (options?.onlyIfUnchanged && (get().streaming || get().messages !== shownBefore)) {
+ return 'superseded';
}
set({ messages: messages ?? [] });
const selected = get().analysisResultContext;
@@ -3591,6 +3621,7 @@ export const useChatStore = create((set, get) => ({
error instanceof Error ? error.message : 'Failed to reload messages.',
});
}
+ return 'done';
},
removeMessage: async (messageId, deleteThread = false) => {
diff --git a/application/v2_ui/src/stores/workflowRunTrackerStore.ts b/application/v2_ui/src/stores/workflowRunTrackerStore.ts
new file mode 100644
index 000000000..e1fcb7d2c
--- /dev/null
+++ b/application/v2_ui/src/stores/workflowRunTrackerStore.ts
@@ -0,0 +1,178 @@
+// workflowRunTrackerStore.ts
+// What the tab's one workflow run tracker knows, for the components that show it.
+//
+// The engine in lib/workflowRunTracker.ts owns the requests and the state; this store mirrors its
+// snapshot so components re-render when it changes. Nothing here sends a request. The run card and
+// the delivered-message footers ask for a chat's runs through useWorkflowRunTracker, and the chat
+// list's running tag reads only what is already here.
+//
+// The selectors are plain functions of a snapshot, so they can be tested without React. Components
+// select the snapshot itself and derive arrays from it with `useMemo`, so a selector that builds a
+// new array never makes the store hook re-render on its own.
+
+import { create } from 'zustand';
+import {
+ isTrackedRunInFlight,
+ type TrackedWorkflowRun,
+ type WorkflowRunConversationRead,
+ type WorkflowRunTrackerSnapshot,
+} from '../lib/workflowRunTracker';
+
+export const EMPTY_WORKFLOW_RUN_TRACKER_SNAPSHOT: WorkflowRunTrackerSnapshot = Object.freeze({
+ running: false,
+ halted: false,
+ available: null,
+ runs: Object.freeze({}),
+ globalCheckedAt: null,
+ globalError: false,
+ conversations: Object.freeze({}),
+});
+
+const NO_CONVERSATION_READ: WorkflowRunConversationRead = Object.freeze({ checkedAt: null, reading: false, error: null });
+
+interface WorkflowRunTrackerState {
+ snapshot: WorkflowRunTrackerSnapshot;
+ /** Replace the mirror with the engine's newest snapshot. */
+ publish: (snapshot: WorkflowRunTrackerSnapshot) => void;
+ /** Forget everything, for tests. */
+ reset: () => void;
+}
+
+export const useWorkflowRunTrackerStore = create((set) => ({
+ snapshot: EMPTY_WORKFLOW_RUN_TRACKER_SNAPSHOT,
+ publish: (snapshot) => set({ snapshot }),
+ reset: () => set({ snapshot: EMPTY_WORKFLOW_RUN_TRACKER_SNAPSHOT }),
+}));
+
+function byRequestedAt(left: TrackedWorkflowRun, right: TrackedWorkflowRun): number {
+ const a = left.row.requested_at ?? '';
+ const b = right.row.requested_at ?? '';
+ if (a !== b) {
+ return a < b ? -1 : 1;
+ }
+ return left.row.run_id < right.row.run_id ? -1 : left.row.run_id > right.row.run_id ? 1 : 0;
+}
+
+/** A chat's runs that are still going or still being posted, oldest request first. */
+export function workflowRunsInFlight(
+ snapshot: WorkflowRunTrackerSnapshot,
+ conversationId: string | null | undefined,
+): TrackedWorkflowRun[] {
+ if (!conversationId) {
+ return [];
+ }
+ return Object.values(snapshot.runs)
+ .filter((tracked) => tracked.row.conversation_id === conversationId && isTrackedRunInFlight(tracked))
+ .sort(byRequestedAt);
+}
+
+/**
+ * The chat list's running tag: "Running Weekly digest", "Running 2 workflows", or empty while
+ * nothing the chat started is in flight. The name is user-authored and only ever rendered as text.
+ */
+export function workflowRunningLabel(
+ snapshot: WorkflowRunTrackerSnapshot,
+ conversationId: string | null | undefined,
+): string {
+ const runs = workflowRunsInFlight(snapshot, conversationId);
+ if (runs.length === 0) {
+ return '';
+ }
+ return runs.length === 1 ? `Running ${runs[0].row.workflow_name}` : `Running ${runs.length} workflows`;
+}
+
+/**
+ * The chat list tag's label: the running label while the tracker is reading, and empty once it has
+ * stopped or halted, when what it last knew may no longer be true.
+ */
+export function workflowRunningTagLabel(
+ snapshot: WorkflowRunTrackerSnapshot,
+ conversationId: string | null | undefined,
+): string {
+ return snapshot.running && !snapshot.halted ? workflowRunningLabel(snapshot, conversationId) : '';
+}
+
+/** The runs one plan answer started, oldest request first. */
+export function workflowRunsForAnswer(
+ snapshot: WorkflowRunTrackerSnapshot,
+ conversationId: string | null | undefined,
+ orchestrationRunId: string | null | undefined,
+): TrackedWorkflowRun[] {
+ if (!conversationId || !orchestrationRunId) {
+ return [];
+ }
+ return Object.values(snapshot.runs)
+ .filter((tracked) =>
+ tracked.row.conversation_id === conversationId && tracked.row.orchestration_run_id === orchestrationRunId)
+ .sort(byRequestedAt);
+}
+
+/** The run one step of a plan answer started, as the plan's own run list names it. */
+export interface WorkflowRunAnswerStep {
+ stepId: string;
+ workflowId: string;
+ runId: string;
+}
+
+/**
+ * The tracker's row for the run one step of a plan answer started: the same run, in the same chat,
+ * started by the same plan run and step, of the same workflow. Undefined when the tracker hasn't
+ * read it or anything disagrees, so the card keeps the step's static link instead of guessing.
+ */
+export function workflowRunForAnswerStep(
+ snapshot: WorkflowRunTrackerSnapshot,
+ conversationId: string | null | undefined,
+ orchestrationRunId: string | null | undefined,
+ step: WorkflowRunAnswerStep,
+): TrackedWorkflowRun | undefined {
+ if (!conversationId || !orchestrationRunId) {
+ return undefined;
+ }
+ const tracked = snapshot.runs[step.runId];
+ if (
+ !tracked
+ || tracked.row.run_id !== step.runId
+ || tracked.row.conversation_id !== conversationId
+ || tracked.row.orchestration_run_id !== orchestrationRunId
+ || tracked.row.step_id !== step.stepId
+ || tracked.row.workflow_id !== step.workflowId
+ ) {
+ return undefined;
+ }
+ return tracked;
+}
+
+/** What the tracker last knew about one run, or undefined. */
+export function trackedWorkflowRun(
+ snapshot: WorkflowRunTrackerSnapshot,
+ runId: string | null | undefined,
+): TrackedWorkflowRun | undefined {
+ return runId ? snapshot.runs[runId] : undefined;
+}
+
+/** The state of a chat's own reads. */
+export function workflowRunConversationRead(
+ snapshot: WorkflowRunTrackerSnapshot,
+ conversationId: string | null | undefined,
+): WorkflowRunConversationRead {
+ return (conversationId && snapshot.conversations[conversationId]) || NO_CONVERSATION_READ;
+}
+
+/**
+ * When a chat's runs were last read: the newer of its own read and a complete read of every chat's
+ * runs, which lists each run still in flight. Null until either has succeeded.
+ */
+export function workflowRunsCheckedAt(
+ snapshot: WorkflowRunTrackerSnapshot,
+ conversationId: string | null | undefined,
+): string | null {
+ const own = workflowRunConversationRead(snapshot, conversationId).checkedAt;
+ const global = snapshot.globalCheckedAt;
+ if (own === null) {
+ return global;
+ }
+ if (global === null) {
+ return own;
+ }
+ return own > global ? own : global;
+}
diff --git a/docs/explanation/features/CHAT_ORCHESTRATION_WORKFLOW_RUNS.md b/docs/explanation/features/CHAT_ORCHESTRATION_WORKFLOW_RUNS.md
index eadcc1322..df63a6d34 100644
--- a/docs/explanation/features/CHAT_ORCHESTRATION_WORKFLOW_RUNS.md
+++ b/docs/explanation/features/CHAT_ORCHESTRATION_WORKFLOW_RUNS.md
@@ -477,6 +477,13 @@ encoded as query values. It doesn't
poll; the status is as of the read, and **Try again** reloads after a failed
read. A 404 renders nothing.
+Since **0.261.251**, while `allow_user_workflows` and
+`enable_chat_orchestration_workflow_runs` are both on, Phase 6b-2's
+`WorkflowRunCard` shows the same runs from the same read, with their live status
+and **Check now**, **Cancel run** and **Retry**. These links are what shows when
+either setting is off. See
+[V2 experience (6b-2)](CHAT_WORKFLOW_RESULT_DELIVERY.md#v2-experience-6b-2).
+
| State | Label |
| --- | --- |
| `queued` | Queued |
@@ -591,7 +598,9 @@ See [Orchestration settings](../../admin/orchestration.md).
4. Select **Approve**. The answer lists each workflow under **Saved workflows:**
as started, already started or not started with the reason.
5. Under the answer, **Started workflows** shows each run's status. **Open run**
- opens the workflow in Workflows with its run history open at that run.
+ opens the workflow in Workflows with its run history open at that run. Since
+ **0.261.251** the status stays current while the run is in flight; see
+ [V2 experience (6b-2)](CHAT_WORKFLOW_RESULT_DELIVERY.md#v2-experience-6b-2).
The user guide is
[Trigger a workflow](../../guides/trigger-a-workflow.md#run-a-workflow-from-chat),
@@ -625,15 +634,21 @@ Ranking sorts the
same bounded list proposals already read. A run step makes point reads of the
conversation, the workflow and the run, and at most one queue call. The link
route makes two point reads per started workflow, and none when the links are
-unavailable. The links don't poll.
+unavailable. The links don't poll. Live status since **0.261.251** comes from
+one tracker per browser tab and Phase 6b-1's batched status route, never a
+request per run.
### Known limitations
- Only personal workflows, from a private conversation, with durable execution
on. Group workflows from chat are Phase 8 (#1550).
- The plan never waits for a run or reads its results. Reading results into the
- conversation and posting them back are later phases (6a and 6b).
-- A link's status is as of when the message loaded. Reload to read it again.
+ conversation is Phase 6a
+ ([Workflow results in chat](CHAT_WORKFLOW_RESULTS_FOLLOW_UP.md)), and posting
+ them back is Phase 6b
+ ([Workflow result delivery to chat](CHAT_WORKFLOW_RESULT_DELIVERY.md)).
+- With either setting for live status off, a link's status is as of when the
+ message loaded. Reload to read it again.
- A Microsoft 365 wait is finished in Workflows, not in the chat.
- There's no "Create & run now" for a workflow a plan proposes.
- A background continuation never starts a new run. It links a run that already
diff --git a/docs/explanation/features/CHAT_WORKFLOW_RESULTS_FOLLOW_UP.md b/docs/explanation/features/CHAT_WORKFLOW_RESULTS_FOLLOW_UP.md
index f5e6c3772..2356060e3 100644
--- a/docs/explanation/features/CHAT_WORKFLOW_RESULTS_FOLLOW_UP.md
+++ b/docs/explanation/features/CHAT_WORKFLOW_RESULTS_FOLLOW_UP.md
@@ -735,7 +735,8 @@ the controls are listed in
- Group workflows (Phase 8).
- Re-checking workflow result contexts when a generated file is published.
- Phase 6b's delivery, run card, recurring-workflow card and chat-list
- indicator, and `v2WorkflowRunPath`.
-- Phase 6b-1 now has its server-side delivery contract in
- [Workflow result delivery to chat](CHAT_WORKFLOW_RESULT_DELIVERY.md); the V2
- run card and chat-list indicator still follow in later work.
+ indicator, and `v2WorkflowRunPath` shipped in 6b-1 (**0.261.227**) and 6b-2
+ (**0.261.251**). See
+ [Workflow result delivery to chat](CHAT_WORKFLOW_RESULT_DELIVERY.md), and
+ [V2 experience (6b-2)](CHAT_WORKFLOW_RESULT_DELIVERY.md#v2-experience-6b-2)
+ for **Follow up** on a posted result.
diff --git a/docs/explanation/features/CHAT_WORKFLOW_RESULT_DELIVERY.md b/docs/explanation/features/CHAT_WORKFLOW_RESULT_DELIVERY.md
index 1a8b323af..de769bf8d 100644
--- a/docs/explanation/features/CHAT_WORKFLOW_RESULT_DELIVERY.md
+++ b/docs/explanation/features/CHAT_WORKFLOW_RESULT_DELIVERY.md
@@ -2,6 +2,8 @@
Implemented in version: **0.261.227**.
+V2 experience (6b-2) implemented in version: **0.261.251**.
+
Application version tracking: `application\single_app\config.py`.
Related issue: #1546 (part 6b-1), part of #1543. Builds on
@@ -20,9 +22,9 @@ Workflows later to see what the run produced. With **Use Workflow Results In
Chat** on, the server posts that run's terminal outcome back into the same
private chat that asked for it, even if the run finishes hours later.
-The delivery is server-side. It ships no V2 run-card UI in this pull request.
-6b-2 polls the status route documented below and decides how to render cards,
-Retry and Open run.
+The delivery is server-side. 6b-1 shipped no V2 run-card UI. 6b-2 (0.261.251)
+polls the status route documented below and renders the run card, Retry and
+Open run; see [V2 experience (6b-2)](#v2-experience-6b-2).
What this version adds:
@@ -308,8 +310,9 @@ Notice titles and messages are fixed:
Undeliverable and expired notices link to
`/workflow-activity?workflowId=...&runId=...&scope=personal` and carry metadata
-`{workflow_id, run_id, workflow_scope: 'personal', delivery_status}`. Until 6b-2
-adds a V2 case, the V2 bell labels the new type generically as `Notification`.
+`{workflow_id, run_id, workflow_scope: 'personal', delivery_status}`. Since
+6b-2 (0.261.251), the V2 bell labels the type **Workflow results** and opens the
+run on V2's Workflows page; see [Run links](#run-links).
### Message shape and placement
@@ -692,6 +695,306 @@ The snapshot rule applies to every kind. If the workflow is later edited,
deleted, resumed or blocked by another active run, 6b-2 must use the status row
and route responses as the live truth.
+## V2 experience (6b-2)
+
+Implemented in version: **0.261.251** (#1546 part 6b-2).
+
+6b-1 posts the result on the server. 6b-2 shows it in V2 while the user is
+working, so they don't have to open Workflows to find out whether the run they
+asked for finished. 6b-2 adds no server route. Everything below reads the status
+route and calls the personal run routes described above.
+
+### When V2 tracks runs
+
+V2 tracks chat-started runs only when the role-aware bootstrap flags
+`allow_user_workflows` and `enable_chat_orchestration_workflow_runs` are both
+on. With either one off, an answer keeps Phase 5's **Started workflows** links
+and V2 makes no status requests.
+
+**Use Workflow Results In Chat** isn't needed for tracking. When it's off as a
+run starts, nothing is posted back: the row's delivery is `not_applicable`, and
+the card says the results are in the workflow's run history. When it's turned
+off after the run starts, 6b-1 posts a short `status` note in place of the
+result.
+
+As in Phase 5, the run card and the message footers appear only in the
+requester's own personal conversation, in the chat that's open, when nothing
+in the message is masked. The run card takes the place of Phase 5's links only
+while both flags are on. A footer doesn't need the tracker: only its Retry
+does, and **Follow up** and **Open run** keep their own conditions, described
+under [Posted messages](#posted-messages). With neither a result to follow up
+on nor `allow_user_workflows`, the footer shows nothing.
+
+### Run card
+
+Under an answer whose plan started workflows, each started run shows as a row
+with the workflow's name and a status badge. Until the tracker has read a run,
+its row keeps Phase 5's link and status. While none of the answer's runs has
+been read, the card's footnote says "Status when this message loaded. Open the
+run for its progress and results."
+
+The rows come from Phase 5's list of the runs the plan started. A run the
+tracker has read for the answer that the list doesn't name, for example because
+the list couldn't be read or came back empty, is added after the list's rows,
+oldest first, with the name from its status row. So when the list fails to
+load, the card still shows the error and **Try again**, and the answer's
+tracked runs keep their live status and **Check now**. These rows wait until
+the list has answered, so no link shows while it loads, and a step the list
+says can't be opened stays closed. When the plan's run can't be found (404),
+the card shows nothing, even if the tracker has read runs for it.
+
+Once read, the row follows the status row's `phase` and `status`:
+
+| Status row | Badge | What the row says |
+| --- | --- | --- |
+| `queued` or `running` | **Queued** or **Running** | How far the run has got, for example "Step 2 of 5 · 4 min elapsed", and the waiting reason when the row has one. A queued run hasn't started a step, so it shows only the time elapsed. `waiting_recovery` reads "Recovering after an interruption." and still counts as running. |
+| `waiting` (`needs_you`) | **Needs you** | Why the run is waiting, for example "Waiting for your approval." or "Reconnect Microsoft 365 to continue." |
+| `completed` or `completed_partial` | **Completed** or **Partly completed** | Where the results went (see below). |
+| `failed` or `expired` | **Failed** or **Timed out** | The row's fixed `error` text and, when `retry_blocked` is set, why Retry isn't offered. |
+| `cancelled` | **Cancelled** | The row's `error` text, or "The run was cancelled." when it has none. |
+| An unknown phase or status, a missing field, or a run that dropped out of a complete read | **Status unavailable** | Nothing else. Only **Open run** is offered. |
+
+`step_index` counts finished steps, so the step shown is `step_index + 1`,
+never more than `step_count`. Elapsed time reads "45 s", "3 min" or "1 h 5 min"
+as of the last check. A finished run says where its results went:
+
+| Delivery | What the card says |
+| --- | --- |
+| `pending` or `delivering` | "Posting results…" |
+| `delivered`, and the message is loaded in this chat | **Results posted below**, a button that scrolls to the message and moves focus to its footer |
+| `delivered`, but the message isn't loaded | "Results were posted to this chat." |
+| `undeliverable`, `expired` or `not_applicable` | "The results are in the workflow's run history." |
+
+#### Card actions
+
+Cancel, Retry and Review and approve come from the row's `actions`, never from
+its status alone. Reconnect comes from the run's waiting action. Cancel and
+Retry show only while the tracker is reading; the two links don't depend on it.
+
+| Control | Shown when | What it does |
+| --- | --- | --- |
+| **Cancel run** | `actions.cancel` is true and the run is queued, running or waiting | Asks first, then calls the run-level `POST /api/user/workflows//runs//cancel`. The workflow-level cancel is never used. |
+| **Retry** | `actions.retry` is true, the run failed, `retry_blocked` is null and the status response's `available` is true | Reads the run's runtime and checks the run can still be resumed, the same check the run's page makes. Then it calls `POST .../runtime/resume` with exactly `{expected_version, request_id}`: the runtime's current version and a fresh UUID per attempt. The run keeps its id, so its result still comes back to the chat. `/resume-failed` is never called. |
+| **Review and approve** | `actions.approve` is true, the run is waiting with the action `approve`, and the row has a `gate_id` | Opens the run in Workflows, where the gate's own prompt and choices are shown. Nothing is approved from the card, and this replaces **Open run**. |
+| **Reconnect Microsoft 365** | The run is waiting with the action `reconnect` | Opens the Microsoft 365 connection settings in the profile. |
+| **Open run** | Always, unless **Review and approve** is shown | Opens the run deep link (see [Run links](#run-links)). |
+
+When `available` is false, Retry is hidden and the card says "Starting workflows
+from chat is turned off, so Retry isn't available here." Cancel stays.
+
+Cancelling asks "Cancel this run?" first, explains that the workflow is asked to
+stop and that anything it already did stays done, and warns that a cancelled
+run can't be retried. **Cancel run** confirms and **Keep running** closes the
+dialog. The answer reads as plain text:
+
+| Cancel answer | What the card says |
+| --- | --- |
+| Accepted | Cancel requested. |
+| 404 | This run is no longer available. |
+| 409 | This run already finished or changed. Check its status. |
+| Anything else | Couldn't cancel the run. Try again. |
+
+Retry sends nothing when the runtime read says the run can't be resumed anymore
+("This run can't be retried anymore."), or when that read fails. A refusal reads
+as plain text, and only the response's `code` is read, never its sentence:
+
+| Retry answer | What the card says |
+| --- | --- |
+| Accepted | Retry requested. |
+| 409 with a `retry_blocked` code (`retry_unavailable`, `workflow_deleted`, `not_resumable`, `deadline_exceeded`, `workflow_definition_changed`, `workflow_already_running`) | The same sentence the row shows for that code, for example "The workflow changed after this run started. Start a new run from Workflows." or "Another run of this workflow is in progress. Retry when it finishes." |
+| 409 `workflow_deleting` | The workflow is being deleted, so this run can't be retried. |
+| 409 `stale_version` | The run changed since it was checked. Check its status and try again. |
+| 409 `invalid_state` | This run can't be retried in its current state. |
+| 409 `request_conflict` | Another request changed this run. Check its status and try again. |
+| Any other 409 | This run can't be retried right now. Check its status. |
+| 400 | The retry request wasn't accepted. |
+| 403 / 404 / 503, from the runtime read or the resume | You don't have access to this run. / This run is no longer available. / Workflows aren't available right now. Try again later. |
+| Another error status, no answer, or a runtime read that can't be used | Couldn't retry the run. Try again. |
+| A success whose answer couldn't be read | Couldn't confirm the retry. Check its status. |
+
+One action runs per run at a time, shared by the card and the run's note. Until
+the server answers and the chat's runs have been read again, that run's
+**Cancel run**, **Retry** and **Review and approve** stay in place with
+`aria-disabled` set, and a click or Enter does nothing. So a read that lands
+mid-Retry and finds the run waiting for approval can't open the gate while the
+Retry is still under way. The chat's runs are read again after every answer,
+and the answer's sentence stays until the run's status changes.
+
+#### Check now
+
+The card's footnote shows when its chat's runs were last checked, for example
+"Checked 9:07 AM", in an `aria-live="polite"` region. **Check now** shows while
+the tracker is reading and the answer started at least one run. It reads this
+chat's runs straight away and wakes the tracker if it had gone quiet; it's
+ignored while that read is under way. A failed read of the chat shows
+"Couldn't check the run status right now. Try again." and keeps the last good
+state. Once the tracker has halted, the card says "Live status isn't available
+right now." A failed global read isn't shown on the card; it only slows the
+tracker down.
+
+### Tracker
+
+One tracker per browser tab follows every chat-started run, so the chat list,
+the bell and any open card stay current from any page. `useWorkflowRunTracker`
+starts it from the app shell once the session has loaded, as
+`useNotificationRuntime` does. Starting it is idempotent: re-renders and page
+changes never add a second one, and it stops on sign-out or a session error.
+
+- **Requests.** Each tick is one global `GET
+ /api/v2/orchestration/workflow-runs/status`, never one request per run. A
+ run card reads its own chat with `?conversation_id=` when it appears, at most
+ once every 10 seconds per chat. **Check now**, the answer to a Cancel or
+ Retry, and a run that dropped out of a complete read force a fresh read of
+ that chat. Stopping aborts every read under way.
+- **Cadence.** While the tab is visible and a run is in flight (queued,
+ running, waiting, or with its delivery `pending` or `delivering`), it checks
+ after 15 seconds, then 30 seconds, 1 minute, 2 minutes and every 5 minutes. A
+ hidden tab pauses, and checks as soon as it's shown again if a run is in
+ flight or a check is owed. The one exception: with desktop notifications on
+ (permission granted and the user's setting on), a hidden tab keeps checking
+ every 5 minutes while a run is in flight.
+- **Going quiet.** With nothing in flight it stops checking, but stays started.
+ A plan answer wakes it with an immediate check. A run card appearing reads
+ its chat, and an in-flight run there wakes the tracker. **Check now** and the
+ answer to a Cancel or Retry wake it too.
+- **Errors.** Failed reads back off: 30 seconds, 1 minute, 2 minutes, then 5
+ minutes. A 401 or 403, or a 400 on the global read, halts it until the app
+ shell starts it again, after the session or the flags change.
+- **Partial reads.** Rows with `live: false` are shown as returned. A truncated
+ read never treats a missing run as finished; only a complete global read
+ retires a run that was in flight and dropped out of it.
+
+#### Baseline: each result is announced once
+
+The first good read in a page session that covers a chat sets that chat's
+baseline: the global read, or the chat's own read when it comes back first.
+Results already posted by then are recorded silently. A later result is
+announced only when it was posted at or after the baseline (by its
+`delivered_at`), or when the tab had already seen the run before its result was
+posted. It also needs a generation and a posted message id that starts with
+`assistant_workflow_delivery_`. Baselines survive the tracker stopping and
+starting within the page session. So a reload never announces a result twice,
+never marks a chat unread again and never shows a second desktop notification.
+
+### When a result lands
+
+When a run's delivery changes to `delivered` during the page session, V2 lands
+it the way it lands any other finished reply: through the chat store's shared
+`settleCompletedReply`, with source `workflow`, the run id and the posted
+message id.
+
+- **In the open chat**, V2 waits until no reply or plan is streaming and the
+ messages aren't loading, then re-reads the messages through the normal path.
+ If a reply starts or the messages change while that read is under way, for
+ example because the user sent a question, V2 drops what it read instead of
+ replacing the messages, and the result waits for the next quiet moment.
+ If the user already chose a source for the composer, it stays chosen. The
+ reply is marked read if the user is watching, and otherwise later, as other
+ replies are. If the re-read doesn't include the message yet, the chat stays
+ unread.
+- **In another chat**, the chat shows the unread dot and the bell count
+ refreshes. If the chat isn't in the list, the list reloads, unless the list
+ is empty, still loading or filtered by a search. 6b-1 already marked the chat
+ unread and created the bell notice, so V2 creates neither.
+- **A desktop notification** shows as it does for other replies, once per
+ message.
+- **Undeliverable or expired** results change nothing in the chat. 6b-1's bell
+ notice is the report. If the tab saw the run in flight earlier in the page
+ session, V2 refreshes the bell count.
+- **A run that drops out** of a complete read while it was in flight has
+ stopped, and how isn't known yet. Its row reads **Status unavailable**. For
+ the open chat, V2 reads that chat's runs again; for any other chat, it
+ reloads the chat list and refreshes the bell, once per read, because a result
+ may have been posted while the tab wasn't looking.
+
+### Running tag
+
+While a chat has a run that's still going or still being posted (its delivery
+`pending` or `delivering`), the chat list shows a small spinner next to the
+generating-images tag, labelled "Running Weekly digest", or "Running 2
+workflows" when there are several. It's built only from what the tracker already
+read, so it adds no requests. It hides while the chat is unread, so it gives way
+to the unread dot when the result is posted. It also hides when the tracker has
+stopped or halted, because what it last read may no longer be true.
+
+### Posted messages
+
+The message itself, the label line and the result or note, is 6b-1's text and
+renders like any other answer. V2 adds a footer, read from
+`metadata.workflow_delivery`:
+
+| Control | Shown on | What it does |
+| --- | --- | --- |
+| **Follow up** | `result` and `analysis` messages, with **Use Workflow Results In Chat** on and a result descriptor that's available and names the same workflow and run | Makes the run's result the composer's source in this chat, then focuses the composer. If the chat can't use it, the footer says "This result can't be used as a source here right now." |
+| **Retry workflow run** | `failed` notes | Resumes the same run, as the card's Retry does. A failed note reads its chat's runs when it appears, and offers Retry only while the tracker is reading, `available` is true, the run's row in this chat (same workflow, plan and step) allows a retry, and that row is for the same generation the note reported. It never starts a new run. |
+| **Open run** | Every posted message, with `allow_user_workflows` on | Opens the run deep link. |
+
+The chat's own **Retry** is hidden on these messages, and **Edit** is only ever
+offered on the user's own messages. The server refuses both for a posted
+message (`workflow_delivery_retry_unsupported`,
+`workflow_delivery_edit_unsupported`), and V2 shows that refusal if a stale
+page sends one.
+
+### Recurring-workflow card
+
+A created recurring-workflow proposal card (Phase 4) now shows when the workflow
+runs next and how its last run went. It needs `allow_user_workflows`. When the
+card renders with its created workflow, it reads the user's workflows and that
+workflow's recent runs once. It reads again only if **Use Workflow Results In
+Chat** changes, and it never polls:
+
+- **Next run**: shown when the workflow is on and has a valid `next_run_at`. A
+ time that has already passed reads "Due now".
+- **Last run**: the newest run's status and time, otherwise the workflow's
+ stored last run, otherwise "No runs yet". A status V2 doesn't know reads
+ "Status unavailable" rather than being guessed at.
+- **Open latest results**: opens the newest completed or partly completed run.
+- **Follow up**: opens a new chat about the newest run chat can answer from, as
+ **Ask in chat** does. It needs **Use Workflow Results In Chat**.
+
+Times include the time zone's name, so a schedule kept in another zone still
+reads correctly. The card's existing schedule line isn't repeated. If either
+read fails, or the workflow isn't in the list, the card says "Run details
+aren't available right now."
+
+### Run links
+
+Every V2 run link uses the Workflows page's query form,
+`/workspace/workflows?workflow_id=...&run_id=...` (or
+`/groups//workflows?...` for a group run), which opens that workflow
+with the run marked and expanded. `v2WorkflowRunPath` in
+`lib/notificationLinks.ts` now returns this path for a known scope with valid
+ids, instead of `null`, and `workflowRunHref` builds the personal form for the
+card, the footer and the recurring card.
+
+| Notice | Where it opens |
+| --- | --- |
+| 6b-1 undeliverable or expired (`workflow_chat_delivery`, classic `/workflow-activity?...&scope=personal` link) | The run in V2 |
+| 6b-1 delivered (`chat_response_complete`, chat link) | The chat (unchanged) |
+| Microsoft 365 (`m365_pending_action_id`) | Classic (unchanged) |
+| `/workflow-activity` with an unknown or missing scope, or a link and metadata that disagree | Classic |
+| `/workflow-activity` with `scope=group&groupId=...` (not Microsoft 365) | The group run in V2 |
+| No `link_url`, a `workflow_priority_alert` or `workflow_chat_delivery` notice with a valid scope and ids in its metadata | The run in V2 (previously no link) |
+| No `link_url` and no scope | No link (unchanged) |
+
+A workflow alert card with a run id and a scope now offers **Open run** in place
+of **Open workflow**; see [V2 workflow alert
+notices](V2_WORKFLOW_ALERT_NOTICES.md).
+
+### V2 limitations
+
+- Tracking needs both flags. When either is off, V2 tracks nothing.
+- A truncated global read never retires a run, so a run that stopped can stay
+ shown as in flight until a complete read.
+- A chat with more than 20 chat-started runs shows Phase 5's links for the
+ older ones.
+- An idle tab learns about runs started in another tab only at its next kick or
+ when the app starts.
+- A delivered row with a null message id or generation isn't announced.
+- `step_label` is always null from the server, so no step name is shown.
+ Elapsed time updates at each check, not every second.
+- Approving happens on the run's page, not inline on the card.
+
## Configuration
| Setting or constant | Value or behavior |
@@ -719,6 +1022,8 @@ and route responses as the live truth.
## File structure
+### Server (6b-1)
+
| File | Purpose |
| --- | --- |
| `application\single_app\functions_workflow_chat_delivery.py` | Pure delivery contract: constants, text builders, metadata, seed, merge, reconcile, message ids, notification keys and hint queue. |
@@ -734,6 +1039,39 @@ and route responses as the live truth.
| `application\single_app\background_tasks.py` | Registers the delivery loop in the background task host and scheduler host. |
| `application\single_app\config.py` | Tracks version `0.261.227`. |
+### V2 (6b-2)
+
+All paths are under `application\v2_ui\src\` except `config.py`.
+
+| File | Purpose |
+| --- | --- |
+| `lib\workflowRunStatus.ts` | Fail-closed reader for the status route, the phase and waiting texts, and which controls a row allows. |
+| `lib\workflowRunTracker.ts` | The tab's one tracker: cadence, back-off, halting, per-chat baselines, retiring runs, and which results are announced. |
+| `lib\useWorkflowRunTracker.ts` | Starts and stops the tracker from the app shell, and lands posted results through the chat store. |
+| `stores\workflowRunTrackerStore.ts` | The tracker's last snapshot for the card, the footer and the running tag, and the tag's label. |
+| `lib\workflowRunActions.ts` | The run-level cancel and the durable resume, with the text each answer reads as. |
+| `lib\useWorkflowRunAction.ts` | Allows one Cancel or Retry at a time per run, from the card or a posted note, and re-reads the chat afterwards. |
+| `lib\useFocusFallback.ts` | Moves focus to the run's row or the footer when the Cancel or Retry that had it disappears. |
+| `lib\workflowDelivery.ts` | Reads `metadata.workflow_delivery`, joins a posted message to its run, and decides Follow up and Retry. |
+| `lib\m365Links.ts` | The Microsoft 365 connection link, shared by the proposal card and the run card. |
+| `components\chat\WorkflowRunCard.tsx` | The run card under a plan's answer. |
+| `components\chat\WorkflowRunLinks.tsx` | Phase 5's started-workflows list, still shown while tracking is off. Its list read and links are shared with the run card. |
+| `components\chat\WorkflowDeliveryFooter.tsx` | The footer on a posted result or note. |
+| `components\chat\WorkflowRunningTag.tsx` | The chat list's running tag. |
+| `components\chat\WorkflowProposalRunSummary.tsx` | Next run, last run, Open latest results and Follow up on a created recurring-workflow card. |
+| `components\chat\WorkflowProposalCard.tsx` | Mounts the run summary, and imports the shared Microsoft 365 link. |
+| `components\chat\MessageList.tsx` | Mounts the run card in place of the started-workflows list when tracking is on, and the footer on posted messages. |
+| `components\chat\MessageActions.tsx` | Hides the chat's own Retry on posted messages. |
+| `components\chat\ConversationRail.tsx` | Mounts the running tag next to the generating-images tag. |
+| `stores\chatStore.ts` | Exports `settleCompletedReply`, so a posted result settles exactly as a streamed reply does. |
+| `App.tsx` | Starts the tracker once the session has loaded and both flags are on. |
+| `lib\workflowEditor.ts` | Adds the run-level cancel client and a shared request-id helper. |
+| `lib\workflowRunLink.ts` | Builds the personal or group run link. |
+| `lib\notificationLinks.ts` | `v2WorkflowRunPath` returns the run link, and workflow notices open their run in V2. |
+| `lib\workflowAlertNotices.ts` | A workflow alert's **Open run** uses the scoped run link. |
+| `lib\notifications.ts` | Labels `workflow_chat_delivery` notices **Workflow results** in the bell. |
+| `config.py` | Tracks version `0.261.251`. |
+
## Usage
### Enable or configure
@@ -771,6 +1109,8 @@ silently because the run link would be dead or unknowable.
## Testing and validation
+### Server (6b-1)
+
When this documentation was written, these workflow chat delivery tests existed
under `functional_tests\`:
@@ -800,6 +1140,34 @@ The docs inventory was regenerated with `scripts\build_docs_inventory.py`, and
the documentation coverage and site-quality tests were run. Mutation testing
results for the delivery implementation are recorded in the pull request.
+### V2 (6b-2)
+
+| Test | What it checks |
+| --- | --- |
+| `functional_tests\test_v2_workflow_run_status.mjs` | Reading the status route: rows dropped for ids that can't be used, rows that fail closed to "Status unavailable", the controls each row allows, the fixed texts, and the closed sets pinned against the server module that writes them. |
+| `functional_tests\test_v2_workflow_run_tracker.mjs` | The tracker against a fake clock: the cadence, the hidden-tab pause and its desktop-notification exception, going quiet and kicks, back-off and halts, an idempotent start, the per-chat baseline (including a run seen before its result was posted), the per-chat read's 10-second limit, closings and retirements. |
+| `functional_tests\test_v2_workflow_run_action_clients.mjs` | Cancel and Retry: the fresh runtime read, the exact resume body, no request to `/resume-failed` or the workflow-level `/cancel`, and the text for every refusal. |
+| `functional_tests\test_v2_workflow_delivery_messages.mjs` | Recognizing a posted message, reading its metadata, when its footer offers Follow up and Retry, how a result settles and where it lands, that only the result's re-read drops a read overtaken by a reply or a change to the messages, the running tag, and the bell's label. |
+| `functional_tests\test_v2_workflow_run_link_routing.mjs` | The run link in both scopes, where each of 6b-1's notices opens, and the alert card's **Open run**. |
+| `functional_tests\test_v2_workflow_run_tracking_xss_guardrail.py` | The new V2 files pass `scripts\check_xss_sinks.py`, and every link they render comes from a reviewed builder or a fixed path. |
+| `ui_tests\test_v2_workflow_run_card.py` | The card's states and actions, tracked runs the plan's list doesn't name, the running tag, how a result settles in the open chat and in another chat (including a question sent while the result is being read), and the footers, with the real stores and tracker in a test harness. |
+| `ui_tests\test_v2_workflow_run_tracker_spa.py` | In the built SPA: the tracker starts only after the app has loaded with both flags on, never twice across pages, and one posted result raises one desktop notification. |
+
+Shared fixtures are in `functional_tests\test_support\workflowRunStatusFixtures.mjs`,
+and the card test's harness is in `ui_tests\fixtures\workflow_run_tracking\`.
+6b-2 also extended these existing tests:
+
+- `ui_tests\test_v2_notifications_bell.py`: the **Workflow results** label and
+ where 6b-1's notices open.
+- `ui_tests\test_v2_orchestration_workflow_proposal_card.py`: the recurring
+ card's next run, last run and buttons.
+- `ui_tests\test_v2_workflow_alert_notices.py` and
+ `ui_tests\test_v2_document_provenance.py`: the alert card's **Open run**.
+- `functional_tests\test_v2_orchestration_workflow_run_links_xss_guardrail.py`:
+ the group form of the run link.
+
+Mutation testing results for 6b-2 are recorded in its pull request.
+
## Known limitations and residual risks
- A third consecutive storage read failure in the double-fault guarded-save path
@@ -857,8 +1225,9 @@ The implementation differs from the approved plan in these documented ways:
- Rebuilt or linked runs keep the older Phase 5 follow-up note text.
- The workflow notice link is the classic
`/workflow-activity?workflowId=...&runId=...&scope=personal` path.
-- The V2 bell shows the generic `Notification` label for
- `workflow_chat_delivery` until 6b-2 adds a specific case.
+- The V2 bell showed the generic `Notification` label for
+ `workflow_chat_delivery` until 6b-2 added the **Workflow results** case in
+ 0.261.251.
## Related
diff --git a/docs/explanation/features/V2_NOTIFICATIONS_BELL.md b/docs/explanation/features/V2_NOTIFICATIONS_BELL.md
index 5d13e34b6..61cf30098 100644
--- a/docs/explanation/features/V2_NOTIFICATIONS_BELL.md
+++ b/docs/explanation/features/V2_NOTIFICATIONS_BELL.md
@@ -167,19 +167,35 @@ leaves the open conversation alone.
### The seams for Phase 6b
-- **Run pages.** `v2WorkflowRunPath(workflowId, runId)` in `lib/notificationLinks.ts`
- returns the V2 route for a workflow run. It returns `null` until Phase 6b adds the
- run page, so `/workflow-activity` links keep opening classic. Filling it in is
- the whole change needed to move those links, and workflow notices that carry a
- run but no link, into V2.
-- **Delivered results.** When 6b posts a result back into a chat, announcing it
- through `announceCompletedReply` in `lib/replyEvents.ts` with
- `source: 'workflow'` raises the desktop notification under the same rules and
+Phase 6b-2 (0.261.251) filled in all three; see
+[Chat Workflow Result Delivery](CHAT_WORKFLOW_RESULT_DELIVERY.md) for the V2 side.
+
+- **Run pages.** `v2WorkflowRunPath(scope, workflowId, runId)` in
+ `lib/notificationLinks.ts` returns the V2 address of a workflow run. There's no
+ separate run page: it's the Workflows section of the workspace the workflow lives
+ in, with that workflow's run history open on the run,
+ `/workspace/workflows?workflow_id=&run_id=` or
+ `/groups//workflows?workflow_id=&run_id=`. It returns `null` when
+ the workspace, the workflow or the run is unknown.
+ - A `/workflow-activity` link opens there only when the link's `scope` (and
+ `groupId`) agree with the notice's `workflow_scope` (and `workflow_group_id`) and
+ its `workflowId` and `runId` agree with the ids the notice wrote. Anything else,
+ including a link the notice doesn't place, keeps opening classic.
+ - A workflow notice with no link of its own (`workflow_priority_alert` or
+ `workflow_chat_delivery`) opens its run when its metadata names the workspace,
+ workflow and run. Any other notice without a link gets none.
+ - A Microsoft 365 notice, one with `m365_pending_action_id` in its metadata or link
+ context, always stays classic, which is the only interface that renders the
+ pending action.
+ - The workspace is never read from `metadata.group_id`, which classic treats as the
+ group to make active.
+- **Delivered results.** When 6b-1 posts a result back into a chat, V2's app-shell
+ workflow run tracker settles it through `settleCompletedReply` with
+ `source: 'workflow'`, so the desktop notification follows the same rules and
deduplication as a chat reply, and clicking it opens that chat.
-- **Undeliverable results.** A notice about a result that couldn't be delivered is
- listed in the panel like any other. If 6b adds a notification type for it, it is
- labeled "Notification" until it is given a label in `describeType` in
- `lib/notifications.ts`.
+- **Undeliverable results.** 6b-1's notice about a result that couldn't be posted has
+ the type `workflow_chat_delivery`. `describeType` in `lib/notifications.ts` labels it
+ **Workflow results**, with the workflow icon.
## Desktop notifications
diff --git a/docs/explanation/features/V2_WORKFLOW_ALERT_NOTICES.md b/docs/explanation/features/V2_WORKFLOW_ALERT_NOTICES.md
index 95d9c53c1..6b4cfdb64 100644
--- a/docs/explanation/features/V2_WORKFLOW_ALERT_NOTICES.md
+++ b/docs/explanation/features/V2_WORKFLOW_ALERT_NOTICES.md
@@ -19,6 +19,9 @@ Open and Dismiss up front, with everything else under Show more, since version:
Hanging from the bell rather than **My Workspace** since version: **0.261.236** (#1642).
+**Open run** in Open workflow's place, for an alert that names its run, since version: **0.261.251**
+(Phase 6b-2, #1546).
+
Dependencies:
- The V2 bell ([V2 Notification Bell and Desktop Notifications](V2_NOTIFICATIONS_BELL.md)).
@@ -30,7 +33,7 @@ Dependencies:
No setting and no container are added. The alerts route gains one optional query
parameter, `since_hours`. The workflow runner now records where each workflow
-lives on the alerts it creates, so **Open workflow** can find it.
+lives on the alerts it creates, so **Open run** and **Open workflow** can find it.
## When a notice appears
@@ -198,23 +201,27 @@ button under a dozen chips and links:
- **Chips** for the alert's enrichments and its trigger, runner and agent.
- **The other links** the alert carries, checked by the bell's resolver. Classic
labels the link to the conversation a workflow posts into "Open workflow"; V2
- names it **Open workflow conversation**, because the card's own **Open workflow**
- goes to the workflow. A link to another site isn't offered; a note says so.
-- **Open workflow**, which goes to the workflows list of the workspace the workflow
+ names it **Open workflow conversation**, because the card's own **Open run** and
+ **Open workflow** go to the workflow. A link to another site isn't offered; a note says so.
+- **Open run**, which goes to the workflows list of the workspace the workflow
lives in and opens the run that raised the alert:
`/workspace/workflows?workflow_id=&run_id=`, or
`/groups//workflows?workflow_id=&run_id=` for a group workflow.
+ Until 0.261.251 this button was labeled **Open workflow**; the address is unchanged.
It works when that list is already open, too. The workflows section acts on each
- navigation that names a workflow once, so choosing Open workflow again, even for
+ navigation that names a workflow once, so choosing Open run again, even for
the same run, opens the run again. Anything else that later changes the list, such
as running another workflow, doesn't reopen a run it has already opened.
When the open list doesn't have the workflow, as when it was created in another tab
after the list was read, the section reads the list once more for that navigation.
If the workflow still isn't there, as when it was deleted after the run, nothing
- opens and the list isn't read again until Open workflow is chosen again.
- Alerts from before this release have no recorded scope. For those, it is taken
- from the workspace named on their **Open workflow** conversation link, and when
- that doesn't name one the button isn't shown rather than guessing.
+ opens and the list isn't read again until Open run is chosen again.
+- **Open workflow**, in Open run's place for an alert that names no run. It goes to the
+ same workflows list naming the workflow alone, `/workspace/workflows?workflow_id=`
+ (or the group's), which opens that workflow in the editor.
+- Alerts from before 0.261.199 have no recorded scope. For those, the workspace is taken
+ from the one named on their **Open workflow conversation** link, and when that doesn't
+ name one, neither Open run nor Open workflow is shown rather than guessing.
- **Ask about this**, when the alert offers it.
Below the card's body:
@@ -229,7 +236,7 @@ Below the card's body:
| Open, Dismiss | Every alert in the entry shown, so a grouped workflow's alerts are handled together. Open marks them read and goes to its destination. The button's tooltip says how many, for example "Acts on all 2 alerts from this workflow." |
| Mark read | Shown in Open's place only when the alert has nothing to open. Every alert in the entry shown. |
| Mark all read | Every alert the card holds, one request each. It never calls the bell's own `mark-all-read`, which would also clear notices the card never showed. |
-| Another link, Open workflow | Opens the page and marks every alert in the entry read, as Open does |
+| Another link, Open run, Open workflow | Opens the page and marks every alert in the entry read, as Open does |
| Ask about this | Opens a new chat that answers from the run's stored result ([Phase 6a](CHAT_WORKFLOW_RESULTS_FOLLOW_UP.md)). Nothing is marked, as with Close: the alert stays unread in the bell. |
| Close (×, Escape, backdrop) | Nothing is marked. The alerts stay unread in the bell and don't pop up again. |
@@ -238,10 +245,11 @@ keyboard user doesn't lose their place.
### Hooks for later phases
-- **Open run.** `workflowAlertOpenRunPath` calls `v2WorkflowRunPath` from
- `lib/notificationLinks.ts`, which returns `null` until Phase 6b adds a V2 run page.
- While it does, the card offers Open workflow, whose `run_id` already opens the
- run's history. Once it returns a path, **Open run** takes Open workflow's place.
+- **Open run** (filled in by Phase 6b-2, 0.261.251). `workflowAlertOpenRunPath` calls
+ `v2WorkflowRunPath(scope, workflowId, runId)` from `lib/notificationLinks.ts`. There's
+ no separate run page: it returns the run's address in its workflow's run history, the
+ same address Open workflow named before. It returns `null` when the alert names no run
+ or can't be placed, and the card then offers Open workflow, or neither.
- **Ask about this.** Phase 6a (0.261.214) fills in `workflowAlertFollowUpAction`.
It returns `{ label: 'Ask about this', run }` for a personal workflow's alert that
names a run which had finished (`completed` or `completed_partial`, read from the
@@ -346,7 +354,7 @@ still gets the empty list.
The workflow runner now records `workflow_scope` (`personal` or `group`) and
`workflow_group_id` (empty for a personal workflow) in the metadata of the alerts it
-creates, which is how Open workflow finds the right workflows list. The key isn't
+creates, which is how Open run and Open workflow find the right workflows list. The key isn't
`group_id`. When a notification is opened, classic makes `link_context.group_id` or
`metadata.group_id` the active group. So a `group_id` in the metadata would switch
groups whenever a group workflow's alert opens a link that names no group, such as a
@@ -429,8 +437,8 @@ as Flask does in production. Before, it answered those with Vite's "did you mean
- The count is polled every 30 seconds, doubling while nothing changes, up to five
minutes. So in a tab that has been quiet for a while an alert can take up to five
minutes to appear. Focusing the window or returning to the tab reads it at once.
-- Open run arrives with Phase 6b. Ask about this (Phase 6a) is offered for personal
- workflows only, and only while **Use Workflow Results In Chat** is on.
+- Ask about this (Phase 6a) is offered for personal workflows only, and only while
+ **Use Workflow Results In Chat** is on.
- The card's **Open** button uses `--ok-strong` with `--on-ok`, a green chosen to keep
its text at 5.5:1 in the light theme and 7.9:1 in the dark theme. **Mark read**,
shown only when there is nothing to open, pairs `--accent` with `--on-accent` at
@@ -445,8 +453,8 @@ as Flask does in production. Before, it answered those with Vite's "did you mean
| `functional_tests/route_tests/test_workflow_alert_since_hours_policy.py` | 8 functions | The route keeps its Blueprint, Swagger and authentication policy; classic's request is unchanged; V2's window is validated and passed through; out-of-range limits fall back as before; invalid windows are refused without reading; a reader failure keeps the existing error shape; through the real reader with a failing Cosmos query, V2's read answers `500` and classic's still answers an empty list |
| `functional_tests/test_v2_alert_lab_excluded_from_build.py` | 4 | The lab's markers exist only in lab code; the reference scanner recognizes every import form; the only reference to the lab is App.tsx's lazy import inside the `import.meta.env.DEV` branch; an existing production build has no file named for the lab and no lab marker (skipped without a build) |
| `functional_tests/test_workflow_priority_alerts.py` | Existing | Classic's workflow alert contract, with the new signature |
-| `ui_tests/test_v2_workflow_alert_notices.py` | 60 | Pop-up versus notify-only and the 24-hour window against a server that leaves both filters out; one claim across two tabs of one browser, and a tab opened later; waiting behind a dialog, the bell's panel and a hidden tab; only a successful read retiring an alert, through a failed read on return and a zero count whose confirming read fails; a rise on return read behind a read already on its way; the eight-second tuck, its hover and focus pause, and high and critical staying; storm grouping; every card action; only Close, Show more, Dismiss and the green Open shown up front, with Open going to the created conversation and settling every alert of the entry, and the detail, chips and other actions under Show more; keyboard focus right after the bell, Escape and the tuck on covering focus; motion with and without reduced motion, including the Web Animations' properties and every `wf-*` keyframe; the rail expanded, collapsed and on a 360 px phone in both themes, with the notch pointing at the bell in each; the notice staying under the bell while the chat rail is scrolled, for an ordinary alert and one that needs acknowledgment; an alert that needs acknowledgment taking a row of its own under the rail's header, pointing at the bell, after the header controls in the tab order and back in its row after the rail is collapsed and expanded; text contrast for every priority and category in both themes, with and without reduced transparency; hostile text rendered as text; refused off-site links; Open workflow's personal, group and unplaceable cases; and the acknowledgment, sound, size and team cases listed in [Workflow Alert Acknowledgment](WORKFLOW_ALERT_ACKNOWLEDGMENT.md) |
-| `ui_tests/test_v2_document_provenance.py` | 6 added | Against the real workflows section, personal and group: Open workflow while the list is open expands the run; a second Open workflow for the same run opens it again; running another workflow afterwards doesn't reopen it; a workflow created after the list was read is found with exactly one more list read, which opens its run; a workflow missing from that read too costs one list read per navigation and no more, opens nothing, shows no error and leaves the list usable |
+| `ui_tests/test_v2_workflow_alert_notices.py` | 60 | Pop-up versus notify-only and the 24-hour window against a server that leaves both filters out; one claim across two tabs of one browser, and a tab opened later; waiting behind a dialog, the bell's panel and a hidden tab; only a successful read retiring an alert, through a failed read on return and a zero count whose confirming read fails; a rise on return read behind a read already on its way; the eight-second tuck, its hover and focus pause, and high and critical staying; storm grouping; every card action; only Close, Show more, Dismiss and the green Open shown up front, with Open going to the created conversation and settling every alert of the entry, and the detail, chips and other actions under Show more; keyboard focus right after the bell, Escape and the tuck on covering focus; motion with and without reduced motion, including the Web Animations' properties and every `wf-*` keyframe; the rail expanded, collapsed and on a 360 px phone in both themes, with the notch pointing at the bell in each; the notice staying under the bell while the chat rail is scrolled, for an ordinary alert and one that needs acknowledgment; an alert that needs acknowledgment taking a row of its own under the rail's header, pointing at the bell, after the header controls in the tab order and back in its row after the rail is collapsed and expanded; text contrast for every priority and category in both themes, with and without reduced transparency; hostile text rendered as text; refused off-site links; Open run's personal and group cases, Open workflow for an alert that names no run, and neither for an alert that can't be placed; and the acknowledgment, sound, size and team cases listed in [Workflow Alert Acknowledgment](WORKFLOW_ALERT_ACKNOWLEDGMENT.md) |
+| `ui_tests/test_v2_document_provenance.py` | 6 added | Against the real workflows section, personal and group: Open run while the list is open expands the run; a second Open run for the same run opens it again; running another workflow afterwards doesn't reopen it; a workflow created after the list was read is found with exactly one more list read, which opens its run; a workflow missing from that read too costs one list read per navigation and no more, opens nothing, shows no error and leaves the list usable |
The UI suite mounts the real V2 frame in a harness build, following
`ui_tests/test_v2_notifications_bell.py`. It stubs HTTP with `page.route` and fakes
diff --git a/docs/explanation/release_notes.md b/docs/explanation/release_notes.md
index a1b01e4a3..c488a460f 100644
--- a/docs/explanation/release_notes.md
+++ b/docs/explanation/release_notes.md
@@ -2,6 +2,48 @@
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.251)**
+
+#### New Features
+
+* **Live Status For Workflows Started From V2 Chat**
+ * Under a plan's answer, each workflow the plan started now shows its run's current status (Queued, Running, Needs you, Completed, Partly completed, Failed, Timed out or Cancelled), with the step it's on, the time elapsed, why it's waiting and where its results went. A status V2 can't read with certainty shows **Status unavailable** with only **Open run**, never a guess.
+ * **Check now** reads the chat's runs straight away and shows when they were last checked. **Cancel run** asks you to confirm, then asks the run to stop. **Retry** resumes the same failed durable run after a fresh read of its runtime and never starts a new run; when the server refuses, for example because the workflow changed or another run is in progress, the card says why. **Review and approve** opens the run in Workflows, where the step's prompt and choices are shown; nothing is approved from the chat. **Reconnect Microsoft 365** opens the connection in your profile.
+ * Shown in private personal chats while `allow_user_workflows` and `enable_chat_orchestration_workflow_runs` are both on. With either one off, the started-workflow links show as before and no status is requested.
+ * (Ref: #1546, #1543, #1610, `WorkflowRunCard.tsx`, `WorkflowRunLinks.tsx`, `workflowRunStatus.ts`, `workflowRunActions.ts`, `useWorkflowRunAction.ts`, `workflowEditor.ts`, [Workflow result delivery to chat](features/CHAT_WORKFLOW_RESULT_DELIVERY.md#v2-experience-6b-2))
+
+* **One Workflow Run Tracker Per Browser Tab**
+ * V2 checks chat-started runs from the app shell, on any page, with one request per check to the batched status route, never one per run, so the chat list, the bell and the run card stay current. It checks first after 15 seconds, then less often up to every 5 minutes, pauses while the tab is hidden unless desktop notifications are on, and stops once nothing is in flight. A new plan answer, the run card or **Check now** starts it again, and it stops when the session ends.
+ * When a run's results are posted while the page is open, the open chat reloads its messages once any reply in progress finishes, without replacing a source you already chose. Another chat gets its unread dot, and the bell count refreshes. The server's one bell notification is the only one, and a desktop notification, if you've turned them on, shows only while you aren't looking at the page.
+ * The first check after the page loads records what was already posted, so reloading never announces a result, marks a chat unread or notifies again.
+ * (Ref: `workflowRunTracker.ts`, `useWorkflowRunTracker.ts`, `workflowRunTrackerStore.ts`, `chatStore.ts`, `App.tsx`, [Workflow result delivery to chat](features/CHAT_WORKFLOW_RESULT_DELIVERY.md#v2-experience-6b-2))
+
+* **Follow Up, Retry And Open Run On Posted Workflow Results**
+ * A message a workflow run posted to its chat now ends with its own actions. **Follow up** makes a posted result or saved analysis the source of your next question in the same chat, while **Use Workflow Results In Chat** is on and the result is still available. **Open run** opens the run in Workflows. A note that the run failed offers **Retry workflow run** only while a fresh check says the same run can still be resumed.
+ * The chat's own **Retry** and **Edit** are hidden on these messages, since the server refuses both. If a refusal still comes back, its message is shown.
+ * (Ref: `WorkflowDeliveryFooter.tsx`, `workflowDelivery.ts`, `MessageActions.tsx`, `MessageList.tsx`, [Chat controls](../reference/chat-controls.md#results-posted-to-the-chat))
+
+* **Next Run And Last Run On The Recurring-Workflow Card**
+ * The card for a workflow created from a chat proposal now shows when it runs next, or **Due now**, and its newest run's status and time, or **No runs yet**. **Open latest results** opens the newest completed or partly completed run, and **Follow up** opens a new chat about the newest run chat can answer from.
+ * The card reads these once when it's shown and doesn't keep checking. If the read fails, it says "Run details aren't available right now."
+ * (Ref: `WorkflowProposalRunSummary.tsx`, `WorkflowProposalCard.tsx`, `workflowEditor.ts`)
+
+* **Workflow Notifications Open The Run In V2**
+ * In V2, a notification that links to a run on the classic workflow activity page now opens that run in the Workflows run history when the link and the notice agree on the workflow, the run and its workspace: personal, or a group with its id. Microsoft 365 notices, and notices whose link and details disagree or don't say which workspace, keep their classic link.
+ * A workflow alert, or a notice that a run's results couldn't be posted to its chat, that has no link of its own but names its run and workspace now opens that run. Previously it had no link.
+ * (Ref: `notificationLinks.ts`, `workflowRunLink.ts`, `workflowAlertNotices.ts`, [V2 Notifications Bell](features/V2_NOTIFICATIONS_BELL.md))
+
+#### User Interface Enhancements
+
+* **Running Tag In The V2 Chat List**
+ * A small spinner beside a chat shows that a workflow it started is still running or its results are being posted, labelled for example "Running Weekly digest" or "Running 2 workflows". It uses what the tracker already knows, so the list sends no requests of its own, and it gives way to the unread dot once the results arrive.
+ * (Ref: `WorkflowRunningTag.tsx`, `ConversationRail.tsx`)
+
+* **Workflow Results Notices And Alert Cards**
+ * The bell labels notices about a run whose results couldn't be posted to its chat as **Workflow results**.
+ * A workflow alert card that names its run now offers **Open run**, which opens the same place **Open workflow** did. Alerts without a run keep **Open workflow**.
+ * (Ref: `notifications.ts`, `WorkflowAlertCard.tsx`, `workflowAlertNotices.ts`, [V2 Workflow Alert Notices](features/V2_WORKFLOW_ALERT_NOTICES.md))
+
### **(v0.261.250)**
#### New Features
diff --git a/docs/guides/trigger-a-workflow.md b/docs/guides/trigger-a-workflow.md
index 646400ec0..97aa9883e 100644
--- a/docs/guides/trigger-a-workflow.md
+++ b/docs/guides/trigger-a-workflow.md
@@ -73,25 +73,33 @@ Before you ask:
shows **Paused**. Starting it here runs it once and leaves it turned off.
3. Select **Approve**. The plan starts each workflow once.
4. Read the answer. It says which workflows started, and which didn't and why.
-5. Under the answer, each started workflow shows its run's status as of when the message
- loaded. Select **Open run** to follow its progress and results in the workflow's run
- history.
-
-The plan doesn't wait for the workflow to finish, and it doesn't bring the results into
-the chat. They arrive where the workflow already sends them, such as its conversation or
-alerts. Stopping the plan doesn't stop a workflow it already started, so cancel the run
-from its run history if you need to. Retrying a failed step never starts a workflow
-twice: when the plan already started it, the retry links that run instead.
+5. Under the answer, each started workflow shows its run's status: the step it's on,
+ the time elapsed, and anything it needs from you. V2 keeps checking while the run
+ is in flight, even while you're on another page. Select **Check now** to check
+ straight away, or **Open run** to follow it in the workflow's run history.
+
+The plan doesn't wait for the workflow to finish. While a run is going, a spinner beside
+its chat in the chat list says so. From the card you can cancel a run that's still going
+with **Cancel run**, and resume a failed one with **Retry**, which continues the same run
+instead of starting another. Stopping the plan doesn't stop a workflow it already
+started. Retrying a failed step of the plan never starts a workflow twice: when the plan
+already started it, the retry links that run instead.
When your administrator also turns on **Use Workflow Results In Chat**, a
durable personal run started from chat can post its outcome back into that same
private chat after it finishes. The chat is marked unread, and a bell
notification opens the chat. If the chat was deleted or shared before delivery,
-you get a workflow notification that opens the run instead.
+you get a workflow notification that opens the run instead. A posted result has
+**Follow up**, to ask about it in the same chat, and **Open run**. A note that the
+run failed offers **Retry workflow run** while the run can still be resumed.
+Without that setting, results arrive where the workflow already sends them, such
+as its conversation or alerts. See
+[Results posted to the chat]({{ '/reference/chat-controls/' | relative_url }}#results-posted-to-the-chat).
A workflow that's already running isn't started again; the answer says so, and you can
start it once that run finishes. If a run the plan started waits for a Microsoft 365
-approval or sign-in, its status shows **Waiting**; follow
+approval or sign-in, its status shows **Needs you**, and for a sign-in the card offers
+**Reconnect Microsoft 365**; follow
[Microsoft 365 authorization waits](#microsoft-365-authorization-waits) to continue it.
## Continue a durable run
@@ -297,6 +305,8 @@ See [Workflow publication completion](../explanation/features/WORKFLOW_PUBLICATI
| The answer says only workflows with durable execution can be started from chat | The workflow runs synchronously | Turn on **Durable execution** in the V2 editor and save, or select **Run** in Workflows. |
| The answer says the workflow is waiting for a Microsoft 365 approval or sign-in | An earlier run is waiting on Microsoft 365 | Finish that approval or sign-in, then ask again. |
| A started workflow's link says it's unavailable | The workflow was deleted, or the run is no longer in its run history | Open the workflow in Workflows to start a new run. |
+| A started workflow shows **Status unavailable** | V2 couldn't read the run's status with certainty, for example because the run stopped while nothing was checking it | Select **Check now**, or **Open run** to see the run in Workflows. |
+| A failed run started from chat doesn't offer **Retry** | The workflow changed or was deleted after the run started, another run of it is in progress, the run reached its time limit, it didn't run because nothing changed, or starting workflows from chat was turned off | Read the reason on the card, then start a new run from Workflows if you still need one. |
## Related
diff --git a/docs/reference/chat-controls.md b/docs/reference/chat-controls.md
index aaf729e88..7c1c449c7 100644
--- a/docs/reference/chat-controls.md
+++ b/docs/reference/chat-controls.md
@@ -575,6 +575,10 @@ the answer's files, and nothing is created until you choose. See
| Deny | Declines the proposal after you confirm. Nothing is created. | Say you don't want this workflow. | Same as Instructions, until the proposal is decided or expires |
| Create again | Creates the workflow again, paused, after you deleted it. | Bring back a workflow you removed, from the same proposal. | Same as Instructions, until the proposal expires |
| Open workflow | Opens the created workflow in Workflows. | Run, edit or turn on the workflow the proposal created. | The workflow exists |
+| Next run | Shows when the created workflow runs next, with the time zone's name, or **Due now** once that time has passed. | Know when the recurring work happens without opening Workflows. | The workflow exists and is turned on |
+| Last run | Shows the newest run's status and when it ran, or **No runs yet**. A status V2 doesn't recognize reads **Status unavailable** rather than a guess. | See whether the workflow's latest run worked. | The workflow exists |
+| Open latest results | Opens the newest completed or partly completed run in the workflow's run history. | Read what the workflow last produced. | The workflow has a completed or partly completed run |
+| Follow up | Opens a new chat about the newest run chat can answer from, as **Ask in chat** does in Workflows. | Ask what the workflow found without copying its output into chat. | `enable_chat_workflow_results`, for a completed or partly completed run that isn't a structured run |
| Check again | Reads the proposal's status again after automatic checking stops. | Confirm the result when creating the workflow takes longer than expected. | The proposal is still being created |
| Try again | Reloads the proposals after a failed read. | Recover the card after a network or server error. | The proposals couldn't be loaded |
@@ -583,6 +587,13 @@ for up to two minutes, and waits while the browser tab is hidden. When
Microsoft 365 isn't connected for workflows, the card links to the connection in
your profile, and while a run waits for Run as approval, it links to Approvals.
+Since **0.261.251**, a created card reads the workflow and its recent runs once
+to show **Next run** and **Last run**. It doesn't keep checking, so open the
+chat again to see a newer run. If that read fails, the card says "Run details
+aren't available right now."
+
+{% include media.html src="reference/chat-controls-workflow-proposal-run-summary.png" alt="A created workflow proposal card showing Next run and Last run, with Open latest results and Follow up." title="Created workflow card" capture="Capture a created workflow proposal card for a scheduled workflow that has run, showing Next run with its time zone, Last run with its status, and the Open latest results and Follow up buttons. Redact the workflow name." %}
+
## Workflow runs (V2 interface)
Implemented in **0.261.212** (Refs: microsoft/simplechat#1551). When you ask chat
@@ -591,20 +602,63 @@ the plan can start it. Only workflows with durable execution on can be started t
and only from a conversation that's private to you. See
[Run a workflow from chat]({{ '/guides/trigger-a-workflow/' | relative_url }}#run-a-workflow-from-chat).
+{% include media.html src="reference/chat-controls-workflow-run-card.png" alt="Started workflows card under a plan answer, showing a running run's step and elapsed time with Cancel run, Open run and Check now, and a running spinner beside the chat in the chat list." title="Workflow run card" capture="Capture a plan answer's Started workflows card with one running run (step, elapsed time, Cancel run, Open run, Check now and its Checked time) and the running spinner beside that chat in the chat list. Redact conversation titles and workflow names." %}
+
| Control or state | What it does | Why you would use it | Available when |
| --- | --- | --- | --- |
| Saved workflows notice | Says the plan always waits for you to approve it, and lists each workflow it would start with its trigger. A workflow that's turned off shows **Paused**. A paused workflow still runs once when you start it here, and starting it doesn't turn it back on. | Check which workflows will start before you approve. A countdown or Auto never starts one. | `enable_chat_orchestration`, `allow_user_workflows` and `enable_chat_orchestration_workflow_runs`, in a conversation that's private to you |
| Workflow (Run view) | Shows the name of the workflow a step starts, with **Paused** when it's turned off. | Match each step to the workflow it starts. | A plan with a step that starts a saved workflow |
-| Started workflows | Lists, under the answer, each workflow the plan started with its run's status: **Queued**, **Running**, **Waiting**, **Completed**, **Partly completed**, **Failed**, **Cancelled** or **Skipped**. The status is as of when the message loaded; reload to read it again. | See at a glance whether the work you started is still going. | Your own private conversation, after a plan started a workflow |
+| Started workflows | Lists, under the answer, each workflow the plan started with its run's status. While runs are tracked, the status stays current: **Queued**, **Running**, **Needs you**, **Completed**, **Partly completed**, **Failed**, **Timed out** or **Cancelled**, with the step the run is on, the time elapsed, why it's waiting and where its results went. Otherwise, and until a run is first checked, the status is as of when the message loaded: **Queued**, **Running**, **Waiting**, **Completed**, **Partly completed**, **Failed**, **Cancelled** or **Skipped**. A status V2 can't read with certainty shows **Status unavailable** and only **Open run**. | See at a glance whether the work you started is still going, without leaving the chat. | Your own private conversation, after a plan started a workflow. Runs are tracked while `allow_user_workflows` and `enable_chat_orchestration_workflow_runs` are both on |
| Open run | Opens the workflow in Workflows with its run history open at that run. | Follow the run's progress and read its results. | The workflow and its run still exist |
| Unavailable | Replaces **Open run** with the reason the run can't be opened, for example because the workflow was deleted or the run is no longer in its history. | Understand why a link is missing without losing the rest. | The run can't be opened, or starting workflows from chat was turned off |
| Try again | Reloads the started workflows after a failed read. | Recover the links after a network or server error. | The started workflows couldn't be loaded |
+| Check now | Reads this chat's runs again straight away. The card shows when they were last checked, for example "Checked 9:07 AM". | Get the latest status without waiting for the next automatic check. | Runs are tracked and the answer started at least one |
+| Cancel run | Asks you to confirm, then asks the run to stop. Anything it already did stays done, and a cancelled run can't be retried. | Stop a run you no longer need. | Runs are tracked, and the run is queued, running or waiting and can still be cancelled |
+| Retry | Resumes the same failed run, reusing the steps that already finished. It never starts a new run. If the run can't be resumed, the card says why, for example because the workflow changed after the run started. | Recover from a failure without asking the plan again. | Runs are tracked, the run failed and can still be resumed, and starting workflows from chat is still on |
+| Review and approve | Opens the run in Workflows, where the step's approval prompt and choices are shown. Nothing is approved from the chat. | Decide on the step the run is waiting for. | The run is waiting for your approval |
+| Reconnect Microsoft 365 | Opens the Microsoft 365 connection in your profile. | Let a run that's waiting for you to sign in continue. | The run is waiting for a Microsoft 365 sign-in |
+| Results posted below | Scrolls to the message the run posted in this chat. | Jump from the run to its results. | The run's results were posted to this chat and that message is loaded |
+| Running tag | Shows a small spinner beside the chat in the chat list, labelled with the workflow's name, for example "Running Weekly digest", or "Running 2 workflows" when there are several. | See which chats are waiting on a workflow, from any page. | Runs are tracked, a run started from that chat is still going or its results are being posted, and the chat has no unread reply |
The answer itself lists what happened to each workflow: started, already started for
-this request, or not started with the reason. Results arrive where the workflow
-already sends them, such as its conversation or alerts, not in the chat answer.
-Stopping the plan doesn't stop a workflow it already started; cancel the run in
-Workflows.
+this request, or not started with the reason. Stopping the plan doesn't stop a
+workflow it already started; use **Cancel run** or cancel the run in Workflows.
+
+A run takes one action at a time. While its **Cancel run** or **Retry** is waiting
+on the server, that run's **Cancel run**, **Retry** and **Review and approve** stay
+in place but do nothing, on the card and on the run's note in the chat.
+
+Runs are tracked since **0.261.251**. While a tracked run is in flight, V2
+checks on it from any page, so the chat list and the bell stay current: first
+after 15 seconds, then less often, up to every 5 minutes. It pauses while the
+browser tab is hidden, unless desktop notifications are on, and stops checking
+once nothing is in flight.
+
+### Results posted to the chat
+
+Implemented in **0.261.227**, with V2's controls in **0.261.251** (Refs:
+microsoft/simplechat#1546). When **Use Workflow Results In Chat**
+(`enable_chat_workflow_results`) is also on, a run started from chat posts its
+result, or a short note when it failed, was cancelled or can't be shown, back
+into the chat that started it. The chat is marked unread, the bell gets one
+notification, and if you've turned on desktop notifications, one shows while
+you aren't looking at the page. The message starts with a line such as
+"Results from `Weekly digest` · you asked on Sun Jan 4, 2026, 9:30 PM EST". If
+the chat was deleted or shared before the run finished, a workflow notification
+opens the run instead. Without that setting, results arrive where the workflow
+already sends them, such as its conversation or alerts.
+
+{% include media.html src="reference/chat-controls-workflow-posted-result.png" alt="A workflow result posted into the chat that started it, with its Results from label and the Follow up and Open run buttons below it." title="Results posted to the chat" capture="Capture a workflow result posted into the chat that started it, showing its Results from label and the Follow up and Open run buttons. Redact the conversation title, workflow name and result content." %}
+
+| Control | What it does | Why you would use it | Available when |
+| --- | --- | --- | --- |
+| Follow up | Makes the run's result the source of your next question in this chat, then puts the cursor in the message box. | Ask about what the run found without opening a new chat. | A posted result or saved analysis, with `enable_chat_workflow_results` on and the result still available |
+| Retry workflow run | Resumes the same failed run, as the card's **Retry** does. It never starts a new run. | Recover from a failure from the note itself. | A note that the run failed, while runs are tracked and the run can still be resumed |
+| Open run | Opens the run in Workflows. | See the run's details, steps and full output. | Any posted message, with `allow_user_workflows` on |
+
+The chat's own **Retry** isn't offered on a posted message, and the server
+refuses Retry and Edit for one: run the workflow again from Workflows, or ask a
+new question instead.
## Workflow results in chat (V2 interface)
diff --git a/functional_tests/test_support/workflowRunStatusFixtures.mjs b/functional_tests/test_support/workflowRunStatusFixtures.mjs
new file mode 100644
index 000000000..fb1d94796
--- /dev/null
+++ b/functional_tests/test_support/workflowRunStatusFixtures.mjs
@@ -0,0 +1,99 @@
+// workflowRunStatusFixtures.mjs
+// Version: 0.261.251
+// Implemented in: 0.261.251
+// Builds rows and responses in the exact shape of 6b-1's chat-started workflow run status route
+// (GET /api/v2/orchestration/workflow-runs/status, functions_workflow_chat_delivery_status.py),
+// for the V2 workflow run tests. Every key the route writes is present, so a test that drops or
+// changes one is testing exactly that change.
+
+const BASE_TIME = Date.parse('2026-01-05T09:00:00Z');
+
+/** A seconds-precision UTC time, as the route writes it, `seconds` after the fixtures' base time. */
+export function at(seconds) {
+ return new Date(BASE_TIME + seconds * 1000).toISOString().replace('.000Z', 'Z');
+}
+
+/** A delivered message id with the prefix the route requires. */
+export function deliveryMessageId(runId, generation) {
+ return `assistant_workflow_delivery_${runId}_g${generation}`;
+}
+
+/** A running row in chat-1, started by orchestration run orun-1, with every field set. */
+export function statusRow(runId, overrides = {}) {
+ const { delivery = {}, actions = {}, ...rest } = overrides;
+ return {
+ workflow_id: `wf-${runId}`,
+ workflow_scope: 'personal',
+ run_id: runId,
+ conversation_id: 'chat-1',
+ orchestration_run_id: 'orun-1',
+ step_id: `step-${runId}`,
+ workflow_name: 'Weekly digest',
+ status: 'running',
+ phase: 'running',
+ runtime_version: 3,
+ step_index: 1,
+ step_count: 4,
+ step_label: null,
+ waiting: null,
+ live: true,
+ requested_at: at(0),
+ started_at: at(5),
+ completed_at: null,
+ elapsed_seconds: 95,
+ delivery: {
+ status: 'pending',
+ generation: null,
+ message_id: null,
+ delivered_at: null,
+ reason: null,
+ ...delivery,
+ },
+ error: null,
+ error_code: null,
+ retry_blocked: null,
+ actions: { cancel: true, retry: false, approve: false, open_run: true, ...actions },
+ ...rest,
+ };
+}
+
+/** A completed run whose result was posted to its chat under `generation` at `deliveredAt`. */
+export function deliveredRow(runId, generation, deliveredAt, overrides = {}) {
+ const { delivery = {}, actions = {}, ...rest } = overrides;
+ return statusRow(runId, {
+ status: 'completed',
+ phase: 'finished',
+ step_index: 4,
+ completed_at: deliveredAt,
+ ...rest,
+ delivery: {
+ status: 'delivered',
+ generation,
+ message_id: deliveryMessageId(runId, generation),
+ delivered_at: deliveredAt,
+ reason: null,
+ ...delivery,
+ },
+ actions: { cancel: false, ...actions },
+ });
+}
+
+/** A failed run with the route's default failure sentence and code. */
+export function failedRow(runId, overrides = {}) {
+ const { delivery = {}, actions = {}, ...rest } = overrides;
+ return statusRow(runId, {
+ status: 'failed',
+ phase: 'failed',
+ completed_at: at(200),
+ error: 'The run stopped before it finished.',
+ error_code: 'failed',
+ ...rest,
+ delivery,
+ actions: { cancel: false, ...actions },
+ });
+}
+
+/** A whole response; `checked_at` defaults to 300 s after the base time. */
+export function statusResponse(runs, overrides = {}) {
+ return { available: true, runs, checked_at: at(300), truncated: false, ...overrides };
+}
diff --git a/functional_tests/test_v2_orchestration_workflow_run_links_xss_guardrail.py b/functional_tests/test_v2_orchestration_workflow_run_links_xss_guardrail.py
index 4404fb49f..6cf6986c9 100644
--- a/functional_tests/test_v2_orchestration_workflow_run_links_xss_guardrail.py
+++ b/functional_tests/test_v2_orchestration_workflow_run_links_xss_guardrail.py
@@ -2,20 +2,24 @@
# test_v2_orchestration_workflow_run_links_xss_guardrail.py
"""
Functional test for the V2 workflow run links passing the XSS sink guardrail.
-Version: 0.261.212
+Version: 0.261.251
Implemented in: 0.261.212
+A group run's link built through the checked group path builder: 0.261.251
This test ensures that the Started workflows links under a chat answer pass
scripts/check_xss_sinks.py the way CI runs it. The link component calls the
reviewed same-origin builder workflowRunHref inside the link, the checker lists
that builder as approved, a link taken from a plain property is still flagged,
-and the builder still returns only the fixed Workflows path.
+and the builder still returns only the fixed Workflows path of the personal
+workspace or, for a group run, of that group's workspace.
"""
import importlib.util
import sys
from pathlib import Path
+import pytest
+
ROOT_DIR = Path(__file__).resolve().parents[1]
V2_SRC_DIR = ROOT_DIR / 'application' / 'v2_ui' / 'src'
@@ -100,32 +104,25 @@ def test_checker_still_flags_a_link_from_a_property() -> None:
def test_builder_returns_only_the_fixed_workflows_path() -> None:
- """The approval holds only while the builder returns the fixed same-origin path."""
+ """The approval holds only while the builder returns a fixed same-origin Workflows path."""
builder_source = read_text(RUN_LINK_BUILDER)
- assert 'export function workflowRunHref(workflowId: string, runId: string): string {' in builder_source
+ assert (
+ 'export function workflowRunHref(workflowId: string, runId: string, '
+ 'scope: WorkflowScope = PERSONAL_SCOPE): string {'
+ ) in builder_source
+ assert "const PERSONAL_SCOPE: WorkflowScope = { type: 'personal' };" in builder_source
assert (
'const params = new URLSearchParams({ [WORKFLOW_LINK_PARAM]: workflowId, '
'[WORKFLOW_RUN_LINK_PARAM]: runId });'
) in builder_source
assert 'return `/workspace/workflows?${params}`;' in builder_source
+ # A group run opens that group's Workflows section, through the checked group path builder.
+ assert "import { groupWorkspacePath } from './groupWorkspaceNavigation';" in builder_source
+ assert "return `${groupWorkspacePath(scope.groupId, 'workflows')}?${params}`;" in builder_source
+ assert builder_source.count('return `') == 2
if __name__ == '__main__':
- tests = [
- test_run_link_files_pass_xss_guardrail,
- test_run_link_uses_the_reviewed_builder_in_the_link,
- test_checker_still_flags_a_link_from_a_property,
- test_builder_returns_only_the_fixed_workflows_path,
- ]
- failures = 0
- for test in tests:
- print(f'Running {test.__name__}...')
- try:
- test()
- print(' passed')
- except AssertionError as error:
- failures += 1
- print(f' failed: {error}')
- print(f'Results: {len(tests) - failures}/{len(tests)} tests passed')
- sys.exit(1 if failures else 0)
+ # Run through pytest, which rewrites these asserts so they still run under python -O.
+ raise SystemExit(pytest.main([__file__, '-q']))
diff --git a/functional_tests/test_v2_workflow_delivery_messages.mjs b/functional_tests/test_v2_workflow_delivery_messages.mjs
new file mode 100644
index 000000000..6ce432a04
--- /dev/null
+++ b/functional_tests/test_v2_workflow_delivery_messages.mjs
@@ -0,0 +1,643 @@
+// test_v2_workflow_delivery_messages.mjs
+// Version: 0.261.251
+// Implemented in: 0.261.251
+// Executes the pure helpers behind the messages a chat-started workflow run posts back to the chat
+// that started it (phase 6b): how a posted message is recognised (the server's own test, the id
+// prefix or the metadata key), how its metadata is read (fail closed), when its footer offers
+// Follow up and when Retry (only for the same run, step and generation, and only while the
+// tracker's newest read says the server would resume it), how a posted result settles as a
+// workflow reply, when the open chat must wait before it is re-read, where each posted result
+// lands, the chat list's running tag, and the bell's label for 6b-1's notice. The server facts the
+// client mirrors are pinned against the modules that write them, so a change on either side fails
+// here first.
+
+import assert from 'node:assert/strict';
+import { readFileSync } from 'node:fs';
+import test from 'node:test';
+import './test_support/tsResolve.mjs';
+import {
+ at,
+ deliveredRow,
+ deliveryMessageId,
+ failedRow,
+ statusResponse,
+ statusRow,
+} from './test_support/workflowRunStatusFixtures.mjs';
+
+globalThis.fetch = () => {
+ throw new Error('The workflow delivery helpers must not make network requests.');
+};
+
+// The repository resolver must be registered before extensionless TypeScript imports load.
+const {
+ WORKFLOW_DELIVERY_KINDS,
+ WORKFLOW_DELIVERY_VERSION,
+ isWorkflowDeliveryMessage,
+ parseWorkflowDeliveryMetadata,
+ planWorkflowDeliveryLanding,
+ readWorkflowDelivery,
+ workflowDeliveryCanRetry,
+ workflowDeliveryFollowUp,
+ workflowDeliveryFooterId,
+ workflowDeliveryMustWait,
+ workflowDeliveryReply,
+ workflowDeliveryRun,
+} = await import('../application/v2_ui/src/lib/workflowDelivery.ts');
+const {
+ WORKFLOW_DELIVERY_MESSAGE_PREFIX,
+ parseWorkflowRunStatusResponse,
+} = await import('../application/v2_ui/src/lib/workflowRunStatus.ts');
+const { describeNotification, normalizeNotification } = await import('../application/v2_ui/src/lib/notifications.ts');
+const {
+ EMPTY_WORKFLOW_RUN_TRACKER_SNAPSHOT,
+ trackedWorkflowRun,
+ useWorkflowRunTrackerStore,
+ workflowRunConversationRead,
+ workflowRunForAnswerStep,
+ workflowRunningLabel,
+ workflowRunningTagLabel,
+ workflowRunsCheckedAt,
+ workflowRunsForAnswer,
+ workflowRunsInFlight,
+} = await import('../application/v2_ui/src/stores/workflowRunTrackerStore.ts');
+
+const SERVER_DIR = new URL('../application/single_app/', import.meta.url);
+const CLIENT_DIR = new URL('../application/v2_ui/src/', import.meta.url);
+
+function source(directory, fileName) {
+ return readFileSync(new URL(fileName, directory), 'utf8').replace(/\r\n/g, '\n');
+}
+
+/** One top-level Python function, from its `def` to the next top-level `def`. */
+function serverFunction(moduleSource, name) {
+ const start = moduleSource.indexOf(`\ndef ${name}(`);
+ assert.notEqual(start, -1, `The server no longer defines ${name}().`);
+ const end = moduleSource.indexOf('\ndef ', start + 1);
+ return moduleSource.slice(start, end === -1 ? undefined : end);
+}
+
+const DELIVERY_MODULE = source(SERVER_DIR, 'functions_workflow_chat_delivery.py');
+const WORKER_MODULE = source(SERVER_DIR, 'functions_workflow_chat_delivery_worker.py');
+const NOTIFICATIONS_MODULE = source(SERVER_DIR, 'functions_notifications.py');
+const TRACKER_HOOK = source(CLIENT_DIR, 'lib/useWorkflowRunTracker.ts');
+const CHAT_STORE = source(CLIENT_DIR, 'stores/chatStore.ts');
+
+const SHA = 'a'.repeat(64);
+
+/** The metadata 6b-1 writes on every posted message, for run `runId` of the fixtures. */
+function deliveryMetadata(runId, overrides = {}) {
+ return {
+ version: 1,
+ kind: 'result',
+ workflow_id: `wf-${runId}`,
+ workflow_scope: 'personal',
+ run_id: runId,
+ generation: 2,
+ run_status: 'completed',
+ orchestration_run_id: 'orun-1',
+ step_id: `step-${runId}`,
+ requested_at: at(0),
+ ...overrides,
+ };
+}
+
+/** 6a's descriptor, as a posted result carries it in `metadata.workflow_result`. */
+function resultDescriptor(runId, overrides = {}) {
+ return {
+ version: 'workflow-result-v1',
+ workflow_id: `wf-${runId}`,
+ run_id: runId,
+ result_sha256: SHA,
+ workflow_name: 'Weekly digest',
+ status: 'completed',
+ completed_at: at(250),
+ ...overrides,
+ };
+}
+
+function parsed(metadata) {
+ const value = parseWorkflowDeliveryMetadata(metadata);
+ assert.ok(value, 'The fixture metadata must parse.');
+ return value;
+}
+
+/** A tracker snapshot holding the rows of one status response, as the engine would publish it. */
+function snapshotOf(rows, overrides = {}) {
+ const runs = {};
+ for (const row of parseWorkflowRunStatusResponse(statusResponse(rows)).runs) {
+ runs[row.run_id] ??= { row, checkedAt: at(300), retired: false };
+ }
+ return {
+ running: true,
+ halted: false,
+ available: true,
+ runs,
+ globalCheckedAt: at(300),
+ globalError: false,
+ conversations: {},
+ ...overrides,
+ };
+}
+
+function retire(snapshot, runId) {
+ return {
+ ...snapshot,
+ runs: { ...snapshot.runs, [runId]: { ...snapshot.runs[runId], retired: true } },
+ };
+}
+
+/** A failed run whose note was posted under generation 4, which the server would resume. */
+function retryableFailedRow(runId, overrides = {}) {
+ const { delivery = {}, actions = {}, ...rest } = overrides;
+ return failedRow(runId, {
+ runtime_version: 4,
+ ...rest,
+ delivery: {
+ status: 'delivered',
+ generation: 4,
+ message_id: deliveryMessageId(runId, 4),
+ delivered_at: at(250),
+ ...delivery,
+ },
+ actions: { retry: true, ...actions },
+ });
+}
+
+const failedNote = (runId, overrides = {}) =>
+ parsed(deliveryMetadata(runId, { kind: 'failed', generation: 4, run_status: 'failed', ...overrides }));
+
+test('a posted message is recognised by its id prefix or its metadata key, as the server checks it', () => {
+ const prefixOnly = { id: deliveryMessageId('run-1', 2), metadata: {} };
+ const metadataOnly = { id: 'assistant_123', metadata: { workflow_delivery: {} } };
+ assert.equal(isWorkflowDeliveryMessage(prefixOnly), true, 'The id prefix alone marks a posted message.');
+ assert.equal(isWorkflowDeliveryMessage({ id: deliveryMessageId('run-1', 2) }), true);
+ assert.equal(isWorkflowDeliveryMessage(metadataOnly), true, 'The metadata key alone marks a posted message.');
+ assert.equal(
+ isWorkflowDeliveryMessage({ id: 'assistant_123', metadata: { workflow_delivery: { version: 99 } } }),
+ true,
+ 'Any object under the key counts, as a Mapping does on the server, whatever it holds.',
+ );
+ for (const value of [null, undefined, [], ['workflow'], 'yes', 1, true]) {
+ assert.equal(
+ isWorkflowDeliveryMessage({ id: 'assistant_123', metadata: { workflow_delivery: value } }),
+ false,
+ `A workflow_delivery of ${JSON.stringify(value)} is not a Mapping.`,
+ );
+ }
+ for (const message of [
+ null,
+ undefined,
+ { id: 'assistant_123' },
+ { id: 'assistant_123', metadata: {} },
+ { id: 'assistant_123', metadata: [] },
+ { id: 'assistant_123', metadata: null },
+ { id: `x${deliveryMessageId('run-1', 2)}` },
+ { id: deliveryMessageId('run-1', 2).toUpperCase() },
+ { id: 42, metadata: {} },
+ ]) {
+ assert.equal(isWorkflowDeliveryMessage(message), false, `${JSON.stringify(message)} was not posted by a run.`);
+ }
+
+ const check = serverFunction(DELIVERY_MODULE, 'is_workflow_delivery_message');
+ assert.match(check, /message_id\.startswith\(DELIVERY_MESSAGE_ID_PREFIX\)/);
+ assert.match(check, /isinstance\(metadata\.get\(DELIVERY_METADATA_KEY\), Mapping\)/);
+ assert.match(DELIVERY_MODULE, new RegExp(`\\nDELIVERY_MESSAGE_ID_PREFIX = '${WORKFLOW_DELIVERY_MESSAGE_PREFIX}'\\n`));
+ assert.match(DELIVERY_MODULE, /\nDELIVERY_METADATA_KEY = 'workflow_delivery'\n/);
+});
+
+test('delivery metadata parses exactly as the server writes it, and fails closed on anything else', () => {
+ const metadata = deliveryMetadata('run-1', { extra: 'ignored' });
+ assert.deepEqual(parseWorkflowDeliveryMetadata(metadata), {
+ version: 1,
+ kind: 'result',
+ workflow_id: 'wf-run-1',
+ workflow_scope: 'personal',
+ run_id: 'run-1',
+ generation: 2,
+ run_status: 'completed',
+ orchestration_run_id: 'orun-1',
+ step_id: 'step-run-1',
+ requested_at: at(0),
+ });
+
+ const sparse = deliveryMetadata('run-1');
+ for (const key of ['generation', 'run_status', 'orchestration_run_id', 'step_id', 'requested_at']) {
+ delete sparse[key];
+ }
+ assert.deepEqual(parseWorkflowDeliveryMetadata(sparse), {
+ version: 1,
+ kind: 'result',
+ workflow_id: 'wf-run-1',
+ workflow_scope: 'personal',
+ run_id: 'run-1',
+ generation: null,
+ run_status: null,
+ orchestration_run_id: null,
+ step_id: null,
+ requested_at: null,
+ }, 'The optional fields read as null when the server had none.');
+ assert.equal(parseWorkflowDeliveryMetadata(deliveryMetadata('run-1', { generation: 0 })).generation, 0);
+
+ for (const value of [null, undefined, [], 'workflow', 1]) {
+ assert.equal(parseWorkflowDeliveryMetadata(value), null);
+ }
+ const invalid = {
+ version: [undefined, 2, '1', 0],
+ kind: [undefined, '', ' ', 3, null],
+ workflow_id: [undefined, '', ' wf-1', 'wf-1 ', 'wf\u0001', 'w'.repeat(257), 5, '\ud800'],
+ run_id: [undefined, '', ' run-1', 'run\u001f', 'r'.repeat(257), null],
+ workflow_scope: [undefined, 'group', 'public', 'Personal'],
+ generation: [-1, 1.5, '2', Number.NaN, true],
+ orchestration_run_id: ['', ' orun', 5],
+ step_id: ['', 'step\u0000', 5],
+ run_status: [5, {}],
+ requested_at: [5, []],
+ };
+ for (const [key, values] of Object.entries(invalid)) {
+ for (const value of values) {
+ const candidate = deliveryMetadata('run-1');
+ if (value === undefined) {
+ delete candidate[key];
+ } else {
+ candidate[key] = value;
+ }
+ assert.equal(
+ parseWorkflowDeliveryMetadata(candidate),
+ null,
+ `A ${key} of ${JSON.stringify(value)} must not parse.`,
+ );
+ }
+ }
+ assert.equal(
+ parseWorkflowDeliveryMetadata(deliveryMetadata('run-1', { workflow_id: 'w'.repeat(256) })).workflow_id.length,
+ 256,
+ 'An id of 256 characters is still one the server keeps.',
+ );
+
+ assert.equal(parseWorkflowDeliveryMetadata(deliveryMetadata('run-1', { kind: 'mystery' })).kind, 'unknown');
+ assert.equal(
+ parseWorkflowDeliveryMetadata(deliveryMetadata('run-1', { kind: 'expired' })).kind,
+ 'unknown',
+ 'Expired is only ever a bell notice, so a message claiming it offers Open run only.',
+ );
+ for (const kind of WORKFLOW_DELIVERY_KINDS) {
+ assert.equal(parseWorkflowDeliveryMetadata(deliveryMetadata('run-1', { kind })).kind, kind);
+ }
+
+ assert.deepEqual(
+ readWorkflowDelivery({ id: deliveryMessageId('run-1', 2), metadata: { workflow_delivery: metadata } }),
+ parseWorkflowDeliveryMetadata(metadata),
+ );
+ for (const message of [null, undefined, {}, { metadata: null }, { metadata: [] }, { metadata: {} }]) {
+ assert.equal(readWorkflowDelivery(message), null);
+ }
+});
+
+test('the client\'s delivery kinds and metadata keys are the server\'s', () => {
+ const serverKinds = [...DELIVERY_MODULE.matchAll(/^KIND_[A-Z_]+ = '([a-z_]+)'$/gm)].map((match) => match[1]);
+ assert.ok(serverKinds.length >= 8, 'The server\'s delivery kinds could not be read.');
+ assert.deepEqual(
+ [...WORKFLOW_DELIVERY_KINDS, 'expired'].sort(),
+ [...serverKinds].sort(),
+ 'Every kind the server can post is known here; only `expired`, a notice, is left out.',
+ );
+ assert.match(DELIVERY_MODULE, new RegExp(`\\nCHAT_DELIVERY_VERSION = ${WORKFLOW_DELIVERY_VERSION}\\n`));
+ assert.match(DELIVERY_MODULE, /\nWORKFLOW_SCOPE = 'personal'\n/);
+
+ const builder = serverFunction(DELIVERY_MODULE, 'build_delivery_metadata');
+ const serverKeys = [...builder.matchAll(/^ {8}'([a-z_]+)':/gm)].map((match) => match[1]);
+ assert.deepEqual(
+ [...serverKeys].sort(),
+ Object.keys(parseWorkflowDeliveryMetadata(deliveryMetadata('run-1'))).sort(),
+ 'The client reads every key the server writes, and nothing it doesn\'t.',
+ );
+});
+
+test('Follow up offers a posted result\'s own descriptor, only for a result or analysis of the same run', () => {
+ const metadata = (descriptor) => ({ workflow_delivery: deliveryMetadata('run-1'), workflow_result: descriptor });
+ const result = parsed(deliveryMetadata('run-1'));
+ const analysis = parsed(deliveryMetadata('run-1', { kind: 'analysis' }));
+
+ const followUp = workflowDeliveryFollowUp(result, metadata(resultDescriptor('run-1')));
+ assert.equal(followUp?.run_id, 'run-1');
+ assert.equal(followUp?.workflow_id, 'wf-run-1');
+ assert.equal(followUp?.result_sha256, SHA);
+ assert.equal(followUp?.available, true);
+ assert.equal(workflowDeliveryFollowUp(analysis, metadata(resultDescriptor('run-1')))?.run_id, 'run-1');
+ assert.equal(
+ workflowDeliveryFollowUp(result, metadata(resultDescriptor('run-1', { status: 'completed_partial' })))?.status,
+ 'completed_partial',
+ );
+
+ for (const kind of ['failed', 'cancelled', 'skipped', 'status', 'content_blocked', 'mystery']) {
+ assert.equal(
+ workflowDeliveryFollowUp(parsed(deliveryMetadata('run-1', { kind })), metadata(resultDescriptor('run-1'))),
+ null,
+ `A ${kind} message is not a source, even carrying a descriptor.`,
+ );
+ }
+ for (const descriptor of [
+ undefined,
+ null,
+ resultDescriptor('run-1', { available: false }),
+ resultDescriptor('run-2'),
+ resultDescriptor('run-1', { workflow_id: 'wf-other' }),
+ resultDescriptor('run-1', { status: 'failed' }),
+ resultDescriptor('run-1', { result_sha256: 'A'.repeat(64) }),
+ resultDescriptor('run-1', { version: 'workflow-result-v2' }),
+ ]) {
+ assert.equal(
+ workflowDeliveryFollowUp(result, metadata(descriptor)),
+ null,
+ `${JSON.stringify(descriptor)} is not this run's readable result.`,
+ );
+ }
+ assert.equal(workflowDeliveryFollowUp(result, null), null);
+});
+
+test('a posted message joins the tracker\'s row only for the same chat, workflow, plan run and step', () => {
+ const snapshot = snapshotOf([deliveredRow('run-1', 2, at(250))]);
+ const delivery = parsed(deliveryMetadata('run-1'));
+ assert.equal(workflowDeliveryRun(snapshot, delivery, 'chat-1'), snapshot.runs['run-1']);
+ assert.equal(workflowDeliveryRun(snapshot, delivery, 'chat-2'), undefined, 'Another chat\'s message never joins.');
+ for (const overrides of [
+ { run_id: 'run-9' },
+ { workflow_id: 'wf-other' },
+ { orchestration_run_id: 'orun-2' },
+ { orchestration_run_id: null },
+ { step_id: 'step-other' },
+ { step_id: null },
+ ]) {
+ assert.equal(
+ workflowDeliveryRun(snapshot, parsed(deliveryMetadata('run-1', overrides)), 'chat-1'),
+ undefined,
+ `${JSON.stringify(overrides)} must not join the row.`,
+ );
+ }
+ assert.equal(workflowDeliveryRun(EMPTY_WORKFLOW_RUN_TRACKER_SNAPSHOT, delivery, 'chat-1'), undefined);
+});
+
+test('a failed run\'s note offers Retry only from the newest read of the same run and generation', () => {
+ const snapshot = snapshotOf([retryableFailedRow('run-f')]);
+ const note = failedNote('run-f');
+ assert.equal(workflowDeliveryCanRetry(snapshot, note, 'chat-1'), true);
+
+ // The note, not the row, decides which kinds can retry.
+ for (const kind of ['result', 'analysis', 'cancelled', 'skipped', 'status', 'content_blocked', 'mystery']) {
+ assert.equal(
+ workflowDeliveryCanRetry(snapshot, failedNote('run-f', { kind }), 'chat-1'),
+ false,
+ `A ${kind} message never offers Retry.`,
+ );
+ }
+ assert.equal(workflowDeliveryCanRetry(snapshot, failedNote('run-f', { generation: null }), 'chat-1'), false);
+
+ // A note from an earlier generation never retries a run that has moved on.
+ for (const generation of [3, 5]) {
+ assert.equal(
+ workflowDeliveryCanRetry(snapshot, failedNote('run-f', { generation }), 'chat-1'),
+ false,
+ `A generation ${generation} note doesn't match the row's generation 4.`,
+ );
+ }
+ const reopened = snapshotOf([retryableFailedRow('run-f', { delivery: { status: 'pending', generation: null, message_id: null, delivered_at: null } })]);
+ assert.equal(workflowDeliveryCanRetry(reopened, note, 'chat-1'), false);
+
+ // The tracker must be reading, and chats must be able to start workflows.
+ for (const overrides of [{ running: false }, { halted: true }, { available: false }, { available: null }]) {
+ assert.equal(
+ workflowDeliveryCanRetry({ ...snapshot, ...overrides }, note, 'chat-1'),
+ false,
+ `${JSON.stringify(overrides)} offers no Retry.`,
+ );
+ }
+ assert.equal(workflowDeliveryCanRetry(retire(snapshot, 'run-f'), note, 'chat-1'), false);
+ assert.equal(workflowDeliveryCanRetry(EMPTY_WORKFLOW_RUN_TRACKER_SNAPSHOT, note, 'chat-1'), false);
+
+ // Only the server's `actions.retry` on a failed row offers it, never the status alone.
+ for (const overrides of [
+ { actions: { retry: false } },
+ { actions: { retry: false }, retry_blocked: 'workflow_definition_changed' },
+ { actions: { retry: true }, retry_blocked: 'workflow_already_running' },
+ ]) {
+ assert.equal(
+ workflowDeliveryCanRetry(snapshotOf([retryableFailedRow('run-f', overrides)]), note, 'chat-1'),
+ false,
+ `${JSON.stringify(overrides)} offers no Retry.`,
+ );
+ }
+ const resumed = snapshotOf([statusRow('run-f', {
+ runtime_version: 5,
+ delivery: { status: 'delivered', generation: 4, message_id: deliveryMessageId('run-f', 4), delivered_at: at(250) },
+ actions: { retry: true },
+ })]);
+ assert.equal(workflowDeliveryCanRetry(resumed, note, 'chat-1'), false, 'A resumed run is running again.');
+
+ const unreadable = snapshotOf([retryableFailedRow('run-f', { status: 'mystery' })]);
+ assert.equal(unreadable.runs['run-f'].row.kind, 'unavailable');
+ assert.equal(workflowDeliveryCanRetry(unreadable, note, 'chat-1'), false, 'An unreadable row offers Open run only.');
+
+ // Every part of the join must hold.
+ assert.equal(workflowDeliveryCanRetry(snapshot, note, 'chat-2'), false);
+ for (const overrides of [{ workflow_id: 'wf-other' }, { orchestration_run_id: 'orun-2' }, { step_id: 'step-other' }]) {
+ assert.equal(workflowDeliveryCanRetry(snapshot, failedNote('run-f', overrides), 'chat-1'), false);
+ }
+});
+
+test('a posted result settles as a workflow reply, keyed by the posted message', () => {
+ const [row] = parseWorkflowRunStatusResponse(statusResponse([deliveredRow('run-1', 2, at(250))])).runs;
+ assert.deepEqual(workflowDeliveryReply(row, 'Planning the offsite'), {
+ conversationId: 'chat-1',
+ messageId: deliveryMessageId('run-1', 2),
+ runId: 'run-1',
+ conversationTitle: 'Planning the offsite',
+ blocked: false,
+ source: 'workflow',
+ });
+ assert.equal(workflowDeliveryReply(row, null).conversationTitle, null);
+ assert.equal(workflowDeliveryFooterId(row.delivery.message_id), `workflow-delivery-${deliveryMessageId('run-1', 2)}`);
+
+ // The shell settles it through the chat store's one path, which the server marked unread.
+ assert.match(
+ TRACKER_HOOK,
+ /settleCompletedReply\(workflowDeliveryReply\(row, [^)]*\), \{ current, serverMarksUnread: true \}\)/,
+ );
+ assert.doesNotMatch(TRACKER_HOOK, /announceCompletedReply|setConversationUnread|markWatchedReplyRead|deferReplyRead/);
+ assert.doesNotMatch(TRACKER_HOOK, /source: '(chat|orchestration)'/);
+ assert.doesNotMatch(TRACKER_HOOK, /\bfetch\(|\bapi\.(get|post|put|patch|delete)\(/, 'The hook adds no request of its own.');
+ assert.match(CHAT_STORE, /export function settleCompletedReply\(/);
+ assert.equal(CHAT_STORE.match(/announceCompletedReply\(/g)?.length, 1, 'The store announces replies in one place.');
+});
+
+test('the open chat is re-read only once it is quiet', () => {
+ const quiet = { streaming: false, messagesLoading: false, orchestrationActive: false };
+ assert.equal(workflowDeliveryMustWait(quiet), false);
+ for (const key of Object.keys(quiet)) {
+ assert.equal(workflowDeliveryMustWait({ ...quiet, [key]: true }), true, `${key} must hold the re-read.`);
+ }
+ assert.match(TRACKER_HOOK, /orchestrationActive: hasActiveOrchestration\(conversationId\)/);
+ assert.match(TRACKER_HOOK, /planWorkflowDeliveryLanding\(waitingDeliveries, openId, openId !== null && chatIsBusy\(openId\)\)/);
+ // The re-read is guarded where it is applied, and only this path asks for the guard.
+ assert.match(TRACKER_HOOK, /await useChatStore\.getState\(\)\.reloadMessages\(\{ onlyIfUnchanged: true \}\);/);
+ assert.match(CHAT_STORE, /if \(options\?\.onlyIfUnchanged && \(get\(\)\.streaming \|\| get\(\)\.messages !== shownBefore\)\) \{\s*return 'superseded';/);
+ assert.equal(CHAT_STORE.match(/onlyIfUnchanged: true/g)?.length ?? 0, 0, 'No store re-read asks for the guard.');
+ assert.match(TRACKER_HOOK, /if \(superseded\) \{\s*if \(epoch === deliveryEpoch\) \{\s*waitingDeliveries\.unshift\(\.\.\.rows\);/);
+});
+
+test('each posted result lands in its own chat: another chat\'s now, the open chat\'s after one quiet re-read', () => {
+ const rows = [
+ { conversation_id: 'chat-1', run_id: 'a' },
+ { conversation_id: 'chat-2', run_id: 'b' },
+ { conversation_id: 'chat-1', run_id: 'c' },
+ { conversation_id: 'chat-3', run_id: 'd' },
+ ];
+ const ids = (list) => list.map((row) => row.run_id);
+ const plan = (openId, busy) => {
+ const landing = planWorkflowDeliveryLanding(rows, openId, busy);
+ return { elsewhere: ids(landing.elsewhere), reloadNow: ids(landing.reloadNow), waiting: ids(landing.waiting) };
+ };
+
+ assert.deepEqual(plan('chat-1', false), { elsewhere: ['b', 'd'], reloadNow: ['a', 'c'], waiting: [] });
+ assert.deepEqual(
+ plan('chat-1', true),
+ { elsewhere: ['b', 'd'], reloadNow: [], waiting: ['a', 'c'] },
+ 'A busy open chat is never re-read mid-stream.',
+ );
+ for (const busy of [false, true]) {
+ assert.deepEqual(plan(null, busy), { elsewhere: ['a', 'b', 'c', 'd'], reloadNow: [], waiting: [] });
+ assert.deepEqual(plan('chat-9', busy), { elsewhere: ['a', 'b', 'c', 'd'], reloadNow: [], waiting: [] });
+ }
+ assert.deepEqual(planWorkflowDeliveryLanding([], 'chat-1', false), { elsewhere: [], reloadNow: [], waiting: [] });
+});
+
+test('the chat list\'s running tag reads only the tracker\'s state, and gives way once a result is posted', () => {
+ const snapshot = snapshotOf([
+ statusRow('run-b', { requested_at: at(20), workflow_name: 'Inbox sweep' }),
+ statusRow('run-a', { requested_at: at(20) }),
+ statusRow('run-c', { requested_at: at(10), status: 'completed', phase: 'finished', completed_at: at(200) }),
+ deliveredRow('run-d', 1, at(250)),
+ statusRow('run-e', { status: 'mystery' }),
+ statusRow('run-f', { conversation_id: 'chat-2', workflow_name: 'Digest for chat two' }),
+ statusRow('run-g', { status: 'completed', phase: 'finished', completed_at: at(200), delivery: { status: 'undeliverable', generation: 2, reason: 'chat_unavailable' } }),
+ statusRow('run-h'),
+ ]);
+ const inFlight = retire(snapshot, 'run-h');
+
+ assert.deepEqual(
+ workflowRunsInFlight(inFlight, 'chat-1').map((tracked) => tracked.row.run_id),
+ ['run-c', 'run-a', 'run-b'],
+ 'Running, or finished and still being posted; oldest request first, then by run id.',
+ );
+ assert.equal(workflowRunningLabel(inFlight, 'chat-1'), 'Running 3 workflows');
+ assert.equal(workflowRunningLabel(inFlight, 'chat-2'), 'Running Digest for chat two');
+ assert.equal(workflowRunningLabel(inFlight, 'chat-3'), '');
+ for (const conversationId of [null, undefined, '']) {
+ assert.deepEqual(workflowRunsInFlight(inFlight, conversationId), []);
+ assert.equal(workflowRunningLabel(inFlight, conversationId), '');
+ }
+
+ const single = snapshotOf([statusRow('run-1'), deliveredRow('run-2', 1, at(250))]);
+ assert.equal(workflowRunningTagLabel(single, 'chat-1'), 'Running Weekly digest');
+ const posted = snapshotOf([deliveredRow('run-1', 1, at(250)), deliveredRow('run-2', 1, at(250))]);
+ assert.equal(workflowRunningTagLabel(posted, 'chat-1'), '', 'A posted result leaves the unread dot alone.');
+ assert.equal(workflowRunningTagLabel({ ...single, halted: true }, 'chat-1'), '');
+ assert.equal(workflowRunningTagLabel({ ...single, running: false }, 'chat-1'), '');
+ assert.equal(workflowRunningTagLabel(EMPTY_WORKFLOW_RUN_TRACKER_SNAPSHOT, 'chat-1'), '');
+});
+
+test('a plan answer finds its own runs, and each step its own run, or nothing', () => {
+ const snapshot = snapshotOf([
+ statusRow('run-2', { requested_at: at(20) }),
+ statusRow('run-1', { requested_at: at(10) }),
+ deliveredRow('run-3', 1, at(250), { requested_at: at(30) }),
+ statusRow('run-4', { orchestration_run_id: 'orun-2' }),
+ statusRow('run-5', { conversation_id: 'chat-2' }),
+ ]);
+ assert.deepEqual(
+ workflowRunsForAnswer(snapshot, 'chat-1', 'orun-1').map((tracked) => tracked.row.run_id),
+ ['run-1', 'run-2', 'run-3'],
+ );
+ for (const [conversationId, orchestrationRunId] of [[null, 'orun-1'], ['chat-1', null], ['chat-1', undefined]]) {
+ assert.deepEqual(workflowRunsForAnswer(snapshot, conversationId, orchestrationRunId), []);
+ }
+
+ const step = { stepId: 'step-run-1', workflowId: 'wf-run-1', runId: 'run-1' };
+ assert.equal(workflowRunForAnswerStep(snapshot, 'chat-1', 'orun-1', step), snapshot.runs['run-1']);
+ for (const [conversationId, orchestrationRunId, stepOverrides] of [
+ ['chat-2', 'orun-1', {}],
+ ['chat-1', 'orun-2', {}],
+ [null, 'orun-1', {}],
+ ['chat-1', null, {}],
+ ['chat-1', 'orun-1', { stepId: 'step-run-2' }],
+ ['chat-1', 'orun-1', { workflowId: 'wf-run-2' }],
+ ['chat-1', 'orun-1', { runId: 'run-9' }],
+ ]) {
+ assert.equal(
+ workflowRunForAnswerStep(snapshot, conversationId, orchestrationRunId, { ...step, ...stepOverrides }),
+ undefined,
+ `${JSON.stringify([conversationId, orchestrationRunId, stepOverrides])} must not match.`,
+ );
+ }
+
+ assert.equal(trackedWorkflowRun(snapshot, 'run-1'), snapshot.runs['run-1']);
+ for (const runId of [null, undefined, '', 'run-9']) {
+ assert.equal(trackedWorkflowRun(snapshot, runId), undefined);
+ }
+});
+
+test('a chat\'s own reads and the newest check time come from the tracker\'s state', () => {
+ const read = { checkedAt: at(120), reading: false, error: null };
+ const snapshot = { ...EMPTY_WORKFLOW_RUN_TRACKER_SNAPSHOT, conversations: { 'chat-1': read } };
+ assert.equal(workflowRunConversationRead(snapshot, 'chat-1'), read);
+ for (const conversationId of ['chat-2', null, undefined, '']) {
+ assert.deepEqual(workflowRunConversationRead(snapshot, conversationId), { checkedAt: null, reading: false, error: null });
+ }
+
+ assert.equal(workflowRunsCheckedAt(EMPTY_WORKFLOW_RUN_TRACKER_SNAPSHOT, 'chat-1'), null);
+ assert.equal(workflowRunsCheckedAt({ ...snapshot, globalCheckedAt: null }, 'chat-1'), at(120));
+ assert.equal(workflowRunsCheckedAt({ ...snapshot, globalCheckedAt: at(60) }, 'chat-1'), at(120));
+ assert.equal(workflowRunsCheckedAt({ ...snapshot, globalCheckedAt: at(180) }, 'chat-1'), at(180));
+ assert.equal(workflowRunsCheckedAt({ ...snapshot, globalCheckedAt: at(180) }, 'chat-2'), at(180));
+
+ assert.equal(Object.isFrozen(EMPTY_WORKFLOW_RUN_TRACKER_SNAPSHOT), true);
+ assert.equal(EMPTY_WORKFLOW_RUN_TRACKER_SNAPSHOT.running, false);
+ assert.equal(EMPTY_WORKFLOW_RUN_TRACKER_SNAPSHOT.available, null);
+ const published = snapshotOf([statusRow('run-1')]);
+ useWorkflowRunTrackerStore.getState().publish(published);
+ assert.equal(useWorkflowRunTrackerStore.getState().snapshot, published);
+ useWorkflowRunTrackerStore.getState().reset();
+ assert.equal(useWorkflowRunTrackerStore.getState().snapshot, EMPTY_WORKFLOW_RUN_TRACKER_SNAPSHOT);
+});
+
+test('the bell names 6b-1\'s notice as workflow results, in the tone the server gives it', () => {
+ const notice = (overrides = {}) => normalizeNotification({
+ id: 'notice-1',
+ notification_type: 'workflow_chat_delivery',
+ title: '"Weekly digest" didn\'t finish',
+ message: 'The chat that started this run can\'t show it anymore. Open the run in Workflows to see the details.',
+ type_config: { icon: 'bi-activity', color: 'info' },
+ link_url: '/workflow-activity?workflowId=wf-1&runId=run-1&scope=personal',
+ metadata: { workflow_id: 'wf-1', run_id: 'run-1', workflow_scope: 'personal', delivery_status: 'undeliverable' },
+ ...overrides,
+ });
+ assert.deepEqual(describeNotification(notice()), { kind: 'workflow', label: 'Workflow results', tone: 'info' });
+ assert.deepEqual(
+ describeNotification(notice({ category: 'failure' })),
+ { kind: 'workflow', label: 'Workflow results', tone: 'info' },
+ 'Unlike a priority alert, the label never depends on a category.',
+ );
+ assert.deepEqual(
+ describeNotification(notice({ notification_type: 'chat_response_complete', type_config: { color: 'success' } })),
+ { kind: 'reply', label: 'AI responded', tone: 'ok' },
+ 'A posted result\'s own notice is the ordinary reply notice.',
+ );
+
+ assert.match(NOTIFICATIONS_MODULE, /\nWORKFLOW_CHAT_DELIVERY_NOTIFICATION_TYPE = 'workflow_chat_delivery'\n/);
+ assert.match(
+ NOTIFICATIONS_MODULE,
+ /\n {4}WORKFLOW_CHAT_DELIVERY_NOTIFICATION_TYPE: \{\n {8}'icon': 'bi-activity',\n {8}'color': 'info'\n {4}\},/,
+ );
+ assert.match(DELIVERY_MODULE, /\nNOTIFICATION_TYPE = 'workflow_chat_delivery'\n/);
+ assert.match(serverFunction(WORKER_MODULE, '_send_notice'), /notification_type=NOTIFICATION_TYPE,/);
+ assert.match(serverFunction(WORKER_MODULE, '_send_chat_notice'), /\.create_chat_response_notification\(/);
+});
diff --git a/functional_tests/test_v2_workflow_run_action_clients.mjs b/functional_tests/test_v2_workflow_run_action_clients.mjs
new file mode 100644
index 000000000..9d2aa3823
--- /dev/null
+++ b/functional_tests/test_v2_workflow_run_action_clients.mjs
@@ -0,0 +1,439 @@
+// test_v2_workflow_run_action_clients.mjs
+// Version: 0.261.251
+// Implemented in: 0.261.251
+// Executes Cancel and Retry for a run a chat started, against a fetch that records every request.
+// Retry reads the run's runtime fresh, then resumes it with exactly {expected_version, request_id}:
+// the version that read returned and a new UUID each attempt. It sends nothing when the runtime says
+// the run can't be resumed or the read fails. Cancel is the run-level cancel with no body. Neither
+// ever reaches /resume-failed or the workflow-level /cancel. Every refusal reads as a fixed sentence
+// chosen by status and code, never the server's own words. The routes, request keys, refusal codes
+// and texts are pinned against the server modules that answer them.
+
+import assert from 'node:assert/strict';
+import { readdirSync, readFileSync } from 'node:fs';
+import { join, relative } from 'node:path';
+import test from 'node:test';
+import { fileURLToPath } from 'node:url';
+import './test_support/tsResolve.mjs';
+
+const calls = [];
+const allCalls = [];
+let script = [];
+
+async function recordingFetch(url, init = {}) {
+ const call = {
+ url: String(url),
+ method: init.method ?? 'GET',
+ headers: { ...(init.headers ?? {}) },
+ body: init.body,
+ credentials: init.credentials,
+ };
+ calls.push(call);
+ allCalls.push(call);
+ assert.ok(script.length > 0, `Unexpected request: ${call.method} ${call.url}`);
+ const next = script.shift();
+ if (next instanceof Error) {
+ throw next;
+ }
+ return next;
+}
+
+globalThis.fetch = recordingFetch;
+
+// The repository resolver must be registered before extensionless TypeScript imports load.
+const {
+ WORKFLOW_CANCEL_FAILED_TEXT,
+ WORKFLOW_CANCEL_REQUESTED_TEXT,
+ WORKFLOW_RETRY_FAILED_TEXT,
+ WORKFLOW_RETRY_REQUESTED_TEXT,
+ cancelWorkflowRun,
+ retryWorkflowRun,
+} = await import('../application/v2_ui/src/lib/workflowRunActions.ts');
+const { WORKFLOW_RETRY_BLOCKED_CODES, workflowRetryBlockedText } = await import(
+ '../application/v2_ui/src/lib/workflowRunStatus.ts'
+);
+
+const TARGET = { workflowId: 'wf-1', runId: 'run-1' };
+const RUNTIME_PATH = '/api/user/workflows/wf-1/runs/run-1/runtime';
+const RESUME_PATH = '/api/user/workflows/wf-1/runs/run-1/runtime/resume';
+const CANCEL_PATH = '/api/user/workflows/wf-1/runs/run-1/cancel';
+const UUID_V4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
+// A sentence no person should read: every refusal below carries it, and no outcome may repeat it.
+const SERVER_SENTENCE = 'Server sentence meant for logs';
+
+const RUN_GONE = 'This run is no longer available.';
+const NO_ACCESS = "You don't have access to this run.";
+const UNAVAILABLE = "Workflows aren't available right now. Try again later.";
+const REJECTED = "The retry request wasn't accepted.";
+const UNCONFIRMED = "Couldn't confirm the retry. Check its status.";
+const NOT_RESUMABLE = "This run can't be retried anymore.";
+const CANCEL_CONFLICT = 'This run already finished or changed. Check its status.';
+const CONFLICT_FALLBACK = "This run can't be retried right now. Check its status.";
+
+function reset(...responses) {
+ calls.length = 0;
+ script = responses;
+}
+
+function json(status, body) {
+ return new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });
+}
+
+/** A runtime answer as the server sends it, defaulting to a failed run that can be resumed. */
+function runtime(overrides = {}) {
+ return json(200, { runtime: { version: 7, state: 'failed', can_resume: true, gate: null, ...overrides }, can_decide: true });
+}
+
+/** The runtime a successful resume answers with: queued, one version on. */
+function resumed(version = 8) {
+ return runtime({ version, state: 'queued', can_resume: false });
+}
+
+function refusal(status, extra = {}) {
+ return json(status, { error: SERVER_SENTENCE, ...extra });
+}
+
+function assertNoServerText(outcome) {
+ assert.ok(!outcome.text.includes(SERVER_SENTENCE), `The outcome repeated the server's sentence: ${outcome.text}`);
+ assert.ok(!/[<>]/.test(outcome.text), `The outcome carries markup: ${outcome.text}`);
+}
+
+function assertRead(call, path = RUNTIME_PATH) {
+ assert.equal(call.method, 'GET');
+ assert.equal(call.url, path);
+ assert.equal(call.body, undefined);
+ assert.equal(call.credentials, 'same-origin');
+}
+
+function assertResume(call, expectedVersion, path = RESUME_PATH) {
+ assert.equal(call.method, 'POST');
+ assert.equal(call.url, path);
+ assert.equal(call.headers['Content-Type'], 'application/json');
+ assert.equal(call.credentials, 'same-origin');
+ const body = JSON.parse(call.body);
+ assert.deepEqual(Object.keys(body).sort(), ['expected_version', 'request_id']);
+ assert.equal(body.expected_version, expectedVersion);
+ assert.match(body.request_id, UUID_V4);
+ return body;
+}
+
+function serverSource(name) {
+ return readFileSync(new URL(name, new URL('../application/single_app/', import.meta.url)), 'utf8')
+ .replace(/\r\n/g, '\n');
+}
+
+function serverFunction(source, name) {
+ const start = source.indexOf(`\ndef ${name}(`);
+ assert.ok(start >= 0, `The server no longer defines ${name}.`);
+ const end = source.indexOf('\ndef ', start + 1);
+ return source.slice(start, end < 0 ? undefined : end);
+}
+
+function serverMethod(source, name) {
+ const start = source.indexOf(`\n def ${name}(`);
+ assert.ok(start >= 0, `The server no longer defines the method ${name}.`);
+ const ends = ['\n def ', '\nclass ', '\ndef ']
+ .map((marker) => source.indexOf(marker, start + 1))
+ .filter((index) => index > 0);
+ return source.slice(start, ends.length ? Math.min(...ends) : undefined);
+}
+
+/** One route's decorators and body, up to the next route the blueprint declares. */
+function serverRoute(source, path) {
+ const marker = `@bp.route('${path}', methods=['POST'])`;
+ const start = source.indexOf(marker);
+ assert.ok(start >= 0, `The server no longer declares POST ${path}.`);
+ const end = source.indexOf('@bp.route(', start + marker.length);
+ return source.slice(start, end < 0 ? undefined : end);
+}
+
+test('Retry reads the runtime, then resumes from exactly the version that read returned', async () => {
+ reset(runtime({ version: 7 }), resumed(8));
+ const first = await retryWorkflowRun(TARGET);
+
+ assert.deepEqual(first, { ok: true, text: WORKFLOW_RETRY_REQUESTED_TEXT });
+ assert.equal(calls.length, 2);
+ assertRead(calls[0]);
+ const firstBody = assertResume(calls[1], 7);
+
+ // The status row's runtime_version (3 in the shared fixtures) is never the version sent: each
+ // attempt reads the runtime again, and a second attempt sends a new request id.
+ reset(runtime({ version: 11 }), resumed(12));
+ const second = await retryWorkflowRun(TARGET);
+
+ assert.deepEqual(second, { ok: true, text: WORKFLOW_RETRY_REQUESTED_TEXT });
+ assertRead(calls[0]);
+ const secondBody = assertResume(calls[1], 11);
+ assert.notEqual(secondBody.request_id, firstBody.request_id);
+});
+
+test('Retry and Cancel encode the workflow and run ids in every path', async () => {
+ const target = { workflowId: 'wf 1/x', runId: 'run?1#2' };
+ const runtimePath = '/api/user/workflows/wf%201%2Fx/runs/run%3F1%232/runtime';
+
+ reset(runtime({ version: 2 }), resumed(3));
+ assert.equal((await retryWorkflowRun(target)).ok, true);
+ assertRead(calls[0], runtimePath);
+ assertResume(calls[1], 2, `${runtimePath}/resume`);
+
+ reset(json(202, { success: true }));
+ assert.equal((await cancelWorkflowRun(target)).ok, true);
+ assert.equal(calls[0].url, '/api/user/workflows/wf%201%2Fx/runs/run%3F1%232/cancel');
+});
+
+test('Retry sends nothing when the runtime says the run cannot be resumed', async () => {
+ const unresumable = [
+ ['can_resume is false', { can_resume: false }],
+ ['can_resume is missing', { can_resume: undefined }],
+ ['the run completed', { state: 'completed' }],
+ ['the run was cancelled', { state: 'cancelled' }],
+ ['the run was skipped', { state: 'skipped' }],
+ ['the run is still running', { state: 'running' }],
+ ['the run is queued', { state: 'queued' }],
+ ['a Microsoft 365 sign-in holds the run', { gate: { id: 'gate-1', reason_code: 'm365_authorization', choices: ['cancel'] } }],
+ ];
+ for (const [label, overrides] of unresumable) {
+ reset(runtime(overrides));
+ const outcome = await retryWorkflowRun(TARGET);
+ assert.deepEqual(outcome, { ok: false, text: NOT_RESUMABLE }, label);
+ assert.equal(calls.length, 1, `${label}: only the read is sent`);
+ assertRead(calls[0]);
+ }
+
+ // A Repeat limit pause is never a failed run the runtime would resume; either way nothing is sent.
+ reset(runtime({ gate: { id: 'gate-2', reason_code: 'repeat_iteration_limit', choices: ['continue_repeat', 'cancel'] } }));
+ const repeat = await retryWorkflowRun(TARGET);
+ assert.equal(repeat.ok, false);
+ assert.equal(calls.length, 1);
+});
+
+test('Retry sends nothing when the runtime has no version it can resume from', async () => {
+ for (const version of [-1, 1.5, '7', null, undefined, Number.MAX_SAFE_INTEGER + 2, Number.NaN]) {
+ reset(runtime({ version }));
+ const outcome = await retryWorkflowRun(TARGET);
+ assert.deepEqual(outcome, { ok: false, text: WORKFLOW_RETRY_FAILED_TEXT }, `version ${String(version)}`);
+ assert.equal(calls.length, 1);
+ }
+});
+
+test('A failed runtime read sends nothing and reads as a fixed sentence', async () => {
+ const reads = [
+ [refusal(403), NO_ACCESS],
+ [json(404, { error: 'Workflow not found.' }), RUN_GONE],
+ [json(404, { error: 'Durable workflow run not found.' }), RUN_GONE],
+ [refusal(503), UNAVAILABLE],
+ [refusal(500), WORKFLOW_RETRY_FAILED_TEXT],
+ [refusal(409, { code: 'invalid_state' }), WORKFLOW_RETRY_FAILED_TEXT],
+ [new TypeError('fetch failed'), WORKFLOW_RETRY_FAILED_TEXT],
+ // A 200 V2 can't read: no runtime at all.
+ [json(200, { can_decide: true }), WORKFLOW_RETRY_FAILED_TEXT],
+ ];
+ for (const [answer, text] of reads) {
+ reset(answer);
+ const outcome = await retryWorkflowRun(TARGET);
+ assert.deepEqual(outcome, { ok: false, text });
+ assertNoServerText(outcome);
+ assert.equal(calls.length, 1, 'Nothing is resumed after a failed read.');
+ }
+});
+
+test('Each resume refusal reads as its own fixed sentence', async () => {
+ const blocked = (code) => workflowRetryBlockedText(code);
+ const refusals = [
+ [409, { code: 'workflow_definition_changed' }, blocked('workflow_definition_changed')],
+ [409, { code: 'workflow_already_running' }, blocked('workflow_already_running')],
+ [409, { code: 'workflow_deleted' }, blocked('workflow_deleted')],
+ [409, { code: 'deadline_exceeded' }, blocked('deadline_exceeded')],
+ [409, { code: 'workflow_deleting' }, "The workflow is being deleted, so this run can't be retried."],
+ [409, { code: 'stale_version' }, 'The run changed since it was checked. Check its status and try again.'],
+ [409, { code: 'invalid_state' }, "This run can't be retried in its current state."],
+ [409, { code: 'request_conflict' }, 'Another request changed this run. Check its status and try again.'],
+ [409, { code: 'etag_conflict' }, CONFLICT_FALLBACK],
+ [409, { code: 'tombstoned' }, CONFLICT_FALLBACK],
+ [409, { code: 'Stale_Version' }, CONFLICT_FALLBACK],
+ [409, { code: 42 }, CONFLICT_FALLBACK],
+ [409, {}, CONFLICT_FALLBACK],
+ [400, {}, REJECTED],
+ [403, {}, NO_ACCESS],
+ [404, {}, RUN_GONE],
+ [503, {}, UNAVAILABLE],
+ [500, {}, WORKFLOW_RETRY_FAILED_TEXT],
+ [502, {}, WORKFLOW_RETRY_FAILED_TEXT],
+ ];
+ for (const [status, extra, text] of refusals) {
+ reset(runtime({ version: 5 }), refusal(status, extra));
+ const outcome = await retryWorkflowRun(TARGET);
+ assert.deepEqual(outcome, { ok: false, text }, `${status} ${JSON.stringify(extra)}`);
+ assertNoServerText(outcome);
+ assert.equal(calls.length, 2);
+ assertResume(calls[1], 5);
+ }
+
+ // Each code a status row can also block Retry with reads the same from a refused resume.
+ for (const code of WORKFLOW_RETRY_BLOCKED_CODES) {
+ reset(runtime({ version: 5 }), refusal(409, { code }));
+ assert.equal((await retryWorkflowRun(TARGET)).text, blocked(code));
+ }
+
+ // A 409 whose answer isn't JSON still reads as the fallback, never as its body.
+ reset(runtime({ version: 5 }), new Response(SERVER_SENTENCE, { status: 409, headers: { 'content-type': 'text/html' } }));
+ const html = await retryWorkflowRun(TARGET);
+ assert.deepEqual(html, { ok: false, text: CONFLICT_FALLBACK });
+});
+
+test('Both 404 answers a resume can give read as a run that is gone', async () => {
+ for (const error of ['Workflow not found.', 'Durable workflow run not found.']) {
+ reset(runtime(), json(404, { error }));
+ assert.deepEqual(await retryWorkflowRun(TARGET), { ok: false, text: RUN_GONE });
+ }
+});
+
+test('A resume with no answer reads as failed; one V2 cannot read may have gone through', async () => {
+ reset(runtime(), new TypeError('fetch failed'));
+ assert.deepEqual(await retryWorkflowRun(TARGET), { ok: false, text: WORKFLOW_RETRY_FAILED_TEXT });
+ assert.equal(calls.length, 2);
+
+ reset(runtime(), json(200, { can_decide: true }));
+ assert.deepEqual(await retryWorkflowRun(TARGET), { ok: false, text: UNCONFIRMED });
+
+ reset(runtime(), new Response('ok', { status: 200, headers: { 'content-type': 'text/html' } }));
+ assert.deepEqual(await retryWorkflowRun(TARGET), { ok: false, text: UNCONFIRMED });
+});
+
+test('Cancel posts the run-level cancel with no body', async () => {
+ reset(json(202, { success: true, workflow: { id: 'wf-1' }, run: { id: 'run-1', status: 'cancelling' } }));
+ const outcome = await cancelWorkflowRun(TARGET);
+
+ assert.deepEqual(outcome, { ok: true, text: WORKFLOW_CANCEL_REQUESTED_TEXT });
+ assert.equal(calls.length, 1);
+ assert.equal(calls[0].method, 'POST');
+ assert.equal(calls[0].url, CANCEL_PATH);
+ assert.equal(calls[0].body, undefined);
+ assert.equal(calls[0].headers['Content-Type'], undefined);
+ assert.equal(calls[0].credentials, 'same-origin');
+
+ reset(new Response(null, { status: 204 }));
+ assert.deepEqual(await cancelWorkflowRun(TARGET), { ok: true, text: WORKFLOW_CANCEL_REQUESTED_TEXT });
+});
+
+test('Each cancel refusal reads as a fixed sentence', async () => {
+ const refusals = [
+ [json(404, { error: 'Workflow not found.' }), RUN_GONE],
+ [json(404, { error: 'Workflow run not found.' }), RUN_GONE],
+ [json(409, { error: 'Workflow run cancellation conflict.' }), CANCEL_CONFLICT],
+ [refusal(409, { code: 'invalid_state' }), CANCEL_CONFLICT],
+ [refusal(500), WORKFLOW_CANCEL_FAILED_TEXT],
+ [refusal(403), WORKFLOW_CANCEL_FAILED_TEXT],
+ [refusal(503), WORKFLOW_CANCEL_FAILED_TEXT],
+ [new TypeError('fetch failed'), WORKFLOW_CANCEL_FAILED_TEXT],
+ ];
+ for (const [answer, text] of refusals) {
+ reset(answer);
+ const outcome = await cancelWorkflowRun(TARGET);
+ assert.deepEqual(outcome, { ok: false, text });
+ assertNoServerText(outcome);
+ assert.equal(calls.length, 1);
+ assert.equal(calls[0].url, CANCEL_PATH);
+ }
+});
+
+test('The resume route takes exactly the two keys V2 sends and answers with the codes V2 reads', () => {
+ const routes = serverSource('route_backend_workflows.py');
+ const resumeRoute = serverRoute(routes, '/api/user/workflows//runs//runtime/resume');
+ assert.match(resumeRoute, /@enabled_required\('allow_user_workflows'\)\n\s+@workflow_user_required\n/);
+ assert.match(resumeRoute, /return _workflow_runtime_response\(workflow_id, run_id, action='resume'\)/);
+
+ const response = serverFunction(routes, '_workflow_runtime_response');
+ assert.ok(response.includes("allowed = {'expected_version', 'request_id'} | ({'gate_id', 'choice'} if action == 'decision' else set())"));
+ assert.ok(response.includes("if not isinstance(data, dict) or data.keys() - allowed:"));
+ assert.ok(response.includes("return jsonify({'error': exc.public_message, 'code': exc.code}), 409"));
+ assert.ok(response.includes("return jsonify({'error': 'Workflow not found.'}), 404"));
+ assert.ok(response.includes("return jsonify({'error': 'Durable workflow run not found.'}), 404"));
+ assert.ok(response.includes("return jsonify({'error': 'Invalid workflow decision.'}), 400"));
+ assert.ok(response.includes("return jsonify({'error': 'Invalid workflow decision or request identifier.'}), 400"));
+ // Every answer to the outer PermissionError, the saved-record branch included, is a 403, which V2 reads as no access.
+ const permission = response.match(/\n except PermissionError(?: as \w+)?:\n((?:[ \t]{5,}.*\n|[ \t]*\n)+)/);
+ assert.ok(permission, 'The runtime response no longer handles PermissionError.');
+ const permissionAnswers = permission[1].match(/^[ \t]+return .*$/gm) || [];
+ assert.ok(permissionAnswers.length > 0, 'The PermissionError branch no longer answers.');
+ for (const answer of permissionAnswers) {
+ assert.match(answer, /^[ \t]+return jsonify\(\{'error': .+\}\), 403$/, `A PermissionError answer isn't a 403: ${answer.trim()}`);
+ }
+ assert.ok(response.includes("return jsonify({'error': 'Workflow progress is temporarily unavailable.'}), 503"));
+
+ // The resume itself refuses with the codes the client names.
+ const runtimeModule = serverSource('functions_workflow_runtime.py');
+ const decide = serverFunction(runtimeModule, 'decide_workflow_runtime');
+ assert.ok(decide.includes('raise WorkflowRuntimeConflict("workflow_definition_changed")'));
+ assert.ok(decide.includes('raise WorkflowRuntimeConflict("workflow_already_running")'));
+ assert.ok(decide.includes('request_id = str(uuid.UUID(data.get("request_id", "")))'));
+ const authorize = serverFunction(runtimeModule, '_authorize_execution');
+ assert.ok(authorize.includes('raise WorkflowRuntimeConflict("workflow_deleting"'));
+
+ const journal = serverMethod(serverSource('functions_workflow_journal.py'), 'journal_request');
+ for (const code of ['request_conflict', 'stale_version', 'invalid_state', 'deadline_exceeded']) {
+ assert.ok(journal.includes(`self._journal_conflict("${code}")`), `journal_request no longer refuses with ${code}`);
+ }
+ assert.match(journal, /if type\(expected_version\) is not int or expected_version != control\["version"\]:/);
+
+ const store = serverSource('functions_workflow_runtime_store.py');
+ const resumable = store.match(/\nRESUMABLE_STATES = frozenset\(\{([^}]*)\}\)/);
+ assert.ok(resumable, 'The runtime store no longer declares RESUMABLE_STATES.');
+ assert.deepEqual(resumable[1].match(/[a-z_]+/g).sort(), ['failed', 'incomplete', 'invalid']);
+ const conflict = serverMethod(serverSource('functions_workflow_journal.py'), '_journal_conflict');
+ assert.ok(conflict.includes('raise WorkflowRuntimeConflict(code)'));
+});
+
+test('The run-level cancel route answers 202, 404 or a code-less 409', () => {
+ const routes = serverSource('route_backend_workflows.py');
+ const cancel = serverRoute(routes, '/api/user/workflows//runs//cancel');
+ assert.match(cancel, /@enabled_required\('allow_user_workflows'\)\n\s+@workflow_user_required\n/);
+ assert.ok(cancel.includes('_request_workflow_run_cancellation('));
+ assert.ok(cancel.includes("return jsonify({'error': 'Workflow not found.'}), 404"));
+ assert.ok(cancel.includes("return jsonify({'error': 'Workflow run not found.'}), 404"));
+ assert.ok(cancel.includes("return jsonify({'error': 'Workflow run cancellation conflict.'}), 409"));
+ assert.ok(cancel.includes("return jsonify({'success': True, 'workflow': updated_workflow, 'run': run_record}), 202"));
+ assert.ok(!cancel.includes("'code'"), 'The cancel route started sending a code; read it in cancelWorkflowRun.');
+
+ // A durable run, which every chat-started run is, cancels through the durable runtime.
+ const request = serverFunction(routes, '_request_workflow_run_cancellation');
+ assert.ok(request.includes("if run_record and run_record.get('durable_execution') is True:"));
+ assert.ok(request.includes('cancel_durable_workflow_run(workflow, target_run_id, actor_user_id=requested_by)'));
+});
+
+test('/resume-failed refuses durable runs, and no V2 module calls it', () => {
+ const routes = serverSource('route_backend_workflows.py');
+ const resumeFailed = serverRoute(routes, '/api/user/workflows//runs//resume-failed');
+ assert.match(resumeFailed, /if workflow\.get\('durable_execution'\) is True:\n\s+return jsonify\(\{'error': '[^']+'\}\), 409/);
+
+ const sourceRoot = fileURLToPath(new URL('../application/v2_ui/src/', import.meta.url));
+ const offenders = [];
+ let scanned = 0;
+ for (const entry of readdirSync(sourceRoot, { recursive: true, withFileTypes: true })) {
+ if (!entry.isFile() || !/\.(ts|tsx)$/.test(entry.name)) {
+ continue;
+ }
+ const file = join(entry.parentPath, entry.name);
+ scanned += 1;
+ readFileSync(file, 'utf8').split(/\r?\n/).forEach((line, index) => {
+ const code = line.trim();
+ if (code.includes('resume-failed') && !code.startsWith('//') && !code.startsWith('*')) {
+ offenders.push(`${relative(sourceRoot, file)}:${index + 1}`);
+ }
+ });
+ }
+ assert.ok(scanned > 100, `Only ${scanned} V2 source files were scanned.`);
+ assert.deepEqual(offenders, []);
+
+ const actions = readFileSync(new URL('../application/v2_ui/src/lib/workflowRunActions.ts', import.meta.url), 'utf8');
+ assert.ok(!/\bcancelScopedWorkflow\b/.test(actions), 'Run actions must never use the workflow-level cancel.');
+});
+
+test('No request in this file reached /resume-failed or the workflow-level cancel', () => {
+ assert.ok(allCalls.length > 0);
+ for (const call of allCalls) {
+ assert.ok(!call.url.includes('resume-failed'), call.url);
+ assert.ok(!/\/api\/user\/workflows\/[^/]+\/cancel$/.test(call.url), call.url);
+ assert.ok(!call.url.startsWith('/api/group/'), call.url);
+ }
+});
diff --git a/functional_tests/test_v2_workflow_run_link_routing.mjs b/functional_tests/test_v2_workflow_run_link_routing.mjs
new file mode 100644
index 000000000..7c73808cd
--- /dev/null
+++ b/functional_tests/test_v2_workflow_run_link_routing.mjs
@@ -0,0 +1,362 @@
+// test_v2_workflow_run_link_routing.mjs
+// Version: 0.261.251
+// Implemented in: 0.261.251
+// Executes where V2 opens a workflow run from a link: the run deep link (workflowRunHref and
+// v2WorkflowRunPath) in personal and group workspaces, the bell's reading of 6b-1's notices about a
+// chat-started run, and the workflow alert card's Open run. A notice opens the run in V2 only when
+// its link and its own metadata agree on the workspace, the workflow and the run. A Microsoft 365
+// notice, a notice that names its workspace only through `group_id`, an unknown workspace and an id
+// a path can't carry all keep the classic page or no link, never a guessed V2 address. The notice
+// shapes are pinned against the server modules that write them.
+
+import assert from 'node:assert/strict';
+import { readFileSync } from 'node:fs';
+import test from 'node:test';
+import './test_support/tsResolve.mjs';
+
+function refuseNetwork() {
+ throw new Error('Link routing never reaches the network.');
+}
+
+globalThis.fetch = refuseNetwork;
+
+// The repository resolver must be registered before extensionless TypeScript imports load.
+const { normalizeNotification } = await import('../application/v2_ui/src/lib/notifications.ts');
+const { resolveNotificationLink, v2WorkflowRunPath } = await import('../application/v2_ui/src/lib/notificationLinks.ts');
+const { readWorkflowRunLink, workflowRunHref } = await import('../application/v2_ui/src/lib/workflowRunLink.ts');
+const {
+ readWorkflowAlert,
+ workflowAlertOpenRunPath,
+ workflowAlertWorkflowPath,
+} = await import('../application/v2_ui/src/lib/workflowAlertNotices.ts');
+
+const ORIGIN = 'https://simplechat.test';
+const PERSONAL = { type: 'personal' };
+const GROUP = { type: 'group', groupId: 'grp-1' };
+const PERSONAL_RUN = '/workspace/workflows?workflow_id=wf-1&run_id=run-1';
+const GROUP_RUN = '/groups/grp-1/workflows?workflow_id=wf-1&run_id=run-1';
+const NO_LINK = { target: null, error: null };
+const INVALID_LINK = 'This notification has an invalid link. Open the destination directly.';
+
+// Ids a path segment or a query must not carry: requireWorkspaceId refuses each of them.
+const BAD_IDS = ['', ' wf-1', 'wf-1 ', 'wf/1', 'wf\\1', 'wf?1', 'wf#1', '.', '..', 'wf\u00001', 'wf\n1', 'wf\u007f1'];
+
+function serverSource(name) {
+ return readFileSync(new URL(name, new URL('../application/single_app/', import.meta.url)), 'utf8')
+ .replace(/\r\n/g, '\n');
+}
+
+function serverFunction(source, name) {
+ const start = source.indexOf(`\ndef ${name}(`);
+ assert.ok(start >= 0, `The server no longer defines ${name}.`);
+ const end = source.indexOf('\ndef ', start + 1);
+ return source.slice(start, end < 0 ? undefined : end);
+}
+
+/** The classic workflow-activity link exactly as 6b-1 writes it (workflow_run_notice_link). */
+function activityLink(workflowId, runId, extra = {}) {
+ return `/workflow-activity?${new URLSearchParams({ workflowId, runId, scope: 'personal', ...extra })}`;
+}
+
+/** A notice as the panel holds it, defaulting to 6b-1's notice for a run it couldn't post. */
+function notice(overrides = {}) {
+ const normalized = normalizeNotification({
+ id: 'n-1',
+ notification_type: 'workflow_chat_delivery',
+ title: 'Results from "Weekly digest" are ready',
+ message: "The chat that started this run can't show it anymore. Open the run in Workflows to see the details.",
+ created_at: '2026-01-05T09:05:00Z',
+ is_read: false,
+ link_url: activityLink('wf-1', 'run-1'),
+ link_context: {},
+ metadata: { workflow_id: 'wf-1', run_id: 'run-1', workflow_scope: 'personal', delivery_status: 'undeliverable' },
+ type_config: { icon: 'bi-activity', color: 'info' },
+ ...overrides,
+ });
+ assert.ok(normalized, 'The fixture is not a notice the panel keeps.');
+ return normalized;
+}
+
+function resolve(overrides) {
+ return resolveNotificationLink(notice(overrides), ORIGIN);
+}
+
+function routeTo(path) {
+ return { target: { kind: 'route', path }, error: null };
+}
+
+function classicAt(href, groupId = null) {
+ return { target: { kind: 'classic', href, groupId }, error: null };
+}
+
+function alert(metadata) {
+ const read = readWorkflowAlert({
+ id: 'alert-1',
+ notification_type: 'workflow_priority_alert',
+ title: 'Weekly digest needs attention',
+ message: 'Three items matched.',
+ created_at: '2026-01-05T09:05:00Z',
+ is_read: false,
+ link_url: '',
+ link_context: {},
+ metadata: { workflow_name: 'Weekly digest', ...metadata },
+ type_config: { icon: 'bi-bell', color: 'secondary' },
+ });
+ assert.ok(read, 'The fixture is not an alert the card reads.');
+ return read;
+}
+
+test("6b-1's notices are the shapes the bell reads", () => {
+ const delivery = serverSource('functions_workflow_chat_delivery.py');
+ assert.match(delivery, /^NOTIFICATION_TYPE = 'workflow_chat_delivery'$/m);
+ assert.match(delivery, /^WORKFLOW_SCOPE = 'personal'$/m);
+ assert.ok(serverFunction(delivery, 'workflow_run_notice_link').includes(
+ "return '/workflow-activity?' + urlencode({'workflowId': workflow_id, 'runId': run_id, 'scope': WORKFLOW_SCOPE})",
+ ));
+ assert.ok(serverFunction(delivery, 'notice_metadata').includes(
+ "return {'workflow_id': workflow_id, 'run_id': run_id, 'workflow_scope': WORKFLOW_SCOPE, "
+ + "'delivery_status': delivery_status}",
+ ));
+
+ // The undeliverable and expired notices carry that link and that metadata, and nothing else.
+ const sendNotice = serverFunction(serverSource('functions_workflow_chat_delivery_worker.py'), '_send_notice');
+ for (const line of [
+ 'notification_type=NOTIFICATION_TYPE,',
+ 'link_url=workflow_run_notice_link(delivery.workflow_id, delivery.run_id),',
+ 'metadata=notice_metadata(delivery.workflow_id, delivery.run_id, delivery_status),',
+ ]) {
+ assert.ok(sendNotice.includes(line), line);
+ }
+ assert.doesNotMatch(sendNotice, /link_context=/);
+
+ // A delivered result is announced with the ordinary reply notice, which opens the chat.
+ const reply = serverFunction(serverSource('functions_notifications.py'), 'create_chat_response_notification');
+ assert.ok(reply.includes("notification_type='chat_response_complete',"));
+ assert.ok(reply.includes("link_url=f'/chats?conversationId={conversation_id}',"));
+});
+
+test('the run deep link is the Workflows section of the workspace the workflow lives in', () => {
+ assert.equal(workflowRunHref('wf-1', 'run-1'), PERSONAL_RUN);
+ assert.equal(workflowRunHref('wf-1', 'run-1', PERSONAL), PERSONAL_RUN);
+ assert.equal(workflowRunHref('wf-1', 'run-1', GROUP), GROUP_RUN);
+
+ // The group id is a path segment, encoded the way the server quotes it; the ids are query values.
+ const href = workflowRunHref('wf 1&x=2', 'run+2', { type: 'group', groupId: "team (a)'s" });
+ assert.equal(href, '/groups/team%20%28a%29%27s/workflows?workflow_id=wf+1%26x%3D2&run_id=run%2B2');
+ const url = new URL(href, ORIGIN);
+ assert.equal(decodeURIComponent(url.pathname), "/groups/team (a)'s/workflows");
+ assert.deepEqual(readWorkflowRunLink(url.search), { workflowId: 'wf 1&x=2', runId: 'run+2' });
+ assert.deepEqual(readWorkflowRunLink(new URL(GROUP_RUN, ORIGIN).searchParams), { workflowId: 'wf-1', runId: 'run-1' });
+
+ // A group id a path segment can't carry is refused rather than built into another path.
+ for (const groupId of ['', 'a/b', 'a\\b', ' grp', '..', 'g?x', 'g#x']) {
+ assert.throws(() => workflowRunHref('wf-1', 'run-1', { type: 'group', groupId }), /Invalid workspace identifier/, groupId);
+ }
+});
+
+test('v2WorkflowRunPath opens a run only in a known workspace, with ids a link can carry', () => {
+ assert.equal(v2WorkflowRunPath(PERSONAL, 'wf-1', 'run-1'), PERSONAL_RUN);
+ assert.equal(v2WorkflowRunPath(GROUP, 'wf-1', 'run-1'), GROUP_RUN);
+ assert.equal(
+ v2WorkflowRunPath(PERSONAL, 'wf 1&x=2', 'run=2'),
+ '/workspace/workflows?workflow_id=wf+1%26x%3D2&run_id=run%3D2',
+ );
+
+ // An unknown workspace is never read as a personal one.
+ for (const scope of [null, undefined, { type: 'public', groupId: 'grp-1' }, { type: '' }, { type: 'Personal' }]) {
+ assert.equal(v2WorkflowRunPath(scope, 'wf-1', 'run-1'), null, JSON.stringify(scope));
+ }
+ for (const groupId of ['', 'a/b', ' grp-1', '..', undefined]) {
+ assert.equal(v2WorkflowRunPath({ type: 'group', groupId }, 'wf-1', 'run-1'), null, String(groupId));
+ }
+ for (const id of [...BAD_IDS, null, undefined, 7, { id: 'wf-1' }]) {
+ assert.equal(v2WorkflowRunPath(PERSONAL, id, 'run-1'), null, `workflow ${JSON.stringify(id)}`);
+ assert.equal(v2WorkflowRunPath(PERSONAL, 'wf-1', id), null, `run ${JSON.stringify(id)}`);
+ assert.equal(v2WorkflowRunPath(GROUP, id, 'run-1'), null, `group workflow ${JSON.stringify(id)}`);
+ }
+});
+
+test("6b-1's notice about a run it couldn't post opens the run in V2", () => {
+ assert.deepEqual(resolve({}), routeTo(PERSONAL_RUN));
+ // The expired notice is the same link with another title and delivery status.
+ assert.deepEqual(resolve({
+ title: '"Weekly digest" didn\'t finish in time to post to chat',
+ metadata: { workflow_id: 'wf-1', run_id: 'run-1', workflow_scope: 'personal', delivery_status: 'expired' },
+ }), routeTo(PERSONAL_RUN));
+ // A trailing slash, the link's own fragment and extra parameters change nothing.
+ assert.deepEqual(resolve({ link_url: `/workflow-activity/?${new URLSearchParams({ workflowId: 'wf-1', runId: 'run-1', scope: 'personal', tab: 'x' })}#top` }), routeTo(PERSONAL_RUN));
+ // Ids the link encodes are decoded once and encoded again for V2.
+ assert.deepEqual(resolve({
+ link_url: activityLink('wf 1&x', 'run=1'),
+ metadata: { workflow_id: 'wf 1&x', run_id: 'run=1', workflow_scope: 'personal' },
+ }), routeTo('/workspace/workflows?workflow_id=wf+1%26x&run_id=run%3D1'));
+ // A notice that wrote no ids takes the link's own.
+ assert.deepEqual(resolve({ metadata: { workflow_scope: 'personal' } }), routeTo(PERSONAL_RUN));
+ // `group_id` only names the group classic makes active; it does not move a personal run.
+ assert.deepEqual(resolve({
+ link_context: { group_id: 'grp-9' },
+ metadata: { workflow_id: 'wf-1', run_id: 'run-1', workflow_scope: 'personal', group_id: 'grp-9' },
+ }), routeTo(PERSONAL_RUN));
+});
+
+test("6b-1's delivered-result notice still opens the chat it posted into", () => {
+ const resolved = resolve({
+ notification_type: 'chat_response_complete',
+ title: 'AI responded in Planning',
+ message: 'Results from "Weekly digest" are in your chat',
+ link_url: '/chats?conversationId=conv-1',
+ link_context: { workspace_type: 'personal', conversation_id: 'conv-1' },
+ metadata: { conversation_id: 'conv-1', message_id: 'assistant_workflow_delivery_abc' },
+ type_config: { icon: 'bi-chat-dots', color: 'success' },
+ });
+ assert.deepEqual(resolved, { target: { kind: 'conversation', conversationId: 'conv-1' }, error: null });
+});
+
+test('a group run opens in V2 only when the link and the notice name the same group', () => {
+ const groupLink = activityLink('wf-1', 'run-1', { scope: 'group', groupId: 'grp-1' });
+ const groupMetadata = { workflow_id: 'wf-1', run_id: 'run-1', workflow_scope: 'group', workflow_group_id: 'grp-1' };
+ assert.deepEqual(resolve({ link_url: groupLink, metadata: groupMetadata }), routeTo(GROUP_RUN));
+
+ // Another group, a group named only through `group_id`, or a group the link doesn't name stays classic.
+ const classicGroup = [
+ { workflow_id: 'wf-1', run_id: 'run-1', workflow_scope: 'group', workflow_group_id: 'grp-2' },
+ { workflow_id: 'wf-1', run_id: 'run-1', workflow_scope: 'group', group_id: 'grp-1' },
+ { workflow_id: 'wf-1', run_id: 'run-1', group_id: 'grp-1' },
+ { workflow_id: 'wf-1', run_id: 'run-1', workflow_scope: 'personal' },
+ ];
+ for (const metadata of classicGroup) {
+ const expected = classicAt(groupLink, typeof metadata.group_id === 'string' ? metadata.group_id : null);
+ assert.deepEqual(resolve({ link_url: groupLink, metadata }), expected, JSON.stringify(metadata));
+ }
+ const unnamedGroup = activityLink('wf-1', 'run-1', { scope: 'group' });
+ assert.deepEqual(resolve({ link_url: unnamedGroup, metadata: groupMetadata }), classicAt(unnamedGroup));
+ const badGroup = activityLink('wf-1', 'run-1', { scope: 'group', groupId: 'grp/1' });
+ assert.deepEqual(resolve({ link_url: badGroup, metadata: { ...groupMetadata, workflow_group_id: 'grp/1' } }), classicAt(badGroup));
+});
+
+test('a workflow-activity link the notice does not agree with keeps the classic page', () => {
+ const cases = [
+ // No workspace in the link, or one V2 doesn't know.
+ [`/workflow-activity?${new URLSearchParams({ workflowId: 'wf-1', runId: 'run-1' })}`, {}],
+ [activityLink('wf-1', 'run-1', { scope: 'public' }), {}],
+ [activityLink('wf-1', 'run-1', { scope: '' }), {}],
+ // No workspace in the notice, or another one.
+ [activityLink('wf-1', 'run-1'), { metadata: { workflow_id: 'wf-1', run_id: 'run-1' } }],
+ [activityLink('wf-1', 'run-1'), { metadata: { workflow_id: 'wf-1', run_id: 'run-1', workflow_scope: 'public' } }],
+ [activityLink('wf-1', 'run-1'), { metadata: { workflow_id: 'wf-1', run_id: 'run-1', workflow_scope: 'group', workflow_group_id: 'grp-1' } }],
+ // Another workflow or run than the notice wrote.
+ [activityLink('wf-1', 'run-1'), { metadata: { workflow_id: 'wf-2', run_id: 'run-1', workflow_scope: 'personal' } }],
+ [activityLink('wf-1', 'run-1'), { metadata: { workflow_id: 'wf-1', run_id: 'run-2', workflow_scope: 'personal' } }],
+ // A missing id, or one a link must not carry.
+ [`/workflow-activity?${new URLSearchParams({ workflowId: 'wf-1', scope: 'personal' })}`, {}],
+ [`/workflow-activity?${new URLSearchParams({ runId: 'run-1', scope: 'personal' })}`, {}],
+ [activityLink('wf/1', 'run-1'), { metadata: { workflow_scope: 'personal' } }],
+ [activityLink('wf-1', ' run-1'), { metadata: { workflow_scope: 'personal' } }],
+ ];
+ for (const [link, overrides] of cases) {
+ assert.deepEqual(resolve({ link_url: link, ...overrides }), classicAt(link), link);
+ }
+});
+
+test('a Microsoft 365 notice keeps the classic page wherever its action id was written', () => {
+ const link = activityLink('wf-1', 'run-1');
+ const metadata = { workflow_id: 'wf-1', run_id: 'run-1', workflow_scope: 'personal' };
+ for (const value of ['act-1', '', null]) {
+ assert.deepEqual(
+ resolve({ link_url: link, metadata: { ...metadata, m365_pending_action_id: value } }),
+ classicAt(link),
+ `metadata ${JSON.stringify(value)}`,
+ );
+ assert.deepEqual(
+ resolve({ link_url: link, metadata, link_context: { m365_pending_action_id: value } }),
+ classicAt(link),
+ `link context ${JSON.stringify(value)}`,
+ );
+ }
+ // Its group, when it names one, is still the one classic makes active.
+ assert.deepEqual(
+ resolve({ link_url: link, metadata, link_context: { m365_pending_action_id: 'act-1', group_id: 'grp-3' } }),
+ classicAt(link, 'grp-3'),
+ );
+ // A chat link to a pending action opens classic's chat page, the only one that renders it.
+ assert.deepEqual(resolve({
+ notification_type: 'm365_approval_requested',
+ link_url: '/chats?conversationId=conv-1&m365_pending_action=act-1',
+ metadata: { m365_pending_action_id: 'act-1' },
+ }), classicAt('/chats?conversationId=conv-1&m365_pending_action=act-1'));
+});
+
+test('a workflow notice without a link opens the run its metadata names, and nothing else does', () => {
+ const personal = { workflow_id: 'wf-1', run_id: 'run-1', workflow_scope: 'personal' };
+ const group = { workflow_id: 'wf-1', run_id: 'run-1', workflow_scope: 'group', workflow_group_id: 'grp-1' };
+ for (const type of ['workflow_chat_delivery', 'workflow_priority_alert']) {
+ assert.deepEqual(resolve({ notification_type: type, link_url: '', metadata: personal }), routeTo(PERSONAL_RUN), type);
+ assert.deepEqual(resolve({ notification_type: type, link_url: '', metadata: group }), routeTo(GROUP_RUN), type);
+ assert.deepEqual(resolve({ notification_type: type, link_url: undefined, metadata: personal }), routeTo(PERSONAL_RUN), type);
+ }
+
+ // Another notice type that happens to name a run has no link.
+ for (const type of ['chat_response_complete', 'system_announcement', 'm365_approval_requested', 'workflow_chat_delivery_extra', '']) {
+ assert.deepEqual(resolve({ notification_type: type, link_url: '', metadata: personal }), NO_LINK, type);
+ }
+
+ const unplaced = [
+ // Microsoft 365, wherever the action id was written.
+ [{ ...personal, m365_pending_action_id: 'act-1' }, {}],
+ [personal, { m365_pending_action_id: 'act-1' }],
+ // A group named only through `group_id`, which classic reads as the group to make active.
+ [{ workflow_id: 'wf-1', run_id: 'run-1', workflow_scope: 'group', group_id: 'grp-1' }, {}],
+ [{ workflow_id: 'wf-1', run_id: 'run-1', group_id: 'grp-1' }, {}],
+ [{ workflow_id: 'wf-1', run_id: 'run-1' }, { group_id: 'grp-1', workspace_type: 'group' }],
+ // No workspace, or one V2 doesn't know.
+ [{ workflow_id: 'wf-1', run_id: 'run-1' }, {}],
+ [{ ...personal, workflow_scope: 'public' }, {}],
+ [{ ...personal, workflow_scope: ['personal'] }, {}],
+ [{ ...group, workflow_group_id: 'grp/1' }, {}],
+ // A missing id, or one a link must not carry.
+ [{ ...personal, run_id: undefined }, {}],
+ [{ ...personal, workflow_id: 'wf/1' }, {}],
+ [{ ...personal, run_id: 'run-1 ' }, {}],
+ [{ ...personal, run_id: 42 }, {}],
+ ];
+ for (const type of ['workflow_chat_delivery', 'workflow_priority_alert']) {
+ for (const [metadata, linkContext] of unplaced) {
+ const resolved = resolve({ notification_type: type, link_url: '', metadata, link_context: linkContext });
+ assert.deepEqual(resolved, NO_LINK, `${type} ${JSON.stringify({ metadata, linkContext })}`);
+ }
+ }
+
+ // A link that is present but blank is still reported, not replaced by the run.
+ assert.deepEqual(resolve({ link_url: ' ', metadata: personal }), { target: null, error: INVALID_LINK });
+});
+
+test("the alert card's Open run is the run in the workflow's own workspace, or nothing", () => {
+ const personal = alert({ workflow_id: 'wf-1', run_id: 'run-1', workflow_scope: 'personal' });
+ assert.equal(workflowAlertOpenRunPath(personal), PERSONAL_RUN);
+ assert.equal(workflowAlertWorkflowPath(personal), PERSONAL_RUN);
+
+ const group = alert({ workflow_id: 'wf-1', run_id: 'run-1', workflow_scope: 'group', workflow_group_id: 'grp-1' });
+ assert.equal(workflowAlertOpenRunPath(group), GROUP_RUN);
+ assert.equal(workflowAlertWorkflowPath(group), GROUP_RUN);
+
+ // Without a run, the card keeps Open workflow.
+ const noRun = alert({ workflow_id: 'wf-1', workflow_scope: 'personal' });
+ assert.equal(workflowAlertOpenRunPath(noRun), null);
+ assert.equal(workflowAlertWorkflowPath(noRun), '/workspace/workflows?workflow_id=wf-1');
+
+ // An alert it can't place offers neither: `group_id` alone never places it.
+ for (const metadata of [
+ { workflow_id: 'wf-1', run_id: 'run-1' },
+ { workflow_id: 'wf-1', run_id: 'run-1', workflow_scope: 'group', group_id: 'grp-1' },
+ { workflow_id: 'wf-1', run_id: 'run-1', workflow_scope: 'group', workflow_group_id: 'grp/1' },
+ ]) {
+ const unplaced = alert(metadata);
+ assert.equal(workflowAlertOpenRunPath(unplaced), null, JSON.stringify(metadata));
+ assert.equal(workflowAlertWorkflowPath(unplaced), null, JSON.stringify(metadata));
+ }
+
+ // An id a link must not carry leaves the run out rather than building another address.
+ const badRun = alert({ workflow_id: 'wf-1', run_id: 'run/1', workflow_scope: 'personal' });
+ assert.equal(workflowAlertOpenRunPath(badRun), null);
+ assert.equal(workflowAlertWorkflowPath(badRun), '/workspace/workflows?workflow_id=wf-1');
+});
diff --git a/functional_tests/test_v2_workflow_run_status.mjs b/functional_tests/test_v2_workflow_run_status.mjs
new file mode 100644
index 000000000..6460fe1cb
--- /dev/null
+++ b/functional_tests/test_v2_workflow_run_status.mjs
@@ -0,0 +1,872 @@
+// test_v2_workflow_run_status.mjs
+// Version: 0.261.251
+// Implemented in: 0.261.251
+// Executes the V2 client's reading of 6b-1's chat-started workflow run status route
+// (GET /api/v2/orchestration/workflow-runs/status): the response envelope, the rows dropped for ids
+// that can't be used, the rows that fail closed to "Status unavailable" for any value outside the
+// server's closed sets, the exact projection a good row keeps, the controls each row offers (Retry
+// only from `actions.retry` on a failed run, never from the status alone), the fixed texts, the
+// step and elapsed formatting, the id checks, the one batched request, and the closed sets and the
+// status-to-phase pairing pinned against the server module that writes them.
+
+import assert from 'node:assert/strict';
+import { readFileSync } from 'node:fs';
+import test from 'node:test';
+import './test_support/tsResolve.mjs';
+import {
+ at,
+ deliveredRow,
+ deliveryMessageId,
+ failedRow,
+ statusResponse,
+ statusRow,
+} from './test_support/workflowRunStatusFixtures.mjs';
+
+function refuseNetwork() {
+ throw new Error('Only the request tests may reach the network, through their own stub.');
+}
+
+globalThis.fetch = refuseNetwork;
+
+// The repository resolver must be registered before extensionless TypeScript imports load.
+const { ApiError } = await import('../application/v2_ui/src/lib/apiClient.ts');
+const {
+ WORKFLOW_DELIVERY_MESSAGE_PREFIX,
+ WORKFLOW_DELIVERY_REASONS,
+ WORKFLOW_DELIVERY_ROW_STATUSES,
+ WORKFLOW_FAILURE_CODES,
+ WORKFLOW_RESULTS_IN_HISTORY_TEXT,
+ WORKFLOW_RESULTS_POSTED_ELSEWHERE_TEXT,
+ WORKFLOW_RESULTS_POSTED_TEXT,
+ WORKFLOW_RESULTS_POSTING_TEXT,
+ WORKFLOW_RETRY_BLOCKED_CODES,
+ WORKFLOW_RETRY_TURNED_OFF_TEXT,
+ WORKFLOW_RUN_CANCELLED_TEXT,
+ WORKFLOW_RUN_ROW_PHASES,
+ WORKFLOW_RUN_ROW_STATUSES,
+ WORKFLOW_RUN_STATUS_INVALID_RESPONSE,
+ WORKFLOW_RUN_STATUS_PATH,
+ WORKFLOW_RUN_STATUS_UNAVAILABLE,
+ WORKFLOW_STATUS_HALTED_TEXT,
+ WORKFLOW_STATUS_READ_ERROR_TEXT,
+ WORKFLOW_WAITING_ACTIONS,
+ WORKFLOW_WAITING_REASONS,
+ fetchWorkflowRunStatus,
+ formatCheckedTime,
+ formatWorkflowElapsed,
+ isStatusConversationId,
+ isWorkflowRunActive,
+ isWorkflowRunIdentifier,
+ isWorkflowRunInFlight,
+ parseWorkflowRunStatusResponse,
+ workflowRetryBlockedText,
+ workflowRunRowControls,
+ workflowRunStatusLabel,
+ workflowRunStepLabel,
+ workflowRunStepText,
+ workflowWaitingText,
+} = await import('../application/v2_ui/src/lib/workflowRunStatus.ts');
+
+const APPROVAL = { reason: 'approval', action: 'approve', gate_id: 'gate-1' };
+const RECOVERY = { reason: 'recovery', action: 'open_run', gate_id: null };
+const RECONNECT = { reason: 'microsoft_365_reconnect', action: 'reconnect', gate_id: null };
+const NO_CONTROLS = { cancel: false, retry: false, retryTurnedOff: false, approve: false, reconnect: false, openRun: true };
+
+function parseRuns(rows) {
+ return parseWorkflowRunStatusResponse(statusResponse(rows)).runs;
+}
+
+function parseRow(row) {
+ const runs = parseRuns([row]);
+ assert.equal(runs.length, 1, 'the row is kept');
+ return runs[0];
+}
+
+function waitingRow(runId, waiting, overrides = {}) {
+ return statusRow(runId, { status: 'waiting', phase: 'needs_you', waiting, ...overrides });
+}
+
+function expiredRow(runId, overrides = {}) {
+ const { delivery = {}, actions = {}, ...rest } = overrides;
+ return statusRow(runId, {
+ status: 'expired',
+ phase: 'failed',
+ error: 'It reached its time limit.',
+ error_code: 'deadline_exceeded',
+ ...rest,
+ delivery: { status: 'expired', reason: 'deadline_exceeded', ...delivery },
+ actions: { cancel: false, ...actions },
+ });
+}
+
+function cancelledRow(runId, overrides = {}) {
+ const { delivery = {}, actions = {}, ...rest } = overrides;
+ return statusRow(runId, {
+ status: 'cancelled',
+ phase: 'cancelled',
+ completed_at: at(200),
+ ...rest,
+ delivery,
+ actions: { cancel: false, ...actions },
+ });
+}
+
+/** A copy of `row` with one change made by `mutate`, so a case can delete a key outright. */
+function rowWith(mutate, row = statusRow('run-1')) {
+ const copy = structuredClone(row);
+ mutate(copy);
+ return copy;
+}
+
+/** What "Status unavailable" keeps of statusRow('run-1'): its identity and nothing else. */
+function unavailableRow(overrides = {}) {
+ return {
+ workflow_id: 'wf-run-1',
+ workflow_scope: 'personal',
+ run_id: 'run-1',
+ conversation_id: 'chat-1',
+ orchestration_run_id: 'orun-1',
+ step_id: 'step-run-1',
+ workflow_name: 'Weekly digest',
+ requested_at: at(0),
+ kind: 'unavailable',
+ ...overrides,
+ };
+}
+
+function jsonResponse(body, status = 200) {
+ return new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });
+}
+
+// ----- The server module the route is built from, read as text so nothing has to import it. -----
+
+const SERVER_DIR = new URL('../application/single_app/', import.meta.url);
+
+function serverSource(fileName) {
+ return readFileSync(new URL(fileName, SERVER_DIR), 'utf8').replace(/\r\n/g, '\n');
+}
+
+const STATUS_MODULE = serverSource('functions_workflow_chat_delivery_status.py');
+const DELIVERY_MODULE = serverSource('functions_workflow_chat_delivery.py');
+const ROUTE_MODULE = serverSource('route_backend_orchestration.py');
+
+function serverStringConstants() {
+ const constants = new Map();
+ for (const source of [DELIVERY_MODULE, STATUS_MODULE]) {
+ for (const [, name, value] of source.matchAll(/^(_?[A-Z][A-Z0-9_]*) = '([^'\n]*)'$/gm)) {
+ constants.set(name, value);
+ }
+ }
+ return constants;
+}
+
+const SERVER_CONSTANTS = serverStringConstants();
+
+function serverValue(token) {
+ assert.ok(SERVER_CONSTANTS.has(token), `${token} is a string constant in the server modules`);
+ return SERVER_CONSTANTS.get(token);
+}
+
+/** The strings in a module-level tuple or frozenset, with named constants resolved. */
+function serverCollection(source, name) {
+ const match = source.match(new RegExp(`^${name} = (?:frozenset\\()?[({]([^)}]*)[)}]`, 'm'));
+ assert.ok(match, `${name} is a module-level tuple or frozenset in the server module`);
+ return [...match[1].matchAll(/'([^'\n]*)'|\b([A-Z][A-Z0-9_]*)\b/g)]
+ .map(([, literal, constant]) => (literal !== undefined ? literal : serverValue(constant)));
+}
+
+function serverFunction(source, name) {
+ const start = source.indexOf(`\ndef ${name}(`);
+ assert.notEqual(start, -1, `${name} is defined in the server module`);
+ const end = source.indexOf('\ndef ', start + 1);
+ return source.slice(start, end === -1 ? undefined : end);
+}
+
+function serverInteger(source, name) {
+ const match = source.match(new RegExp(`^${name} = (\\d+)$`, 'm'));
+ assert.ok(match, `${name} is an integer constant in the server module`);
+ return Number(match[1]);
+}
+
+/** The one phase the server writes with each status, read from `_status_and_phase`. */
+function serverPhaseForStatus() {
+ const body = serverFunction(STATUS_MODULE, '_status_and_phase');
+ const phases = {};
+ for (const [, condition = '', status, phase] of body.matchAll(
+ /(?:if ([^\n]+):\n\s+)?return (state|'[a-z_]+'), '([a-z_]+)'/g,
+ )) {
+ if (status !== 'state') {
+ phases[status.slice(1, -1)] = phase;
+ continue;
+ }
+ const inline = condition.match(/^state in \(([^)]*)\)$/);
+ const named = condition.match(/^state in ([A-Z][A-Z0-9_]*)$/);
+ assert.ok(inline || named, `the condition "${condition}" names the states it passes through`);
+ const states = inline
+ ? [...inline[1].matchAll(/'([a-z_]+)'/g)].map((item) => item[1])
+ : serverCollection(DELIVERY_MODULE, named[1]);
+ for (const state of states) {
+ phases[state] = phase;
+ }
+ }
+ return phases;
+}
+
+function sorted(values) {
+ return [...values].sort();
+}
+
+// ----- The closed sets -----
+
+test('every closed set the client accepts is exactly the one the server writes', () => {
+ assert.deepEqual(sorted(WORKFLOW_RUN_ROW_STATUSES), sorted(serverCollection(STATUS_MODULE, 'ROW_STATUSES')));
+ assert.deepEqual(sorted(WORKFLOW_RUN_ROW_PHASES), sorted(serverCollection(STATUS_MODULE, 'ROW_PHASES')));
+ assert.deepEqual(
+ sorted(WORKFLOW_DELIVERY_ROW_STATUSES),
+ sorted(serverCollection(STATUS_MODULE, 'DELIVERY_ROW_STATUSES')),
+ );
+ assert.deepEqual(sorted(WORKFLOW_DELIVERY_REASONS), sorted(serverCollection(STATUS_MODULE, 'DELIVERY_REASONS')));
+ assert.deepEqual(
+ sorted(WORKFLOW_RETRY_BLOCKED_CODES),
+ sorted(serverCollection(STATUS_MODULE, 'RETRY_BLOCKED_CODES')),
+ );
+
+ const failureBlock = DELIVERY_MODULE.match(/^_FAILURE_REASONS = \{\n([\s\S]*?)\n\}/m);
+ assert.ok(failureBlock, '_FAILURE_REASONS is a module-level dict');
+ const failureCodes = [...failureBlock[1].matchAll(/^\s+(?:'([a-z0-9_]+)'|([A-Z][A-Z0-9_]*)):/gm)]
+ .map(([, literal, constant]) => literal ?? serverValue(constant));
+ assert.deepEqual(sorted(WORKFLOW_FAILURE_CODES), sorted(failureCodes));
+
+ const waitingBody = serverFunction(STATUS_MODULE, '_waiting');
+ const waitingReasons = new Set([...waitingBody.matchAll(/reason, action = '([a-z0-9_]+)'/g)].map((item) => item[1]));
+ for (const [, constant, literal] of waitingBody.matchAll(/reason = ([A-Z][A-Z0-9_]*) if [^\n]* else '([a-z0-9_]+)'/g)) {
+ waitingReasons.add(serverValue(constant));
+ waitingReasons.add(literal);
+ }
+ assert.deepEqual(sorted(WORKFLOW_WAITING_REASONS), sorted(waitingReasons));
+ const waitingActions = [...SERVER_CONSTANTS]
+ .filter(([name]) => name.startsWith('WAITING_ACTION_'))
+ .map(([, value]) => value);
+ assert.deepEqual(sorted(WORKFLOW_WAITING_ACTIONS), sorted(waitingActions));
+
+ assert.equal(WORKFLOW_DELIVERY_MESSAGE_PREFIX, serverValue('DELIVERY_MESSAGE_ID_PREFIX'));
+ assert.equal(serverValue('_SECONDS_FORMAT'), '%Y-%m-%dT%H:%M:%SZ');
+ assert.equal(serverInteger(STATUS_MODULE, '_ID_MAX_LENGTH'), 256);
+ assert.equal(serverInteger(DELIVERY_MODULE, 'NAME_MAX_LENGTH'), 80);
+ assert.ok(STATUS_MODULE.includes("_CONVERSATION_ID = re.compile(r'[A-Za-z0-9_-]{1,128}')"));
+ assert.ok(ROUTE_MODULE.includes(`@bp.route("${WORKFLOW_RUN_STATUS_PATH}", methods=["GET"])`));
+});
+
+test('each status is read only with the one phase the server pairs it with', () => {
+ const serverPhases = serverPhaseForStatus();
+ assert.deepEqual(serverPhases, {
+ expired: 'failed',
+ queued: 'running',
+ running: 'running',
+ completed: 'finished',
+ completed_partial: 'finished',
+ failed: 'failed',
+ cancelled: 'cancelled',
+ waiting: 'needs_you',
+ });
+ assert.deepEqual(sorted(Object.keys(serverPhases)), sorted(WORKFLOW_RUN_ROW_STATUSES));
+
+ for (const status of WORKFLOW_RUN_ROW_STATUSES) {
+ for (const phase of WORKFLOW_RUN_ROW_PHASES) {
+ const row = statusRow('run-1', {
+ status,
+ phase,
+ ...(status === 'waiting' ? { waiting: APPROVAL } : {}),
+ ...(status === 'failed' || status === 'expired'
+ ? { error: 'It stopped.', error_code: status === 'expired' ? 'deadline_exceeded' : 'failed' }
+ : {}),
+ });
+ const expected = phase === serverPhases[status] ? 'status' : 'unavailable';
+ assert.equal(parseRow(row).kind, expected, `${status} with ${phase}`);
+ }
+ }
+});
+
+// ----- The envelope -----
+
+test('a response that is not the route envelope is refused as a whole', () => {
+ const good = statusResponse([statusRow('run-1')]);
+ const broken = [
+ null,
+ undefined,
+ 'text',
+ 42,
+ [],
+ [good],
+ { ...good, available: 'true' },
+ { ...good, available: undefined },
+ { ...good, truncated: 0 },
+ { ...good, truncated: undefined },
+ { ...good, runs: {} },
+ { ...good, runs: null },
+ { ...good, runs: undefined },
+ { ...good, checked_at: '2026-01-05T09:05:00.123Z' },
+ { ...good, checked_at: '2026-01-05T09:05:00+00:00' },
+ { ...good, checked_at: '2026-01-05 09:05:00Z' },
+ { ...good, checked_at: '2026-13-05T09:05:00Z' },
+ { ...good, checked_at: '2026-01-05' },
+ { ...good, checked_at: null },
+ { ...good, checked_at: Date.parse(at(300)) },
+ ];
+ for (const value of broken) {
+ assert.throws(() => parseWorkflowRunStatusResponse(value), { message: WORKFLOW_RUN_STATUS_INVALID_RESPONSE });
+ }
+});
+
+test('a good envelope keeps exactly its four fields', () => {
+ const parsed = parseWorkflowRunStatusResponse({
+ ...statusResponse([], { available: false, truncated: true }),
+ debug: '',
+ });
+ assert.deepEqual(parsed, { available: false, runs: [], checked_at: at(300), truncated: true });
+});
+
+// ----- Rows dropped for their ids -----
+
+test('a row whose ids cannot be used is dropped, and does not claim its run id', () => {
+ const dropped = [
+ null,
+ 'run-x',
+ 7,
+ [statusRow('run-x')],
+ statusRow('run-x', { workflow_id: '' }),
+ statusRow('run-x', { workflow_id: ' wf-run-x' }),
+ statusRow('run-x', { workflow_id: 7 }),
+ statusRow('run-x', { conversation_id: undefined }),
+ statusRow('run-x', { conversation_id: 'chat\u0007' }),
+ statusRow('run-x', { orchestration_run_id: undefined }),
+ statusRow('run-x', { orchestration_run_id: '' }),
+ statusRow('run-x', { step_id: undefined }),
+ statusRow('run-x', { step_id: 12 }),
+ statusRow('run-x', { workflow_scope: 'group' }),
+ statusRow('run-x', { workflow_scope: undefined }),
+ statusRow('x'.repeat(257)),
+ statusRow('run\ud800x'),
+ ];
+ const kept = statusRow('run-x', { workflow_name: 'Kept' });
+ const runs = parseRuns([...dropped, kept]);
+ assert.equal(runs.length, 1);
+ assert.equal(runs[0].kind, 'status');
+ assert.equal(runs[0].workflow_name, 'Kept');
+});
+
+test('a run with no plan run or step id is kept, tracked but joined to no answer', () => {
+ const row = parseRow(statusRow('run-1', { orchestration_run_id: null, step_id: null }));
+ assert.equal(row.kind, 'status');
+ assert.equal(row.orchestration_run_id, null);
+ assert.equal(row.step_id, null);
+});
+
+test('the first row for a run wins, because the route lists the newest request first', () => {
+ const runs = parseRuns([
+ statusRow('run-1', { workflow_name: 'Newest' }),
+ statusRow('run-1', { workflow_name: 'Older' }),
+ statusRow('run-2'),
+ ]);
+ assert.deepEqual(runs.map((row) => [row.run_id, row.workflow_name]), [
+ ['run-1', 'Newest'],
+ ['run-2', 'Weekly digest'],
+ ]);
+
+ // An unreadable newest row is not replaced by an older one that might be stale.
+ const unreadable = parseRuns([statusRow('run-1', { status: 'mystery' }), statusRow('run-1')]);
+ assert.equal(unreadable.length, 1);
+ assert.equal(unreadable[0].kind, 'unavailable');
+});
+
+// ----- The exact projection -----
+
+test('a good row keeps exactly the route projection, and nothing else it was sent', () => {
+ const sent = waitingRow('run-1', { ...APPROVAL, debug: 'x' }, {
+ step_label: 'Collect files',
+ delivery: { debug: 'x' },
+ actions: { approve: true, debug: true },
+ debug: '',
+ raw_error: 'Traceback (most recent call last)',
+ });
+ assert.deepEqual(parseRow(sent), {
+ workflow_id: 'wf-run-1',
+ workflow_scope: 'personal',
+ run_id: 'run-1',
+ conversation_id: 'chat-1',
+ orchestration_run_id: 'orun-1',
+ step_id: 'step-run-1',
+ workflow_name: 'Weekly digest',
+ requested_at: at(0),
+ kind: 'status',
+ status: 'waiting',
+ phase: 'needs_you',
+ runtime_version: 3,
+ step_index: 1,
+ step_count: 4,
+ step_label: 'Collect files',
+ started_at: at(5),
+ completed_at: null,
+ elapsed_seconds: 95,
+ waiting: { reason: 'approval', action: 'approve', gate_id: 'gate-1' },
+ delivery: { status: 'pending', generation: null, message_id: null, delivered_at: null, reason: null },
+ error: null,
+ error_code: null,
+ retry_blocked: null,
+ actions: { cancel: true, retry: false, approve: true, open_run: true },
+ live: true,
+ });
+
+ const delivered = parseRow(deliveredRow('run-2', 4, at(250)));
+ assert.deepEqual(delivered.delivery, {
+ status: 'delivered',
+ generation: 4,
+ message_id: deliveryMessageId('run-2', 4),
+ delivered_at: at(250),
+ reason: null,
+ });
+ assert.equal(delivered.status, 'completed');
+ assert.equal(delivered.phase, 'finished');
+});
+
+test('values the contract allows to be empty are read, not refused', () => {
+ const row = parseRow(statusRow('run-1', {
+ requested_at: null,
+ started_at: null,
+ elapsed_seconds: null,
+ runtime_version: null,
+ step_index: null,
+ step_count: null,
+ live: false,
+ }));
+ assert.equal(row.kind, 'status');
+ assert.equal(row.requested_at, null);
+ assert.equal(row.runtime_version, null);
+ assert.equal(row.live, false);
+
+ const approvalWithoutGate = parseRow(waitingRow('run-1', { ...APPROVAL, gate_id: null }));
+ assert.equal(approvalWithoutGate.kind, 'status');
+ assert.equal(approvalWithoutGate.waiting.gate_id, null);
+
+ const undeliverable = parseRow(deliveredRow('run-1', 2, at(250), {
+ delivery: { status: 'undeliverable', message_id: null, delivered_at: null, reason: 'chat_unavailable' },
+ }));
+ assert.deepEqual(undeliverable.delivery, {
+ status: 'undeliverable',
+ generation: 2,
+ message_id: null,
+ delivered_at: null,
+ reason: 'chat_unavailable',
+ });
+});
+
+// ----- Rows that fail closed -----
+
+test('a row with any value outside the closed contract shows "Status unavailable" with only its identity', () => {
+ const failed = failedRow('run-1');
+ const cases = [
+ ['an unknown status', (row) => { row.status = 'paused'; }],
+ ['a missing status', (row) => { delete row.status; }],
+ ['a phase the server never pairs with the status', (row) => { row.phase = 'finished'; }],
+ ['an unknown phase', (row) => { row.phase = 'thinking'; }],
+ ['a waiting run in the running phase', (row) => { row.status = 'waiting'; row.waiting = APPROVAL; }],
+ ['a fractional runtime version', (row) => { row.runtime_version = 1.5; }],
+ ['a negative runtime version', (row) => { row.runtime_version = -1; }],
+ ['a runtime version sent as text', (row) => { row.runtime_version = '3'; }],
+ ['a missing step index', (row) => { delete row.step_index; }],
+ ['a step count sent as text', (row) => { row.step_count = '4'; }],
+ ['negative elapsed seconds', (row) => { row.elapsed_seconds = -5; }],
+ ['a step label that is not text', (row) => { row.step_label = 5; }],
+ ['a start time with milliseconds', (row) => { row.started_at = '2026-01-05T09:00:05.000Z'; }],
+ ['a start time with an offset', (row) => { row.started_at = '2026-01-05T09:00:05+00:00'; }],
+ ['a completion time that is not a time', (row) => { row.completed_at = 'yesterday'; }],
+ ['a missing live flag', (row) => { delete row.live; }],
+ ['a live flag sent as text', (row) => { row.live = 'true'; }],
+ ['an unknown waiting reason', (row) => { row.waiting = { ...RECOVERY, reason: 'coffee' }; }],
+ ['an unknown waiting action', (row) => { row.waiting = { ...RECOVERY, action: 'approve_all' }; }],
+ ['an empty gate id', (row) => { row.waiting = { ...APPROVAL, gate_id: '' }; }],
+ ['a missing gate id', (row) => { row.waiting = { reason: 'approval', action: 'approve' }; }],
+ ['waiting that is not a record', (row) => { row.waiting = 'approval'; }],
+ ['a missing waiting value', (row) => { delete row.waiting; }],
+ ['a missing delivery', (row) => { delete row.delivery; }],
+ ['a delivery that is not a record', (row) => { row.delivery = 'delivered'; }],
+ ['the stored delivery status the route maps away', (row) => { row.delivery.status = 'ready'; }],
+ ['a negative delivery generation', (row) => { row.delivery.generation = -1; }],
+ ['a delivery generation sent as text', (row) => { row.delivery.generation = '2'; }],
+ ['a delivered time with milliseconds', (row) => { row.delivery.delivered_at = '2026-01-05T09:04:10.000Z'; }],
+ ['a message id without the delivery prefix', (row) => { row.delivery.message_id = 'assistant_1'; }],
+ ['a message id with a trailing space', (row) => { row.delivery.message_id = `${deliveryMessageId('run-1', 1)} `; }],
+ ['a message id that is too long', (row) => { row.delivery.message_id = WORKFLOW_DELIVERY_MESSAGE_PREFIX + 'x'.repeat(229); }],
+ ['an unknown delivery reason', (row) => { row.delivery.reason = 'network_down'; }],
+ ['a missing delivery reason', (row) => { delete row.delivery.reason; }],
+ ['missing actions', (row) => { delete row.actions; }],
+ ['an action sent as text', (row) => { row.actions.retry = 'true'; }],
+ ['a missing Open run action', (row) => { delete row.actions.open_run; }],
+ ['an unknown failure code', (row) => { row.error_code = 'boom'; }],
+ ['a missing failure code', (row) => { delete row.error_code; }],
+ ['an unknown retry block', (row) => { row.retry_blocked = 'maybe_later'; }],
+ ['a missing retry block', (row) => { delete row.retry_blocked; }],
+ ['an error that is not text', (row) => { row.error = { message: 'x' }; }],
+ ['a needs-you row with nothing to wait for', (row) => {
+ Object.assign(row, { status: 'waiting', phase: 'needs_you', waiting: null });
+ }],
+ ['a failed row with a blank error', (row) => { Object.assign(row, failed, { error: ' ' }); }],
+ ['a failed row with no error', (row) => { Object.assign(row, failed, { error: null }); }],
+ ['a failed row with no failure code', (row) => { Object.assign(row, failed, { error_code: null }); }],
+ ['a timed-out row with no failure code', (row) => { Object.assign(row, expiredRow('run-1'), { error_code: null }); }],
+ ];
+ for (const [label, mutate] of cases) {
+ assert.deepEqual(parseRuns([rowWith(mutate)]), [unavailableRow()], label);
+ }
+
+ // A message id of exactly 256 characters is still an id.
+ const longest = parseRow(rowWith((row) => {
+ row.delivery.message_id = WORKFLOW_DELIVERY_MESSAGE_PREFIX + 'x'.repeat(228);
+ }));
+ assert.equal(longest.kind, 'status');
+});
+
+test('an unreadable name or request time makes the row unavailable, with a safe name and no time', () => {
+ const cases = [
+ ['a name that is not text', (row) => { row.workflow_name = 42; }, { workflow_name: 'Workflow' }],
+ ['a missing name', (row) => { delete row.workflow_name; }, { workflow_name: 'Workflow' }],
+ ['a request time that is not a time', (row) => { row.requested_at = 'soon'; }, { requested_at: null }],
+ ['a missing request time', (row) => { delete row.requested_at; }, { requested_at: null }],
+ ];
+ for (const [label, mutate, identity] of cases) {
+ assert.deepEqual(parseRuns([rowWith(mutate)]), [unavailableRow(identity)], label);
+ }
+});
+
+test('an unavailable row is never in flight and offers only Open run', () => {
+ const row = parseRow(statusRow('run-1', { status: 'mystery' }));
+ assert.equal(workflowRunStatusLabel(row), WORKFLOW_RUN_STATUS_UNAVAILABLE);
+ assert.equal(WORKFLOW_RUN_STATUS_UNAVAILABLE, 'Status unavailable');
+ assert.equal(isWorkflowRunInFlight(row), false);
+ assert.equal(isWorkflowRunActive(row), false);
+ assert.deepEqual(workflowRunRowControls(row, true), NO_CONTROLS);
+ assert.deepEqual(workflowRunRowControls(row, false), NO_CONTROLS);
+});
+
+// ----- The states 6b-1 calls out -----
+
+test('a recovering run reads as running, with its reason, and never as needing you', () => {
+ const row = parseRow(statusRow('run-1', { waiting: RECOVERY }));
+ assert.equal(row.kind, 'status');
+ assert.equal(row.status, 'running');
+ assert.equal(row.phase, 'running');
+ assert.deepEqual(row.waiting, RECOVERY);
+ assert.equal(workflowRunStatusLabel(row), 'Running');
+ assert.equal(workflowWaitingText(row.waiting.reason), 'Recovering after an interruption.');
+ assert.equal(isWorkflowRunActive(row), true);
+ assert.deepEqual(workflowRunRowControls(row, true), { ...NO_CONTROLS, cancel: true });
+});
+
+test('a skipped run reads as failed with its own code, and is never offered Retry', () => {
+ const row = parseRow(failedRow('run-1', {
+ error: 'No new or changed files were detected.',
+ error_code: 'skipped',
+ }));
+ assert.equal(row.status, 'failed');
+ assert.equal(row.phase, 'failed');
+ assert.equal(row.error_code, 'skipped');
+ assert.equal(workflowRunStatusLabel(row), 'Failed');
+ assert.deepEqual(workflowRunRowControls(row, true), NO_CONTROLS);
+});
+
+test('a run that reached its time limit reads as timed out, not in flight, with nothing to retry', () => {
+ const row = parseRow(expiredRow('run-1'));
+ assert.equal(row.status, 'expired');
+ assert.equal(row.phase, 'failed');
+ assert.equal(row.error, 'It reached its time limit.');
+ assert.equal(workflowRunStatusLabel(row), 'Timed out');
+ assert.equal(isWorkflowRunInFlight(row), false);
+ assert.deepEqual(workflowRunRowControls(row, true), NO_CONTROLS);
+});
+
+// ----- Controls -----
+
+test('each control comes from the server actions, never from the status alone', () => {
+ const cases = [
+ ['a running run the server lets you cancel', statusRow('r'), true, { ...NO_CONTROLS, cancel: true }],
+ ['a queued run', statusRow('r', { status: 'queued' }), true, { ...NO_CONTROLS, cancel: true }],
+ ['a run already cancelling', statusRow('r', { actions: { cancel: false } }), true, NO_CONTROLS],
+ ['a finished run marked cancellable', deliveredRow('r', 1, at(250), { actions: { cancel: true } }), true, NO_CONTROLS],
+ ['a failed run the server lets you retry', failedRow('r', { actions: { retry: true } }), true, { ...NO_CONTROLS, retry: true }],
+ ['a failed run the server does not let you retry', failedRow('r'), true, NO_CONTROLS],
+ [
+ 'a failed run that is blocked but marked retryable',
+ failedRow('r', { actions: { retry: true }, retry_blocked: 'workflow_definition_changed' }),
+ true,
+ NO_CONTROLS,
+ ],
+ [
+ 'a retryable failed run while chats cannot start workflows',
+ failedRow('r', { actions: { retry: true } }),
+ false,
+ { ...NO_CONTROLS, retryTurnedOff: true },
+ ],
+ [
+ 'a blocked failed run while chats cannot start workflows',
+ failedRow('r', { retry_blocked: 'retry_unavailable' }),
+ false,
+ NO_CONTROLS,
+ ],
+ ['a timed-out run marked retryable', expiredRow('r', { actions: { retry: true } }), true, NO_CONTROLS],
+ ['a cancelled run marked retryable', cancelledRow('r', { actions: { retry: true } }), true, NO_CONTROLS],
+ ['a running run marked retryable', statusRow('r', { actions: { retry: true } }), true, { ...NO_CONTROLS, cancel: true }],
+ [
+ 'a run waiting at an approval gate',
+ waitingRow('r', APPROVAL, { actions: { approve: true } }),
+ true,
+ { ...NO_CONTROLS, cancel: true, approve: true },
+ ],
+ [
+ 'an approval with no gate',
+ waitingRow('r', { ...APPROVAL, gate_id: null }, { actions: { approve: true } }),
+ true,
+ { ...NO_CONTROLS, cancel: true },
+ ],
+ ['an approval the server does not allow', waitingRow('r', APPROVAL), true, { ...NO_CONTROLS, cancel: true }],
+ [
+ 'an output review marked approvable',
+ waitingRow('r', { reason: 'output_review', action: 'open_run', gate_id: 'gate-1' }, { actions: { approve: true } }),
+ true,
+ { ...NO_CONTROLS, cancel: true },
+ ],
+ [
+ 'a running run carrying an approval',
+ statusRow('r', { waiting: APPROVAL, actions: { approve: true } }),
+ true,
+ { ...NO_CONTROLS, cancel: true },
+ ],
+ ['a Microsoft 365 sign-in', waitingRow('r', RECONNECT), true, { ...NO_CONTROLS, cancel: true, reconnect: true }],
+ ['a running run carrying a sign-in', statusRow('r', { waiting: RECONNECT }), true, { ...NO_CONTROLS, cancel: true }],
+ ];
+ for (const [label, sent, available, expected] of cases) {
+ assert.deepEqual(workflowRunRowControls(parseRow(sent), available), expected, label);
+ }
+});
+
+test('a run is in flight while it is going or its result is still on its way', () => {
+ const cases = [
+ ['queued', statusRow('r', { status: 'queued' }), true, true],
+ ['running', statusRow('r'), true, true],
+ ['waiting', waitingRow('r', APPROVAL), true, true],
+ ['completed, result pending', deliveredRow('r', 1, at(250), { delivery: { status: 'pending', message_id: null, delivered_at: null } }), true, false],
+ ['completed, result posting', deliveredRow('r', 1, at(250), { delivery: { status: 'delivering', message_id: null, delivered_at: null } }), true, false],
+ ['completed, result posted', deliveredRow('r', 1, at(250)), false, false],
+ ['failed, note pending', failedRow('r'), true, false],
+ ['failed, nothing to post', failedRow('r', { delivery: { status: 'not_applicable' } }), false, false],
+ ['undeliverable', deliveredRow('r', 1, at(250), { delivery: { status: 'undeliverable', message_id: null, delivered_at: null } }), false, false],
+ ['timed out', expiredRow('r'), false, false],
+ ['cancelled, note posted', cancelledRow('r', { delivery: { status: 'delivered', generation: 1, message_id: deliveryMessageId('r', 1), delivered_at: at(250) } }), false, false],
+ ];
+ for (const [label, sent, inFlight, active] of cases) {
+ const row = parseRow(sent);
+ assert.equal(isWorkflowRunInFlight(row), inFlight, `${label} in flight`);
+ assert.equal(isWorkflowRunActive(row), active, `${label} active`);
+ }
+});
+
+// ----- Fixed texts -----
+
+test('every status, waiting reason, retry block and card sentence reads as fixed text', () => {
+ const labels = {};
+ for (const status of WORKFLOW_RUN_ROW_STATUSES) {
+ const phase = { queued: 'running', running: 'running', waiting: 'needs_you', completed: 'finished', completed_partial: 'finished', failed: 'failed', expired: 'failed', cancelled: 'cancelled' }[status];
+ const row = statusRow('r', {
+ status,
+ phase,
+ waiting: status === 'waiting' ? APPROVAL : null,
+ ...(phase === 'failed' ? { error: 'It stopped.', error_code: 'failed' } : {}),
+ });
+ labels[status] = workflowRunStatusLabel(parseRow(row));
+ }
+ assert.deepEqual(labels, {
+ queued: 'Queued',
+ running: 'Running',
+ waiting: 'Needs you',
+ completed: 'Completed',
+ completed_partial: 'Partly completed',
+ failed: 'Failed',
+ cancelled: 'Cancelled',
+ expired: 'Timed out',
+ });
+
+ assert.deepEqual(Object.fromEntries(WORKFLOW_WAITING_REASONS.map((reason) => [reason, workflowWaitingText(reason)])), {
+ approval: 'Waiting for your approval.',
+ microsoft_365_reconnect: 'Reconnect Microsoft 365 to continue.',
+ microsoft_365_approval: 'Waiting for a Microsoft 365 approval.',
+ output_review: 'Waiting for you to review its output.',
+ recovery: 'Recovering after an interruption.',
+ deadline_exceeded: 'Paused. Open the run to continue.',
+ paused: 'Paused. Open the run to continue.',
+ });
+
+ assert.deepEqual(Object.fromEntries(WORKFLOW_RETRY_BLOCKED_CODES.map((code) => [code, workflowRetryBlockedText(code)])), {
+ retry_unavailable: 'Retry isn\'t available for this run right now.',
+ workflow_deleted: 'The workflow was deleted, so this run can\'t be retried.',
+ not_resumable: 'This run can\'t be retried.',
+ deadline_exceeded: 'This run reached its time limit, so it can\'t be retried.',
+ workflow_definition_changed: 'The workflow changed after this run started. Start a new run from Workflows.',
+ workflow_already_running: 'Another run of this workflow is in progress. Retry when it finishes.',
+ });
+
+ assert.equal(WORKFLOW_RETRY_TURNED_OFF_TEXT, 'Starting workflows from chat is turned off, so Retry isn\'t available here.');
+ assert.equal(WORKFLOW_RUN_CANCELLED_TEXT, 'The run was cancelled.');
+ assert.equal(WORKFLOW_RESULTS_POSTING_TEXT, 'Posting results…');
+ assert.equal(WORKFLOW_RESULTS_POSTED_TEXT, 'Results posted below');
+ assert.equal(WORKFLOW_RESULTS_POSTED_ELSEWHERE_TEXT, 'Results were posted to this chat.');
+ assert.equal(WORKFLOW_RESULTS_IN_HISTORY_TEXT, 'The results are in the workflow\'s run history.');
+ assert.equal(WORKFLOW_STATUS_READ_ERROR_TEXT, 'Couldn\'t check the run status right now. Try again.');
+ assert.equal(WORKFLOW_STATUS_HALTED_TEXT, 'Live status isn\'t available right now.');
+});
+
+test('a workflow name is kept as one line of at most 80 characters, and as text', () => {
+ const name = (workflowName) => parseRow(statusRow('r', { workflow_name: workflowName })).workflow_name;
+ assert.equal(name(' Weekly\n\tdigest '), 'Weekly digest');
+ assert.equal(name('😀'.repeat(81)), '😀'.repeat(80));
+ assert.equal([...name('😀'.repeat(81))].length, 80);
+ assert.equal(name(`${'a'.repeat(79)} b`), 'a'.repeat(79));
+ assert.equal(name(' '), 'Workflow');
+ assert.equal(name(''), 'Workflow');
+ assert.equal(name(''), '');
+ assert.equal(name('Use `code` & "quotes"'), 'Use `code` & "quotes"');
+ // Only a name that isn't text makes the row unavailable; an empty one still reads.
+ assert.equal(parseRow(statusRow('r', { workflow_name: '' })).kind, 'status');
+});
+
+test('steps and elapsed time read plainly, and only once the server counted them', () => {
+ const step = (overrides) => workflowRunStepText(parseRow(statusRow('r', overrides)));
+ assert.equal(step({}), 'Step 2 of 4');
+ assert.equal(step({ step_index: 0 }), 'Step 1 of 4');
+ assert.equal(step({ step_index: 4 }), 'Step 4 of 4');
+ assert.equal(step({ step_index: 9 }), 'Step 4 of 4');
+ assert.equal(step({ step_index: 0, step_count: 0 }), '');
+ assert.equal(step({ step_count: null }), '');
+ assert.equal(step({ step_index: null }), '');
+
+ const label = (stepLabel) => workflowRunStepLabel(parseRow(statusRow('r', { step_label: stepLabel })));
+ assert.equal(label(null), '');
+ assert.equal(label(''), '');
+ assert.equal(label(' Collect\n files '), 'Collect files');
+ assert.equal(label('Draft'), 'Draft');
+
+ const elapsed = [
+ [null, ''],
+ [-1, ''],
+ [Number.NaN, ''],
+ [Number.POSITIVE_INFINITY, ''],
+ [0, '0 s'],
+ [59.9, '59 s'],
+ [60, '1 min'],
+ [3599, '59 min'],
+ [3600, '1 h'],
+ [3660, '1 h 1 min'],
+ [7325, '2 h 2 min'],
+ ];
+ for (const [seconds, text] of elapsed) {
+ assert.equal(formatWorkflowElapsed(seconds), text, String(seconds));
+ }
+});
+
+test('the checked time is the reader\'s local time to the minute, or nothing', () => {
+ for (const value of [null, '', 'not a time']) {
+ assert.equal(formatCheckedTime(value), '');
+ }
+ const checked = at(307);
+ const shown = formatCheckedTime(checked);
+ assert.equal(shown, new Date(checked).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }));
+ assert.doesNotMatch(shown, /[:.]07(?!\d)/, 'the seconds are not shown');
+});
+
+// ----- Ids -----
+
+test('ids are checked the way the server keeps them', () => {
+ const good = ['a', 'run-1', 'x'.repeat(256), '😀'.repeat(256), 'run\u007f1', 'é'];
+ const bad = [
+ '', ' a', 'a ', '\ta', 'x'.repeat(257), '😀'.repeat(257), 'a\u0000b', 'a\u001fb', 'a\nb', '\ud800', 'a\udc00b',
+ 5, null, undefined, {}, ['a'],
+ ];
+ for (const value of good) {
+ assert.equal(isWorkflowRunIdentifier(value), true, JSON.stringify(value));
+ }
+ for (const value of bad) {
+ assert.equal(isWorkflowRunIdentifier(value), false, String(value));
+ }
+
+ const goodChats = ['chat-1', 'Chat_2', 'x'.repeat(128), 'a'];
+ const badChats = ['', 'x'.repeat(129), 'chat 1', 'chat.1', 'chat/1', 'é', ' chat', 'chat\n', 5, null, undefined];
+ for (const value of goodChats) {
+ assert.equal(isStatusConversationId(value), true, value);
+ }
+ for (const value of badChats) {
+ assert.equal(isStatusConversationId(value), false, String(value));
+ }
+});
+
+// ----- The request -----
+
+test('one batched GET reads every tracked run, or one chat\'s runs', async (t) => {
+ t.after(() => {
+ globalThis.fetch = refuseNetwork;
+ });
+ const requests = [];
+ globalThis.fetch = async (url, init) => {
+ requests.push({ url, init });
+ return jsonResponse(statusResponse([statusRow('run-1')], { truncated: true, available: false }));
+ };
+ const controller = new AbortController();
+ const all = await fetchWorkflowRunStatus(null, controller.signal);
+ await fetchWorkflowRunStatus('chat-1', controller.signal);
+ await fetchWorkflowRunStatus('a&b=c', controller.signal);
+
+ assert.deepEqual(requests.map((request) => request.url), [
+ WORKFLOW_RUN_STATUS_PATH,
+ `${WORKFLOW_RUN_STATUS_PATH}?conversation_id=chat-1`,
+ `${WORKFLOW_RUN_STATUS_PATH}?conversation_id=a%26b%3Dc`,
+ ]);
+ assert.equal(WORKFLOW_RUN_STATUS_PATH, '/api/v2/orchestration/workflow-runs/status');
+ for (const { init } of requests) {
+ assert.equal(init.method, 'GET');
+ assert.equal(init.credentials, 'same-origin');
+ assert.equal(init.signal, controller.signal);
+ assert.equal(init.body, undefined);
+ assert.equal(init.headers.Accept, 'application/json');
+ }
+ assert.equal(all.truncated, true);
+ assert.equal(all.available, false);
+ assert.equal(all.runs.length, 1, 'rows still come back when chats cannot start workflows');
+});
+
+test('a refused read raises the server\'s own error, and an unreadable one the fixed text', async (t) => {
+ t.after(() => {
+ globalThis.fetch = refuseNetwork;
+ });
+ const refusals = [
+ [503, { error: 'Workflow run status isn\'t available right now. Try again later.', code: 'workflow_run_status_unavailable' }, false],
+ [400, { error: 'The conversation ID is not valid.', code: 'invalid_conversation_id' }, false],
+ [401, { error: 'Sign in again.' }, true],
+ [403, { error: 'Workflows aren\'t available for your account.' }, true],
+ ];
+ for (const [status, body, authError] of refusals) {
+ globalThis.fetch = async () => jsonResponse(body, status);
+ await assert.rejects(fetchWorkflowRunStatus(null), (error) => {
+ assert.ok(error instanceof ApiError);
+ assert.equal(error.status, status);
+ assert.equal(error.message, body.error);
+ assert.equal(error.isAuthError, authError);
+ return true;
+ });
+ }
+
+ globalThis.fetch = async () => jsonResponse({ runs: [] });
+ await assert.rejects(fetchWorkflowRunStatus(null), { message: WORKFLOW_RUN_STATUS_INVALID_RESPONSE });
+
+ globalThis.fetch = async () => new Response('Sign in', {
+ status: 200,
+ headers: { 'content-type': 'text/html' },
+ });
+ await assert.rejects(fetchWorkflowRunStatus(null), { message: WORKFLOW_RUN_STATUS_INVALID_RESPONSE });
+});
diff --git a/functional_tests/test_v2_workflow_run_tracker.mjs b/functional_tests/test_v2_workflow_run_tracker.mjs
new file mode 100644
index 000000000..bc5d7f08a
--- /dev/null
+++ b/functional_tests/test_v2_workflow_run_tracker.mjs
@@ -0,0 +1,879 @@
+// test_v2_workflow_run_tracker.mjs
+// Version: 0.261.251
+// Implemented in: 0.261.251
+// Executes the app shell's one workflow run tracker against a fake clock, fake timers and a fake
+// status route: the visible cadence (15 s easing to every 5 minutes, one batched request per tick),
+// the hidden-tab pause and its desktop-notification exception, idle stops and kicks, back-off and
+// halts, an idempotent start and a clean stop, the first-read baseline that keeps a reload from
+// announcing a result twice (kept per chat, so one chat's read never silences another), closings
+// and retirements, and the 10-second dedupe of a chat's own reads.
+
+import assert from 'node:assert/strict';
+import nodeTest from 'node:test';
+import './test_support/tsResolve.mjs';
+import { at, deliveredRow, statusResponse, statusRow } from './test_support/workflowRunStatusFixtures.mjs';
+
+// The fake status route answers only when a test says so, so a change that sends a request a test
+// doesn't expect would leave that test waiting forever. The time limit turns that into a failure.
+const test = (name, fn) => nodeTest(name, { timeout: 10_000 }, fn);
+
+globalThis.fetch = () => {
+ throw new Error('The workflow run tracker must not make network requests of its own.');
+};
+
+// The repository resolver must be registered before extensionless TypeScript imports load.
+const {
+ WORKFLOW_RUN_CONVERSATION_DEDUPE_MS,
+ WORKFLOW_RUN_ERROR_DELAYS_MS,
+ WORKFLOW_RUN_HIDDEN_DELAY_MS,
+ WORKFLOW_RUN_POLL_DELAYS_MS,
+ createWorkflowRunTracker,
+ isTrackedRunInFlight,
+ workflowRunTrackerShouldRun,
+} = await import('../application/v2_ui/src/lib/workflowRunTracker.ts');
+const {
+ WORKFLOW_STATUS_READ_ERROR_TEXT,
+ parseWorkflowRunStatusResponse,
+} = await import('../application/v2_ui/src/lib/workflowRunStatus.ts');
+
+const flush = () => new Promise((resolve) => setImmediate(resolve));
+
+function undeliverableRow(runId, overrides = {}) {
+ return statusRow(runId, {
+ status: 'completed',
+ phase: 'finished',
+ step_index: 4,
+ completed_at: at(250),
+ actions: { cancel: false },
+ delivery: { status: 'undeliverable', generation: 2, reason: 'chat_unavailable' },
+ ...overrides,
+ });
+}
+
+function expiredRow(runId) {
+ return statusRow(runId, {
+ status: 'expired',
+ phase: 'failed',
+ error: 'It reached its time limit.',
+ error_code: 'deadline_exceeded',
+ actions: { cancel: false },
+ delivery: { status: 'expired' },
+ });
+}
+
+/**
+ * A tracker wired to a fake clock, fake timers, a fake page and a status route that answers only
+ * when the test says so. Each request is kept, with its signal, until `respond` or `fail`.
+ */
+function createHarness({ visible = true, desktop = false, onState, onDelivered, onClosed } = {}) {
+ const state = { visible, desktop, clock: 1_000_000 };
+ const timers = new Map();
+ const requests = [];
+ const listeners = new Set();
+ const counts = { subscribed: 0, unsubscribed: 0, halted: 0, maxTimers: 0, states: 0 };
+ const events = { delivered: [], closed: [], retired: [] };
+ let nextTimerId = 1;
+
+ const tracker = createWorkflowRunTracker({
+ fetchStatus(conversationId, signal) {
+ return new Promise((resolve, reject) => {
+ requests.push({ conversationId, signal, resolve, reject });
+ });
+ },
+ setTimer(callback, delayMs) {
+ const id = nextTimerId;
+ nextTimerId += 1;
+ timers.set(id, { callback, at: state.clock + delayMs });
+ counts.maxTimers = Math.max(counts.maxTimers, timers.size);
+ return id;
+ },
+ clearTimer(handle) {
+ timers.delete(handle);
+ },
+ now: () => state.clock,
+ isVisible: () => state.visible,
+ subscribeVisibility(listener) {
+ counts.subscribed += 1;
+ listeners.add(listener);
+ return () => {
+ counts.unsubscribed += 1;
+ listeners.delete(listener);
+ };
+ },
+ desktopNotificationsOn: () => state.desktop,
+ onState(snapshot) {
+ counts.states += 1;
+ onState?.(snapshot);
+ },
+ onDelivered(row) {
+ events.delivered.push(row);
+ onDelivered?.(row);
+ },
+ onClosed(row) {
+ events.closed.push(row);
+ onClosed?.(row);
+ },
+ onRetired(row) {
+ events.retired.push(row);
+ },
+ onHalted() {
+ counts.halted += 1;
+ },
+ });
+
+ function request(index) {
+ const sent = requests[index];
+ assert.ok(sent, `request ${index} should have been sent`);
+ return sent;
+ }
+
+ return {
+ tracker,
+ state,
+ timers,
+ requests,
+ counts,
+ events,
+ async respond(index, body) {
+ request(index).resolve(parseWorkflowRunStatusResponse(body));
+ await flush();
+ },
+ async fail(index, status) {
+ const error = new Error('read failed');
+ if (status !== undefined) {
+ error.status = status;
+ }
+ request(index).reject(error);
+ await flush();
+ },
+ /** Milliseconds until the one scheduled check, or null when none is. */
+ nextDelay() {
+ assert.ok(timers.size <= 1, 'the tracker keeps at most one timer');
+ const [entry] = timers.values();
+ return entry ? entry.at - state.clock : null;
+ },
+ async fire() {
+ assert.equal(timers.size, 1, 'one check should be scheduled');
+ const [[id, entry]] = timers.entries();
+ state.clock = entry.at;
+ timers.delete(id);
+ entry.callback();
+ await flush();
+ },
+ advance(ms) {
+ state.clock += ms;
+ },
+ async setVisible(value) {
+ state.visible = value;
+ for (const listener of [...listeners]) {
+ listener();
+ }
+ await flush();
+ },
+ delivered() {
+ return events.delivered.map((row) => `${row.run_id}:${row.delivery.generation}`);
+ },
+ };
+}
+
+test('the tracker runs only when the user can use workflows and chats can start them', () => {
+ assert.equal(workflowRunTrackerShouldRun({
+ allow_user_workflows: true,
+ enable_chat_orchestration_workflow_runs: true,
+ }), true);
+ assert.equal(workflowRunTrackerShouldRun({
+ allow_user_workflows: true,
+ enable_chat_orchestration_workflow_runs: true,
+ enable_chat_workflow_results: false,
+ }), true, 'a run shows its progress on its card even when results are not posted back');
+ for (const features of [
+ null,
+ undefined,
+ {},
+ { allow_user_workflows: true },
+ { enable_chat_orchestration_workflow_runs: true },
+ { allow_user_workflows: true, enable_chat_orchestration_workflow_runs: false },
+ { allow_user_workflows: true, enable_chat_orchestration_workflow_runs: undefined },
+ { allow_user_workflows: false, enable_chat_orchestration_workflow_runs: true },
+ { allow_user_workflows: 'true', enable_chat_orchestration_workflow_runs: true },
+ { allow_user_workflows: true, enable_chat_orchestration_workflow_runs: 1 },
+ ]) {
+ assert.equal(workflowRunTrackerShouldRun(features), false, String(JSON.stringify(features)));
+ }
+});
+
+test('the cadence constants are the roadmap cadence', () => {
+ assert.deepEqual([...WORKFLOW_RUN_POLL_DELAYS_MS], [15_000, 30_000, 60_000, 120_000, 300_000]);
+ assert.deepEqual([...WORKFLOW_RUN_ERROR_DELAYS_MS], [30_000, 60_000, 120_000, 300_000]);
+ assert.equal(WORKFLOW_RUN_HIDDEN_DELAY_MS, 300_000);
+ assert.equal(WORKFLOW_RUN_CONVERSATION_DEDUPE_MS, 10_000);
+});
+
+test('a visible tab with runs in flight checks after 15 s, easing to every 5 minutes, one request per tick', async () => {
+ const h = createHarness();
+ const rows = [statusRow('run-a'), statusRow('run-b'), statusRow('run-c', { conversation_id: 'chat-2' })];
+ h.tracker.start();
+ assert.equal(h.requests.length, 1, 'start checks straight away');
+ const delays = [];
+ for (let tick = 0; tick < 6; tick += 1) {
+ await h.respond(tick, statusResponse(rows, { checked_at: at(300 + tick * 400) }));
+ delays.push(h.nextDelay());
+ await h.fire();
+ assert.equal(h.requests.length, tick + 2, 'one request per tick, however many runs are in flight');
+ }
+ assert.deepEqual(delays, [15_000, 30_000, 60_000, 120_000, 300_000, 300_000]);
+ assert.ok(h.requests.every((sent) => sent.conversationId === null), 'every tick reads every chat at once');
+ assert.equal(h.counts.maxTimers, 1);
+});
+
+test('with nothing in flight it stops until a kick starts it again', async () => {
+ const h = createHarness();
+ h.tracker.start();
+ await h.respond(0, statusResponse([deliveredRow('run-a', 1, at(200))]));
+ assert.equal(h.nextDelay(), null, 'nothing in flight: no check is scheduled');
+
+ h.tracker.kick();
+ assert.equal(h.requests.length, 1, 'a kick without immediate waits for the timer');
+ assert.equal(h.nextDelay(), 15_000);
+ await h.fire();
+ assert.equal(h.requests.length, 2);
+ await h.respond(1, statusResponse([deliveredRow('run-a', 1, at(200))], { checked_at: at(330) }));
+ assert.equal(h.nextDelay(), null, 'the kick is spent once its check finds nothing in flight');
+
+ h.tracker.kick({ immediate: true });
+ assert.equal(h.requests.length, 3, 'an immediate kick checks at once');
+ assert.equal(h.requests[2].conversationId, null);
+});
+
+test('a kick brings the next check forward but never pushes it back', async () => {
+ const h = createHarness();
+ const rows = [statusRow('run-a')];
+ h.tracker.start();
+ await h.respond(0, statusResponse(rows));
+ for (let index = 1; index <= 4; index += 1) {
+ await h.fire();
+ await h.respond(index, statusResponse(rows, { checked_at: at(300 + index * 400) }));
+ }
+ assert.equal(h.nextDelay(), 300_000);
+ h.tracker.kick();
+ assert.equal(h.nextDelay(), 15_000, 'a kick restarts the ladder');
+ h.advance(10_000);
+ h.tracker.kick();
+ assert.equal(h.nextDelay(), 5_000, 'a second kick keeps the sooner check');
+ assert.equal(h.requests.length, 5);
+});
+
+test('a kick during a read sends no second request; the read schedules the next check', async () => {
+ const h = createHarness();
+ h.tracker.start();
+ h.tracker.kick();
+ h.tracker.kick({ immediate: true });
+ assert.equal(h.requests.length, 1);
+ assert.equal(h.nextDelay(), null, 'no timer runs while a read is under way');
+ await h.respond(0, statusResponse([]));
+ assert.equal(h.nextDelay(), 15_000, 'the read may predate the new run, so another check follows');
+ await h.fire();
+ await h.respond(1, statusResponse([], { checked_at: at(330) }));
+ assert.equal(h.nextDelay(), null);
+});
+
+test("a chat's read that finds a run in flight starts the checks again", async () => {
+ const h = createHarness();
+ h.tracker.start();
+ await h.respond(0, statusResponse([]));
+ assert.equal(h.nextDelay(), null);
+ const read = h.tracker.requestConversationRuns('chat-1');
+ await h.respond(1, statusResponse([statusRow('run-a')], { checked_at: at(310) }));
+ assert.equal(await read, true);
+ assert.equal(h.nextDelay(), 15_000);
+ await h.fire();
+ assert.equal(h.requests[2].conversationId, null, 'the checks that follow read every chat at once');
+});
+
+test('a hidden tab pauses and checks as soon as it is shown again, from the start of the ladder', async () => {
+ const h = createHarness();
+ const rows = [statusRow('run-a')];
+ h.tracker.start();
+ await h.setVisible(false);
+ await h.setVisible(true);
+ assert.equal(h.requests.length, 1, 'showing the tab during a read sends no second one');
+ await h.respond(0, statusResponse(rows));
+ await h.fire();
+ await h.respond(1, statusResponse(rows, { checked_at: at(400) }));
+ assert.equal(h.nextDelay(), 30_000);
+
+ await h.setVisible(false);
+ assert.equal(h.nextDelay(), null, 'hidden: paused');
+ h.advance(3_600_000);
+ assert.equal(h.requests.length, 2);
+ await h.setVisible(true);
+ assert.equal(h.requests.length, 3, 'shown: it checks at once');
+ await h.respond(2, statusResponse(rows, { checked_at: at(4_000) }));
+ assert.equal(h.nextDelay(), 15_000, 'and the ladder starts over');
+});
+
+test('showing the tab checks only when something is in flight, failing or asked for', async () => {
+ const h = createHarness();
+ h.tracker.start();
+ await h.respond(0, statusResponse([deliveredRow('run-a', 1, at(200))]));
+ await h.setVisible(false);
+ await h.setVisible(true);
+ assert.equal(h.requests.length, 1, 'nothing in flight: showing the tab sends nothing');
+ assert.equal(h.nextDelay(), null);
+
+ await h.setVisible(false);
+ h.tracker.kick();
+ assert.equal(h.nextDelay(), null, 'hidden: the kick waits for the tab');
+ await h.setVisible(true);
+ assert.equal(h.requests.length, 2, 'a kick that came while hidden is checked once the tab is shown');
+});
+
+test('a hidden tab keeps a 5-minute check only while desktop notifications are on and a run is in flight', async () => {
+ const h = createHarness({ desktop: true });
+ h.tracker.start();
+ await h.respond(0, statusResponse([statusRow('run-a')]));
+ assert.equal(h.nextDelay(), 15_000);
+ await h.setVisible(false);
+ assert.equal(h.nextDelay(), 300_000);
+ await h.fire();
+ assert.equal(h.requests.length, 2);
+ await h.respond(1, statusResponse([statusRow('run-a')], { checked_at: at(600) }));
+ assert.equal(h.nextDelay(), 300_000, 'every 5 minutes while hidden');
+ await h.fire();
+ await h.respond(2, statusResponse([deliveredRow('run-a', 1, at(850))], { checked_at: at(900) }));
+ assert.equal(h.nextDelay(), null, 'nothing in flight: a hidden tab stops checking');
+
+ const off = createHarness({ desktop: true });
+ off.tracker.start();
+ await off.respond(0, statusResponse([statusRow('run-a')]));
+ await off.setVisible(false);
+ assert.equal(off.nextDelay(), 300_000);
+ off.state.desktop = false;
+ await off.fire();
+ await off.respond(1, statusResponse([statusRow('run-a')], { checked_at: at(600) }));
+ assert.equal(off.nextDelay(), null, 'desktop notifications turned off: a hidden tab is paused');
+});
+
+test('a tracker started in a hidden tab waits for the tab unless desktop notifications are on', async () => {
+ const hidden = createHarness({ visible: false });
+ hidden.tracker.start();
+ assert.equal(hidden.requests.length, 0);
+ assert.equal(hidden.nextDelay(), null);
+ hidden.tracker.kick({ immediate: true });
+ assert.equal(hidden.requests.length, 0, 'an immediate kick in a hidden tab waits too');
+ assert.equal(hidden.nextDelay(), null);
+ await hidden.setVisible(true);
+ assert.equal(hidden.requests.length, 1, 'shown: it checks at once');
+
+ const desktop = createHarness({ visible: false, desktop: true });
+ desktop.tracker.start();
+ assert.equal(desktop.requests.length, 1, 'desktop notifications on: a hidden tab checks at once');
+});
+
+test('starting a running tracker adds no second subscription, request or timer', async () => {
+ const h = createHarness();
+ h.tracker.start();
+ h.tracker.start();
+ assert.equal(h.counts.subscribed, 1);
+ assert.equal(h.requests.length, 1);
+ await h.respond(0, statusResponse([statusRow('run-a')]));
+ h.tracker.start();
+ assert.equal(h.requests.length, 1);
+ assert.equal(h.timers.size, 1);
+ assert.equal(h.counts.maxTimers, 1);
+ assert.equal(h.counts.subscribed, 1);
+});
+
+test('stop aborts every read, drops the timer and ignores late answers', async () => {
+ const h = createHarness();
+ h.tracker.start();
+ const chatRead = h.tracker.requestConversationRuns('chat-1');
+ assert.equal(h.requests.length, 2);
+ assert.equal(h.tracker.getSnapshot().conversations['chat-1'].reading, true);
+
+ h.tracker.stop();
+ assert.equal(h.requests[0].signal.aborted, true);
+ assert.equal(h.requests[1].signal.aborted, true);
+ assert.equal(h.counts.unsubscribed, 1);
+ let snapshot = h.tracker.getSnapshot();
+ assert.equal(snapshot.running, false);
+ assert.equal(snapshot.conversations['chat-1'].reading, false);
+
+ await h.respond(0, statusResponse([statusRow('run-a')]));
+ await h.respond(1, statusResponse([statusRow('run-a')]));
+ assert.equal(await chatRead, false);
+ snapshot = h.tracker.getSnapshot();
+ assert.deepEqual(snapshot.runs, {}, 'a late answer changes nothing');
+ assert.equal(h.nextDelay(), null);
+
+ h.tracker.kick();
+ h.tracker.kick({ immediate: true });
+ assert.equal(await h.tracker.requestConversationRuns('chat-1'), false);
+ await h.setVisible(false);
+ await h.setVisible(true);
+ assert.equal(h.requests.length, 2, 'a stopped tracker sends nothing');
+ assert.equal(h.nextDelay(), null);
+});
+
+test('stopping and starting again keeps what the page session already saw', async () => {
+ const h = createHarness();
+ h.tracker.start();
+ await h.respond(0, statusResponse([deliveredRow('run-a', 1, at(200)), statusRow('run-b')]));
+ assert.equal(h.nextDelay(), 15_000);
+ h.tracker.stop();
+ assert.equal(h.nextDelay(), null, 'stop drops the timer');
+ h.tracker.start();
+ assert.equal(h.counts.subscribed, 2);
+ assert.equal(h.requests.length, 2);
+ await h.respond(1, statusResponse([
+ deliveredRow('run-a', 1, at(200)),
+ deliveredRow('run-b', 1, at(320)),
+ ], { checked_at: at(330) }));
+ assert.deepEqual(h.delivered(), ['run-b:1'], 'only the posting that is new to this page session');
+});
+
+test('failed reads back off to every 5 minutes without a tight loop, then recover', async () => {
+ const h = createHarness();
+ h.tracker.start();
+ const delays = [];
+ for (let index = 0; index < 5; index += 1) {
+ await h.fail(index, 503);
+ delays.push(h.nextDelay());
+ assert.equal(h.tracker.getSnapshot().globalError, true);
+ await h.fire();
+ }
+ assert.deepEqual(delays, [30_000, 60_000, 120_000, 300_000, 300_000]);
+ assert.equal(h.requests.length, 6);
+
+ await h.setVisible(false);
+ assert.equal(h.nextDelay(), null, 'hidden: paused, even while failing');
+ await h.setVisible(true);
+ assert.equal(h.requests.length, 6, 'a read is already under way');
+ await h.respond(5, statusResponse([statusRow('run-a')]));
+ const snapshot = h.tracker.getSnapshot();
+ assert.equal(snapshot.globalError, false);
+ assert.equal(snapshot.halted, false);
+ assert.equal(h.nextDelay(), 15_000);
+});
+
+test('a failing tracker that is hidden and shown again checks at once', async () => {
+ const h = createHarness();
+ h.tracker.start();
+ await h.fail(0, 503);
+ await h.setVisible(false);
+ assert.equal(h.nextDelay(), null);
+ await h.setVisible(true);
+ assert.equal(h.requests.length, 2);
+});
+
+test('the error back-off keeps the ladder position', async () => {
+ const h = createHarness();
+ const rows = [statusRow('run-a')];
+ h.tracker.start();
+ await h.respond(0, statusResponse(rows));
+ await h.fire();
+ await h.fail(1, 503);
+ assert.equal(h.nextDelay(), 30_000);
+ await h.fire();
+ await h.respond(2, statusResponse(rows, { checked_at: at(400) }));
+ assert.equal(h.nextDelay(), 30_000, 'it resumes where it was, not from the start or a step further on');
+ await h.fire();
+ await h.respond(3, statusResponse(rows, { checked_at: at(500) }));
+ assert.equal(h.nextDelay(), 60_000);
+});
+
+test('a kick keeps the back-off timer, Check now still checks, and any failure counts', async () => {
+ const h = createHarness();
+ h.tracker.start();
+ await h.fail(0, 503);
+ h.advance(10_000);
+ h.tracker.kick();
+ assert.equal(h.nextDelay(), 20_000, 'a kick does not cut the back-off short');
+ assert.equal(h.requests.length, 1);
+ h.tracker.kick({ immediate: true });
+ assert.equal(h.requests.length, 2, 'an immediate kick (Check now) checks at once');
+
+ const network = createHarness();
+ network.tracker.start();
+ await network.fail(0);
+ const snapshot = network.tracker.getSnapshot();
+ assert.equal(snapshot.halted, false);
+ assert.equal(snapshot.globalError, true);
+ assert.equal(network.nextDelay(), 30_000, 'a failure without a status backs off too');
+});
+
+for (const status of [401, 403, 400]) {
+ test(`a ${status} on the read of every chat halts the tracker for the page session`, async () => {
+ const h = createHarness();
+ h.tracker.start();
+ const chatRead = h.tracker.requestConversationRuns('chat-1');
+ await h.fail(0, status);
+ const snapshot = h.tracker.getSnapshot();
+ assert.equal(snapshot.halted, true);
+ assert.equal(h.counts.halted, 1);
+ assert.equal(h.requests[1].signal.aborted, true, 'a halt aborts the reads under way');
+ assert.equal(snapshot.conversations['chat-1'].reading, false);
+ await h.respond(1, statusResponse([statusRow('run-a')]));
+ assert.equal(await chatRead, false);
+ assert.deepEqual(h.tracker.getSnapshot().runs, {});
+ assert.equal(h.nextDelay(), null);
+
+ h.tracker.kick();
+ h.tracker.kick({ immediate: true });
+ await h.setVisible(false);
+ await h.setVisible(true);
+ assert.equal(await h.tracker.requestConversationRuns('chat-1'), false);
+ h.tracker.start();
+ assert.equal(h.requests.length, 2, 'a halted tracker sends nothing, even when started again');
+ assert.equal(h.nextDelay(), null);
+ assert.equal(h.counts.halted, 1);
+
+ h.tracker.stop();
+ h.tracker.start();
+ assert.equal(h.tracker.getSnapshot().halted, false, 'a fresh start clears the halt');
+ assert.equal(h.requests.length, 3);
+ });
+}
+
+test("a failed read of one chat shows its error on that chat only and does not halt", async () => {
+ const h = createHarness({ visible: false });
+ h.tracker.start();
+ let index = 0;
+ for (const status of [400, 503, undefined]) {
+ const read = h.tracker.requestConversationRuns('chat-1');
+ assert.equal(h.requests.length, index + 1, 'a failed read is never deduped');
+ assert.equal(h.requests[index].conversationId, 'chat-1');
+ await h.fail(index, status);
+ assert.equal(await read, false);
+ const snapshot = h.tracker.getSnapshot();
+ assert.equal(snapshot.halted, false);
+ assert.deepEqual(snapshot.conversations['chat-1'], {
+ checkedAt: null,
+ reading: false,
+ error: WORKFLOW_STATUS_READ_ERROR_TEXT,
+ });
+ assert.equal(snapshot.globalError, false, "one chat's failure is not the tracker's");
+ index += 1;
+ }
+ const good = h.tracker.requestConversationRuns('chat-1');
+ await h.respond(index, statusResponse([statusRow('run-a')], { checked_at: at(310) }));
+ assert.equal(await good, true);
+ assert.deepEqual(h.tracker.getSnapshot().conversations['chat-1'], {
+ checkedAt: at(310),
+ reading: false,
+ error: null,
+ });
+ assert.equal(h.nextDelay(), null, 'hidden with desktop notifications off: nothing is scheduled');
+});
+
+for (const status of [401, 403]) {
+ test(`a ${status} on one chat's read halts the tracker`, async () => {
+ const h = createHarness({ visible: false });
+ h.tracker.start();
+ const read = h.tracker.requestConversationRuns('chat-1');
+ await h.fail(0, status);
+ assert.equal(await read, false);
+ const snapshot = h.tracker.getSnapshot();
+ assert.equal(snapshot.halted, true);
+ assert.equal(h.counts.halted, 1);
+ assert.equal(snapshot.conversations['chat-1'].error, WORKFLOW_STATUS_READ_ERROR_TEXT);
+ await h.setVisible(true);
+ assert.equal(h.requests.length, 1);
+ });
+}
+
+test('the first read of a page session records what is already posted and announces none of it', async () => {
+ const posted = [
+ deliveredRow('run-a', 2, at(200)),
+ deliveredRow('run-b', 1, at(300), { conversation_id: 'chat-2' }),
+ ];
+ const h = createHarness();
+ h.tracker.start();
+ await h.respond(0, statusResponse([...posted, statusRow('run-c')]));
+ assert.deepEqual(h.events.delivered, [], 'already posted, even in the same second as the read');
+ await h.fire();
+ await h.respond(1, statusResponse([...posted, statusRow('run-c')], { checked_at: at(330) }));
+ assert.deepEqual(h.events.delivered, [], 'and still silent on the next read');
+
+ // A reload starts a new tracker, whose first read is silent too.
+ const reloaded = createHarness();
+ reloaded.tracker.start();
+ await reloaded.respond(0, statusResponse(posted, { checked_at: at(400) }));
+ reloaded.tracker.kick({ immediate: true });
+ await reloaded.respond(1, statusResponse(posted, { checked_at: at(430) }));
+ assert.deepEqual(reloaded.events.delivered, []);
+});
+
+test('a posting seen later in the page session is announced once per generation, after the state is published', async () => {
+ const statusWhenAnnounced = [];
+ const h = createHarness({
+ onDelivered(row) {
+ statusWhenAnnounced.push(h.tracker.getSnapshot().runs[row.run_id].row.delivery.status);
+ },
+ });
+ h.tracker.start();
+ await h.respond(0, statusResponse([statusRow('run-a'), statusRow('run-b')]));
+ await h.fire();
+ await h.respond(1, statusResponse([
+ deliveredRow('run-a', 4, at(310)),
+ deliveredRow('run-b', 1, at(310), { delivery: { message_id: null } }),
+ ], { checked_at: at(320) }));
+ assert.deepEqual(h.delivered(), ['run-a:4'], 'a posting without a message id is never announced');
+ assert.deepEqual(statusWhenAnnounced, ['delivered'], 'readers see the new state when they are told');
+
+ h.tracker.kick({ immediate: true });
+ await h.respond(2, statusResponse([deliveredRow('run-a', 4, at(310))], { checked_at: at(350) }));
+ assert.equal(h.events.delivered.length, 1, 'the same posting is announced once');
+
+ // Retried: the run runs again and posts under a new generation.
+ h.tracker.kick({ immediate: true });
+ await h.respond(3, statusResponse([statusRow('run-a', { runtime_version: 5 })], { checked_at: at(400) }));
+ await h.fire();
+ await h.respond(4, statusResponse([deliveredRow('run-a', 6, at(420))], { checked_at: at(430) }));
+ assert.deepEqual(h.delivered(), ['run-a:4', 'run-a:6']);
+});
+
+test('a posting in the same second as the first read, but missing from it, is announced', async () => {
+ const h = createHarness();
+ h.tracker.start();
+ await h.respond(0, statusResponse([], { checked_at: at(300) }));
+ h.tracker.kick({ immediate: true });
+ await h.respond(1, statusResponse([
+ deliveredRow('run-new', 1, at(300)),
+ deliveredRow('run-old', 1, at(299)),
+ ], { checked_at: at(330) }));
+ assert.deepEqual(h.delivered(), ['run-new:1'], 'posted before the first read: already there, so silent');
+});
+
+test('a run seen before its result was posted is announced once, even when the posting time is behind the first read', async () => {
+ // Server instances' clocks can differ, so a posting's time can fall before the read that saw
+ // the run still waiting to post.
+ const h = createHarness();
+ h.tracker.start();
+ await h.respond(0, statusResponse([statusRow('run-a')], { checked_at: at(300) }));
+ assert.deepEqual(h.events.delivered, []);
+ await h.fire();
+ const posted = [deliveredRow('run-a', 1, at(299)), deliveredRow('run-b', 1, at(299))];
+ await h.respond(1, statusResponse(posted, { checked_at: at(330) }));
+ assert.deepEqual(h.delivered(), ['run-a:1'], 'run-b was never seen unposted, so it was posted before the first read');
+ h.tracker.kick({ immediate: true });
+ await h.respond(2, statusResponse(posted, { checked_at: at(360) }));
+ assert.deepEqual(h.delivered(), ['run-a:1'], 'announced exactly once');
+
+ // The same through a chat's own reads, against that chat's first read.
+ const chat = createHarness({ visible: false });
+ chat.tracker.start();
+ const first = chat.tracker.requestConversationRuns('chat-1');
+ await chat.respond(0, statusResponse([statusRow('run-c')], { checked_at: at(300) }));
+ assert.equal(await first, true);
+ chat.advance(WORKFLOW_RUN_CONVERSATION_DEDUPE_MS);
+ const second = chat.tracker.requestConversationRuns('chat-1');
+ assert.equal(chat.requests[1].conversationId, 'chat-1');
+ await chat.respond(1, statusResponse([deliveredRow('run-c', 2, at(299))], { checked_at: at(330) }));
+ assert.equal(await second, true);
+ assert.deepEqual(chat.delivered(), ['run-c:2']);
+});
+
+test("one chat's own read never silences another chat's first listing", async () => {
+ const h = createHarness({ visible: false });
+ h.tracker.start();
+ const chatRead = h.tracker.requestConversationRuns('chat-a');
+ await h.respond(0, statusResponse([statusRow('run-a1', { conversation_id: 'chat-a' })], { checked_at: at(300) }));
+ assert.equal(await chatRead, true);
+ assert.equal(h.requests.length, 1, 'hidden with desktop notifications off: no read of every chat yet');
+
+ await h.setVisible(true);
+ assert.equal(h.requests.length, 2);
+ assert.equal(h.requests[1].conversationId, null);
+ await h.respond(1, statusResponse([
+ deliveredRow('run-b1', 1, at(300), { conversation_id: 'chat-b' }),
+ deliveredRow('run-a1', 1, at(300), { conversation_id: 'chat-a' }),
+ ], { checked_at: at(310) }));
+ assert.deepEqual(h.delivered(), ['run-a1:1'], "chat-b's first listing is silent; chat-a's posting is new");
+});
+
+test("a chat's own first read records what is already posted there; later postings are announced", async () => {
+ const h = createHarness({ visible: false });
+ h.tracker.start();
+ const first = h.tracker.requestConversationRuns('chat-1');
+ await h.respond(0, statusResponse([deliveredRow('run-x', 1, at(100))], { checked_at: at(300) }));
+ assert.equal(await first, true);
+ assert.deepEqual(h.events.delivered, []);
+ h.advance(WORKFLOW_RUN_CONVERSATION_DEDUPE_MS);
+ const second = h.tracker.requestConversationRuns('chat-1');
+ await h.respond(1, statusResponse([
+ deliveredRow('run-y', 2, at(305)),
+ deliveredRow('run-x', 1, at(100)),
+ ], { checked_at: at(310) }));
+ assert.equal(await second, true);
+ assert.deepEqual(h.delivered(), ['run-y:2']);
+});
+
+test('only a complete read of every chat retires a run that dropped out, and only once', async () => {
+ const h = createHarness();
+ h.tracker.start();
+ await h.respond(0, statusResponse([statusRow('run-a'), statusRow('run-b')]));
+ await h.fire();
+ await h.respond(1, statusResponse([statusRow('run-b')], { checked_at: at(330), truncated: true }));
+ let snapshot = h.tracker.getSnapshot();
+ assert.equal(snapshot.runs['run-a'].retired, false, 'a truncated read leaves rows out, so it retires nothing');
+ assert.equal(snapshot.globalCheckedAt, at(300), 'a truncated read is not a complete read');
+
+ const chatRead = h.tracker.requestConversationRuns('chat-1');
+ await h.respond(2, statusResponse([], { checked_at: at(340) }));
+ assert.equal(await chatRead, true);
+ assert.equal(h.tracker.getSnapshot().runs['run-a'].retired, false, "a chat's own read is capped, so it retires nothing");
+ assert.deepEqual(h.events.retired, []);
+
+ await h.fire();
+ await h.respond(3, statusResponse([statusRow('run-b')], { checked_at: at(370) }));
+ snapshot = h.tracker.getSnapshot();
+ assert.equal(snapshot.runs['run-a'].retired, true);
+ assert.equal(isTrackedRunInFlight(snapshot.runs['run-a']), false);
+ assert.equal(isTrackedRunInFlight(undefined), false);
+ assert.equal(snapshot.runs['run-a'].checkedAt, at(300), 'a retired run keeps the read it came from');
+ assert.equal(snapshot.globalCheckedAt, at(370));
+ assert.deepEqual(h.events.retired.map((row) => row.run_id), ['run-a']);
+
+ h.tracker.kick({ immediate: true });
+ await h.respond(4, statusResponse([statusRow('run-b')], { checked_at: at(400) }));
+ assert.equal(h.events.retired.length, 1, 'a retired run is retired once');
+
+ // A chat's read listed run-c in the same second as a complete read that does not list it: the
+ // complete read is no newer, so it says nothing about run-c.
+ const otherChat = h.tracker.requestConversationRuns('chat-2');
+ await h.respond(5, statusResponse([statusRow('run-c', { conversation_id: 'chat-2' })], { checked_at: at(430) }));
+ assert.equal(await otherChat, true);
+ h.tracker.kick({ immediate: true });
+ await h.respond(6, statusResponse([statusRow('run-b')], { checked_at: at(430) }));
+ assert.equal(h.tracker.getSnapshot().runs['run-c'].retired, false);
+
+ h.tracker.kick({ immediate: true });
+ await h.respond(7, statusResponse([], { checked_at: at(460) }));
+ snapshot = h.tracker.getSnapshot();
+ assert.equal(snapshot.runs['run-b'].retired, true);
+ assert.equal(snapshot.runs['run-c'].retired, true);
+ assert.deepEqual(h.events.retired.map((row) => row.run_id), ['run-a', 'run-b', 'run-c']);
+ assert.equal(h.nextDelay(), null, 'nothing in flight: no check is scheduled');
+});
+
+test("a chat's own read is shared while under way and stands for 10 seconds after a good answer", async () => {
+ const h = createHarness({ visible: false });
+ h.tracker.start();
+ const first = h.tracker.requestConversationRuns('chat-1');
+ assert.equal(h.tracker.requestConversationRuns('chat-1'), first, 'a read under way is shared');
+ assert.equal(h.requests.length, 1);
+ assert.equal(h.requests[0].conversationId, 'chat-1');
+ await h.respond(0, statusResponse([deliveredRow('run-a', 1, at(200))]));
+ assert.equal(await first, true);
+
+ // Counted before awaiting, so a read that went out fails here rather than waiting on an answer.
+ const repeat = h.tracker.requestConversationRuns('chat-1');
+ assert.equal(h.requests.length, 1, 'right after a good answer no second request is sent');
+ assert.equal(await repeat, true);
+ h.advance(WORKFLOW_RUN_CONVERSATION_DEDUPE_MS - 1);
+ const stillFresh = h.tracker.requestConversationRuns('chat-1');
+ assert.equal(h.requests.length, 1, 'within 10 s the last good read stands');
+ assert.equal(await stillFresh, true);
+ h.advance(1);
+ const later = h.tracker.requestConversationRuns('chat-1');
+ assert.equal(h.requests.length, 2, 'after 10 s it reads again');
+ await h.respond(1, statusResponse([deliveredRow('run-a', 1, at(200))], { checked_at: at(310) }));
+ assert.equal(await later, true);
+
+ const forced = h.tracker.requestConversationRuns('chat-1', { force: true });
+ assert.equal(h.requests.length, 3, 'a forced read skips the dedupe');
+ const replacement = h.tracker.requestConversationRuns('chat-1', { force: true });
+ assert.equal(h.requests[2].signal.aborted, true, 'a forced read replaces the one under way');
+ assert.equal(h.requests.length, 4);
+ await h.respond(2, statusResponse([statusRow('run-late')], { checked_at: at(320) }));
+ assert.equal(await forced, false);
+ assert.equal(h.tracker.getSnapshot().runs['run-late'], undefined, 'the replaced read changes nothing');
+ await h.respond(3, statusResponse([deliveredRow('run-a', 1, at(200))], { checked_at: at(330) }));
+ assert.equal(await replacement, true);
+
+ const other = h.tracker.requestConversationRuns('chat-2');
+ assert.equal(h.requests.length, 5, 'another chat is read on its own');
+ assert.equal(h.requests[4].conversationId, 'chat-2');
+ await h.respond(4, statusResponse([], { checked_at: at(340) }));
+ assert.equal(await other, true);
+
+ for (const invalid of ['', 'has space', ' chat-1', 'chat-1 ', 'x/y', 'chat?x=1', 'a'.repeat(129), null, undefined, 42]) {
+ assert.equal(await h.tracker.requestConversationRuns(invalid), false, String(invalid));
+ }
+ assert.equal(h.requests.length, 5, 'an id the route would refuse is never sent');
+});
+
+test("a chat's read speaks for that chat only, and an older answer never overwrites a newer one", async () => {
+ const h = createHarness();
+ h.tracker.start();
+ const chatRead = h.tracker.requestConversationRuns('chat-1');
+ await h.respond(0, statusResponse([statusRow('run-a')], { checked_at: at(320) }));
+ await h.respond(1, statusResponse([
+ statusRow('run-a', { status: 'queued' }),
+ statusRow('run-z', { conversation_id: 'chat-2' }),
+ ], { checked_at: at(310), available: false }));
+ assert.equal(await chatRead, true);
+ let snapshot = h.tracker.getSnapshot();
+ assert.equal(snapshot.runs['run-a'].row.status, 'running', 'the older answer is ignored');
+ assert.equal(snapshot.runs['run-a'].checkedAt, at(320));
+ assert.equal(snapshot.runs['run-z'], undefined, "another chat's row in a chat's read is ignored");
+ assert.equal(snapshot.available, true, 'availability follows the newest answer');
+ assert.equal(snapshot.conversations['chat-1'].checkedAt, at(310));
+
+ h.tracker.kick({ immediate: true });
+ await h.respond(2, statusResponse([statusRow('run-a')], { checked_at: at(350), available: false }));
+ snapshot = h.tracker.getSnapshot();
+ assert.equal(snapshot.available, false);
+ assert.equal(snapshot.runs['run-a'].checkedAt, at(350));
+});
+
+test('a run seen in flight that can no longer post to its chat is reported once', async () => {
+ const h = createHarness();
+ h.tracker.start();
+ await h.respond(0, statusResponse([statusRow('run-a'), statusRow('run-d'), undeliverableRow('run-b')]));
+ assert.deepEqual(h.events.closed, [], 'nothing is reported from the first read');
+ await h.fire();
+ const closedRows = [
+ undeliverableRow('run-a'),
+ expiredRow('run-d'),
+ undeliverableRow('run-b'),
+ undeliverableRow('run-e'),
+ ];
+ await h.respond(1, statusResponse(closedRows, { checked_at: at(330) }));
+ assert.deepEqual(
+ h.events.closed.map((row) => `${row.run_id}:${row.delivery.status}`),
+ ['run-a:undeliverable', 'run-d:expired'],
+ 'never for a run that was not seen in flight',
+ );
+ assert.equal(h.nextDelay(), null);
+ h.tracker.kick({ immediate: true });
+ await h.respond(2, statusResponse(closedRows, { checked_at: at(360) }));
+ assert.equal(h.events.closed.length, 2, 'each closing is reported once');
+ assert.deepEqual(h.events.delivered, []);
+});
+
+test("a reader's failure never stops the tracker", async () => {
+ const h = createHarness({
+ onState() {
+ throw new Error('state reader failed');
+ },
+ onDelivered() {
+ throw new Error('delivery reader failed');
+ },
+ });
+ h.tracker.start();
+ await h.respond(0, statusResponse([statusRow('run-a'), statusRow('run-b')]));
+ assert.equal(h.nextDelay(), 15_000);
+ await h.fire();
+ await h.respond(1, statusResponse([
+ deliveredRow('run-a', 1, at(310)),
+ undeliverableRow('run-b'),
+ statusRow('run-c'),
+ ], { checked_at: at(330) }));
+ assert.ok(h.counts.states > 0);
+ assert.deepEqual(h.delivered(), ['run-a:1']);
+ assert.deepEqual(h.events.closed.map((row) => row.run_id), ['run-b'], 'the next reader is still told');
+ assert.equal(h.nextDelay(), 30_000, 'and the next check is still scheduled');
+ assert.equal(h.tracker.getSnapshot().runs['run-a'].row.delivery.status, 'delivered');
+});
diff --git a/functional_tests/test_v2_workflow_run_tracking_xss_guardrail.py b/functional_tests/test_v2_workflow_run_tracking_xss_guardrail.py
new file mode 100644
index 000000000..df9b57446
--- /dev/null
+++ b/functional_tests/test_v2_workflow_run_tracking_xss_guardrail.py
@@ -0,0 +1,239 @@
+#!/usr/bin/env python3
+# test_v2_workflow_run_tracking_xss_guardrail.py
+"""
+Functional test for the V2 chat workflow run tracking passing the XSS sink guardrail.
+Version: 0.261.251
+Implemented in: 0.261.251
+
+This test ensures that the files behind a chat-started workflow run in V2 pass
+scripts/check_xss_sinks.py in full: the run card under a plan's answer, the
+app-shell tracker and its store, the running tag in the chat list, the footer on
+a delivered message, and the next and last run on the recurring workflow card.
+None of the changed V2 files renders raw HTML, every link they render is built
+by the reviewed workflowRunHref builder or is the fixed M365_CONNECT_HREF path,
+and the run status rows carry ids, never a URL. The checker approves an
+upper-case constant by its name alone, so the constant's value is checked here
+too. App.tsx and MessageList.tsx are checked by CI on changed lines only,
+because each has one older finding outside this work. Synthetic snippets show
+that the checker still flags a link or HTML taken from a status row's own
+fields.
+"""
+
+import importlib.util
+import re
+import sys
+from pathlib import Path
+
+import pytest
+
+sys.path.append(str(Path(__file__).resolve().parent))
+
+from test_support.versioning import assert_app_version_at_least
+
+
+ROOT_DIR = Path(__file__).resolve().parents[1]
+V2_SRC_DIR = ROOT_DIR / 'application' / 'v2_ui' / 'src'
+CHAT_COMPONENTS_DIR = V2_SRC_DIR / 'components' / 'chat'
+LIB_DIR = V2_SRC_DIR / 'lib'
+STORES_DIR = V2_SRC_DIR / 'stores'
+XSS_CHECKER_FILE = ROOT_DIR / 'scripts' / 'check_xss_sinks.py'
+
+RUN_CARD = CHAT_COMPONENTS_DIR / 'WorkflowRunCard.tsx'
+DELIVERY_FOOTER = CHAT_COMPONENTS_DIR / 'WorkflowDeliveryFooter.tsx'
+RUNNING_TAG = CHAT_COMPONENTS_DIR / 'WorkflowRunningTag.tsx'
+PROPOSAL_RUN_SUMMARY = CHAT_COMPONENTS_DIR / 'WorkflowProposalRunSummary.tsx'
+RUN_LINKS = CHAT_COMPONENTS_DIR / 'WorkflowRunLinks.tsx'
+PROPOSAL_CARD = CHAT_COMPONENTS_DIR / 'WorkflowProposalCard.tsx'
+M365_LINKS = LIB_DIR / 'm365Links.ts'
+NOTIFICATION_LINKS = LIB_DIR / 'notificationLinks.ts'
+RUN_STATUS = LIB_DIR / 'workflowRunStatus.ts'
+DELIVERY = LIB_DIR / 'workflowDelivery.ts'
+TRACKER = LIB_DIR / 'workflowRunTracker.ts'
+TRACKER_STORE = STORES_DIR / 'workflowRunTrackerStore.ts'
+
+NEW_FILES = (
+ RUN_CARD,
+ DELIVERY_FOOTER,
+ RUNNING_TAG,
+ PROPOSAL_RUN_SUMMARY,
+ M365_LINKS,
+ LIB_DIR / 'useFocusFallback.ts',
+ LIB_DIR / 'useWorkflowRunAction.ts',
+ LIB_DIR / 'useWorkflowRunTracker.ts',
+ DELIVERY,
+ LIB_DIR / 'workflowRunActions.ts',
+ RUN_STATUS,
+ TRACKER,
+ TRACKER_STORE,
+)
+
+# Changed files with no older findings, so they're checked in full like the new ones.
+CLEAN_CHANGED_FILES = (
+ CHAT_COMPONENTS_DIR / 'ConversationRail.tsx',
+ CHAT_COMPONENTS_DIR / 'MessageActions.tsx',
+ PROPOSAL_CARD,
+ RUN_LINKS,
+ NOTIFICATION_LINKS,
+ LIB_DIR / 'notifications.ts',
+ LIB_DIR / 'workflowAlertNotices.ts',
+ LIB_DIR / 'workflowEditor.ts',
+ LIB_DIR / 'workflowRunLink.ts',
+ STORES_DIR / 'chatStore.ts',
+)
+
+# Each has one older finding outside this work, so CI checks their changed lines instead.
+CHANGED_LINES_ONLY_FILES = (
+ V2_SRC_DIR / 'App.tsx',
+ CHAT_COMPONENTS_DIR / 'MessageList.tsx',
+)
+
+LINK_COMPONENTS = (RUN_CARD, DELIVERY_FOOTER, RUNNING_TAG, PROPOSAL_RUN_SUMMARY, RUN_LINKS)
+RAW_HTML_SINKS = ('dangerouslySetInnerHTML', '.innerHTML', '.outerHTML', 'insertAdjacentHTML', 'document.write')
+LINK_ATTRIBUTE_RE = re.compile(r'(?
+ Open run
+ Reconnect Microsoft 365
+
+ >
+ );
+}
+"""
+
+APPROVED_ROW_SNIPPET = """
+import { Link } from 'react-router-dom';
+import { M365_CONNECT_HREF } from '../../lib/m365Links';
+import { workflowRunHref } from '../../lib/workflowRunLink';
+
+export function Approved({ row }: { row: { workflow_id: string; run_id: string; error: string } }) {
+ return (
+ <>
+ Open run
+ Reconnect Microsoft 365
+
{row.error}
+ >
+ );
+}
+"""
+
+
+def read_text(path: Path) -> str:
+ """Read a UTF-8 text file from the repository."""
+ return path.read_text(encoding='utf-8')
+
+
+def load_xss_checker_module():
+ """Import the XSS checker module from disk without changing sys.path."""
+ spec = importlib.util.spec_from_file_location('check_xss_sinks', XSS_CHECKER_FILE)
+ assert spec is not None and spec.loader is not None, 'Expected a module spec for check_xss_sinks.py'
+ module = importlib.util.module_from_spec(spec)
+ sys.modules[spec.name] = module
+ spec.loader.exec_module(module)
+ return module
+
+
+def describe(module, issues) -> list[str]:
+ """Format checker issues the way CI annotates them."""
+ return [module.format_error_annotation(issue) for issue in issues]
+
+
+def test_version_is_at_least_the_tracking_release() -> None:
+ """The run tracking ships in 0.261.251."""
+ version = assert_app_version_at_least('0.261.251')
+ assert version
+
+
+def test_run_tracking_files_pass_xss_guardrail() -> None:
+ """Every new file, and every changed file with no older findings, passes the checker in full."""
+ module = load_xss_checker_module()
+ for path in NEW_FILES + CLEAN_CHANGED_FILES:
+ assert path.is_file(), f'Missing {path}'
+ issues = module.inspect_file(path)
+ assert issues == [], describe(module, issues)
+
+
+def test_run_tracking_files_render_no_raw_html() -> None:
+ """No changed V2 file writes HTML, so every server string renders as text."""
+ for path in NEW_FILES + CLEAN_CHANGED_FILES + CHANGED_LINES_ONLY_FILES:
+ source = read_text(path)
+ for sink in RAW_HTML_SINKS:
+ assert sink not in source, f'{path.name} uses {sink}'
+
+
+def test_run_tracking_links_use_reviewed_builders() -> None:
+ """Each link is workflowRunHref over a run's ids or the fixed Microsoft 365 path."""
+ module = load_xss_checker_module()
+ assert 'workflowRunHref' in module.TS_SAME_ORIGIN_URL_BUILDERS
+
+ found = {}
+ for path in LINK_COMPONENTS:
+ expressions = LINK_ATTRIBUTE_RE.findall(read_text(path))
+ for expression in expressions:
+ assert APPROVED_LINK_RE.match(expression.strip()), f'{path.name} links to {expression!r}'
+ found[path.name] = [expression.strip() for expression in expressions]
+
+ # The scan finds the links it is meant to check.
+ assert found[RUN_CARD.name] == [
+ 'workflowRunHref(workflowId, runId)',
+ 'workflowRunHref(run.workflowId, run.runId)',
+ 'M365_CONNECT_HREF',
+ ]
+ assert found[DELIVERY_FOOTER.name] == ['workflowRunHref(delivery.workflow_id, delivery.run_id)']
+ assert found[PROPOSAL_RUN_SUMMARY.name] == ['workflowRunHref(workflowId, latestResultRunId)']
+ assert found[RUNNING_TAG.name] == []
+ assert found[RUN_LINKS.name] == ['workflowRunHref(item.run.workflowId, item.run.runId)']
+
+ builder_import = "import { workflowRunHref } from '../../lib/workflowRunLink';"
+ constant_import = "import { M365_CONNECT_HREF } from '../../lib/m365Links';"
+ for path in (RUN_CARD, DELIVERY_FOOTER, PROPOSAL_RUN_SUMMARY, RUN_LINKS):
+ assert builder_import in read_text(path), f'{path.name} does not import workflowRunHref'
+ # The proposal card shares the run card's reconnect path instead of keeping its own copy.
+ for path in (RUN_CARD, PROPOSAL_CARD):
+ source = read_text(path)
+ assert constant_import in source, f'{path.name} does not import M365_CONNECT_HREF'
+ assert 'const M365_CONNECT_HREF' not in source, f'{path.name} defines its own M365_CONNECT_HREF'
+
+
+def test_fixed_link_values_stay_same_origin() -> None:
+ """The constant the checker trusts by name is a fixed same-origin path, and notices reuse the builder."""
+ m365_source = read_text(M365_LINKS)
+ assert "export const M365_CONNECT_HREF = '/profile?tab=settings#m365-connection-status';" in m365_source
+ assert m365_source.count('export ') == 1
+
+ notification_source = read_text(NOTIFICATION_LINKS)
+ assert "import { workflowRunHref } from './workflowRunLink';" in notification_source
+ assert ' const workflow = safeId(workflowId);\n' in notification_source
+ assert ' const run = safeId(runId);\n' in notification_source
+ assert "(scope.type !== 'personal' && scope.type !== 'group')" in notification_source
+ assert 'return workflowRunHref(workflow, run, scope);' in notification_source
+
+ # Status rows, delivery metadata and tracker state carry ids, never a URL to render.
+ for path in (RUN_STATUS, DELIVERY, TRACKER, TRACKER_STORE):
+ source = read_text(path)
+ assert 'href' not in source, f'{path.name} carries an href'
+ assert 'link_url' not in source, f'{path.name} reads a link_url'
+
+
+def test_checker_still_flags_links_and_html_from_a_status_row() -> None:
+ """Approving the builder and the constant doesn't approve a URL or HTML from the server."""
+ module = load_xss_checker_module()
+
+ unsafe_issues = module.inspect_source(Path('Synthetic.tsx'), UNSAFE_ROW_SNIPPET)
+ assert [issue.line for issue in unsafe_issues] == [7, 8, 9], describe(module, unsafe_issues)
+ assert module.TS_RULE_JSX_URL_ATTRIBUTE in unsafe_issues[0].message, describe(module, unsafe_issues)
+ assert module.TS_RULE_JSX_URL_ATTRIBUTE in unsafe_issues[1].message, describe(module, unsafe_issues)
+ assert module.TS_RULE_DANGEROUS_INNER_HTML in unsafe_issues[2].message, describe(module, unsafe_issues)
+
+ approved_issues = module.inspect_source(Path('Synthetic.tsx'), APPROVED_ROW_SNIPPET)
+ assert approved_issues == [], describe(module, approved_issues)
+
+
+if __name__ == '__main__':
+ # Run through pytest, which rewrites these asserts so they still run under python -O.
+ raise SystemExit(pytest.main([__file__, '-q']))
diff --git a/ui_tests/fixtures/workflow_run_tracking/.gitignore b/ui_tests/fixtures/workflow_run_tracking/.gitignore
new file mode 100644
index 000000000..550f7603b
--- /dev/null
+++ b/ui_tests/fixtures/workflow_run_tracking/.gitignore
@@ -0,0 +1,7 @@
+# Generated by harness_build from harness_entry.tsx and application/v2_ui/src; rebuilt on demand,
+# machine-specific, and never tracked. Mirrors ui_tests/fixtures/notification_bell/.gitignore.
+harness.bundle.js
+harness.bundle.js.map
+harness.bundle.css
+harness.bundle.css.map
+__pycache__/
diff --git a/ui_tests/fixtures/workflow_run_tracking/harness.html b/ui_tests/fixtures/workflow_run_tracking/harness.html
new file mode 100644
index 000000000..703424161
--- /dev/null
+++ b/ui_tests/fixtures/workflow_run_tracking/harness.html
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+ Workflow run tracking component tests
+
+
+
+
+
+
+
diff --git a/ui_tests/fixtures/workflow_run_tracking/harness_entry.tsx b/ui_tests/fixtures/workflow_run_tracking/harness_entry.tsx
new file mode 100644
index 000000000..a3307182f
--- /dev/null
+++ b/ui_tests/fixtures/workflow_run_tracking/harness_entry.tsx
@@ -0,0 +1,127 @@
+// harness_entry.tsx
+//
+// Test-only harness entry (NOT application source) for ui_tests/test_v2_workflow_run_card.py.
+//
+// Mounts the real V2 frame -- AppShell with its chat list and notification bell, and the chat
+// page -- in a memory router, with the notification runtime and the workflow run tracker running
+// above it the way App.tsx runs them: both start only once the session has loaded, and the tracker
+// only when the user can use the saved workflows a chat starts. Nothing in application/v2_ui/src is
+// replaced; the test answers HTTP and fakes only the browser APIs a headless page cannot drive
+// (Notification, visibility and focus).
+
+import { StrictMode, type ReactNode } from 'react';
+import { createRoot, type Root } from 'react-dom/client';
+import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom';
+
+import * as bootstrapStore from '../../../application/v2_ui/src/stores/bootstrapStore';
+import * as chatStore from '../../../application/v2_ui/src/stores/chatStore';
+import * as notificationStore from '../../../application/v2_ui/src/stores/notificationStore';
+import * as orchestrationStore from '../../../application/v2_ui/src/stores/orchestrationStore';
+import * as uiStore from '../../../application/v2_ui/src/stores/uiStore';
+import * as userSettingsStore from '../../../application/v2_ui/src/stores/userSettingsStore';
+import * as workflowRunTrackerStore from '../../../application/v2_ui/src/stores/workflowRunTrackerStore';
+import * as replyEvents from '../../../application/v2_ui/src/lib/replyEvents';
+import * as desktopNotifications from '../../../application/v2_ui/src/lib/desktopNotifications';
+import * as appNavigation from '../../../application/v2_ui/src/lib/appNavigation';
+import * as trackerHook from '../../../application/v2_ui/src/lib/useWorkflowRunTracker';
+import { useNotificationRuntime } from '../../../application/v2_ui/src/lib/useNotificationRuntime';
+import { useWorkflowRunTracker } from '../../../application/v2_ui/src/lib/useWorkflowRunTracker';
+import { workflowRunTrackerShouldRun } from '../../../application/v2_ui/src/lib/workflowRunTracker';
+import { AppShell } from '../../../application/v2_ui/src/components/layout/AppShell';
+import { ChatPage } from '../../../application/v2_ui/src/pages/ChatPage';
+import type { CompletedReply } from '../../../application/v2_ui/src/lib/replyEvents';
+
+function Runtime({ children }: { children: ReactNode }) {
+ const data = bootstrapStore.useBootstrapStore((state) => state.data);
+ const error = bootstrapStore.useBootstrapStore((state) => state.error);
+ const ready = Boolean(data) && !error;
+ useNotificationRuntime(ready);
+ useWorkflowRunTracker(ready && workflowRunTrackerShouldRun(data?.features));
+ return <>{children}>;
+}
+
+function CurrentRoute() {
+ const { pathname, search } = useLocation();
+ return (
+
+ );
+}
+
+/** Stands in for the Workflows page: it says which run a link asked it to open. */
+function WorkflowsPlaceholder() {
+ const { search } = useLocation();
+ return
{`Workflows page ${search}`}
;
+}
+
+function Frame() {
+ return (
+
+
+
+
+ } />
+ } />
+ Another page} />
+
+
+
+ );
+}
+
+let root: Root | null = null;
+
+function unmount(): void {
+ root?.unmount();
+ root = null;
+}
+
+function mount(path: string): void {
+ unmount();
+ const container = document.getElementById('root');
+ if (!container) {
+ throw new Error('Root container #root was not found in the harness page.');
+ }
+ root = createRoot(container);
+ root.render(
+
+
+
+
+
+
+ ,
+ );
+}
+
+// Every finished reply announced, from the page's first moment, as the desktop notifier hears them.
+const completedReplies: CompletedReply[] = [];
+replyEvents.subscribeCompletedReplies((reply) => {
+ completedReplies.push(reply);
+});
+
+declare global {
+ interface Window {
+ WorkflowRunTrackingHarness: unknown;
+ }
+}
+
+window.WorkflowRunTrackingHarness = {
+ mount,
+ unmount,
+ completedReplies,
+ stores: {
+ bootstrap: bootstrapStore,
+ chat: chatStore,
+ notification: notificationStore,
+ orchestration: orchestrationStore,
+ ui: uiStore,
+ userSettings: userSettingsStore,
+ workflowRunTracker: workflowRunTrackerStore,
+ },
+ tracker: trackerHook,
+ replyEvents,
+ desktopNotifications,
+ appNavigation,
+};
diff --git a/ui_tests/test_v2_document_provenance.py b/ui_tests/test_v2_document_provenance.py
index d3f60e08f..8f5065bb1 100644
--- a/ui_tests/test_v2_document_provenance.py
+++ b/ui_tests/test_v2_document_provenance.py
@@ -1,10 +1,11 @@
# test_v2_document_provenance.py
"""
Production-SPA coverage for where a V2 document came from.
-Version: 0.261.199
+Version: 0.261.251
Implemented in: 0.261.194
A workflow alert's Open workflow, followed while that workflows list is already open: 0.261.199
One more list read, and no more, for a workflow the open list lacks: 0.261.199
+Open run in Open workflow's place, for an alert that names its run: 0.261.251
The real SPA runs against closed synthetic document, workflow and chat APIs, with no live data.
A list row says only which kind of origin a document has (`origin_kind`). The details pane asks
@@ -14,7 +15,7 @@
never send a raw `origin`.
The workflows list acts on each navigation that names a run once, including one that only
-changes the address while the list is open, as a workflow alert's Open workflow does (Track N2).
+changes the address while the list is open, as a workflow alert's Open run does (Track N2).
When the list lacks the workflow a navigation names, it reads the list once more for that
navigation and no more.
"""
@@ -177,7 +178,7 @@ def _dispatch(self, route, entry):
super()._dispatch(route, entry)
-# What each workspace's Open workflow names: the workflow, its title, the run the alert is about,
+# What each workspace's Open run names: the workflow, its title, the run the alert is about,
# another workflow on the same list, and the list's own address.
ALERT_CASES = {
"personal": (WORKFLOW_ID, "Quarterly review workflow", LINKED_RUN_ID, "Agent review workflow",
@@ -319,11 +320,11 @@ def raise_workflow_alert(ui, scope, identifier, title):
return notice
-def open_workflow_from(ui, notice):
+def open_run_from(ui, notice):
notice.locator("[data-workflow-alert-open]").click()
card = ui.page.locator("[data-workflow-alert-card]")
expect(card).to_be_visible()
- card.locator("[data-workflow-alert-open-workflow]").click()
+ card.locator("[data-workflow-alert-open-run]").click()
expect(card).to_have_count(0)
@@ -406,8 +407,8 @@ def test_a_run_link_outside_the_recent_runs_says_so_and_a_workflow_link_still_ed
@pytest.mark.parametrize("scope", ("personal", "group"))
-def test_open_workflow_from_an_alert_while_the_list_is_open(alert_workflows_ui, scope):
- """A workflow alert's Open workflow, followed while that workspace's workflows list is open,
+def test_open_run_from_an_alert_while_the_list_is_open(alert_workflows_ui, scope):
+ """A workflow alert's Open run, followed while that workspace's workflows list is open,
changes only the address; the list stays mounted. It still opens the run's history with the
run expanded, and does so again each time it is followed, even to the same address. Nothing
else that changes the list afterwards, such as running another workflow, opens it again."""
@@ -432,7 +433,7 @@ def test_open_workflow_from_an_alert_while_the_list_is_open(alert_workflows_ui,
def item_reads():
return [entry.path for entry in ui.requests if entry.path.endswith("/items")]
- open_workflow_from(ui, raise_workflow_alert(ui, scope, "alert-1", "Review found blocking items"))
+ open_run_from(ui, raise_workflow_alert(ui, scope, "alert-1", "Review found blocking items"))
expect(ui.page).to_have_url(target)
expect(linked).to_have_count(1)
expect(linked.get_by_role("button", name="Hide run task results", exact=True)).to_be_visible()
@@ -441,10 +442,10 @@ def item_reads():
wait_until(ui, lambda: ui.read_calls == ["alert-1"], lambda: f"Opening marked {ui.read_calls} as read.")
assert item_reads() == [items_path]
- # The reader closes the run; the same Open workflow, on a second alert, opens it again.
+ # The reader closes the run; the same Open run, on a second alert, opens it again.
linked.get_by_role("button", name="Hide run task results", exact=True).click()
expect(linked.get_by_role("button", name="Show run task results", exact=True)).to_be_visible()
- open_workflow_from(ui, raise_workflow_alert(ui, scope, "alert-2", "Review still has blocking items"))
+ open_run_from(ui, raise_workflow_alert(ui, scope, "alert-2", "Review still has blocking items"))
expect(ui.page).to_have_url(target)
expect(linked.get_by_role("button", name="Hide run task results", exact=True)).to_be_visible()
wait_until(ui, lambda: ui.read_calls == ["alert-1", "alert-2"], lambda: f"Opening marked {ui.read_calls} as read.")
@@ -502,26 +503,26 @@ def open_list_without_the_alerted_workflow(ui, scope):
return workflows, record
-def follow_open_workflow_to_a_missing_workflow(ui, scope, identifier, title):
+def follow_open_run_to_a_missing_workflow(ui, scope, identifier, title):
"""
- Follow a new alert's Open workflow to a workflow the list lacks. Returns how many more times
+ Follow a new alert's Open run to a workflow the list lacks. Returns how many more times
the list has been read once the page has stayed quiet for QUIET_MS after the first new read.
"""
notice = raise_workflow_alert(ui, scope, identifier, title)
list_reads = workflow_list_reads(ui, scope)
- open_workflow_from(ui, notice)
+ open_run_from(ui, notice)
wait_until(
ui, lambda: workflow_list_reads(ui, scope) > list_reads,
- lambda: "Open workflow never read the list again for a workflow the list lacked.",
+ lambda: "Open run never read the list again for a workflow the list lacked.",
)
ui.page.wait_for_timeout(QUIET_MS)
return workflow_list_reads(ui, scope) - list_reads
@pytest.mark.parametrize("scope", ("personal", "group"))
-def test_open_workflow_reads_the_list_again_for_a_workflow_created_since(alert_workflows_ui, scope):
+def test_open_run_reads_the_list_again_for_a_workflow_created_since(alert_workflows_ui, scope):
"""The open list was read before the alert's workflow existed, as when it was created in
- another tab. Open workflow reads the list once more, finds the workflow there and opens its
+ another tab. Open run reads the list once more, finds the workflow there and opens its
history on the alert's run. That one read is the only list read the navigation causes."""
ui = alert_workflows_ui
workflow_id, name, run_id, _, list_path = ALERT_CASES[scope]
@@ -532,7 +533,7 @@ def test_open_workflow_reads_the_list_again_for_a_workflow_created_since(alert_w
notice = raise_workflow_alert(ui, scope, "alert-1", "Review found blocking items")
list_reads = workflow_list_reads(ui, scope)
- open_workflow_from(ui, notice)
+ open_run_from(ui, notice)
expect(ui.page).to_have_url(f"{ORIGIN}/v2{list_path}?workflow_id={workflow_id}&run_id={run_id}")
expect(linked).to_have_count(1)
expect(linked.get_by_role("button", name="Hide run task results", exact=True)).to_be_visible()
@@ -542,7 +543,7 @@ def test_open_workflow_reads_the_list_again_for_a_workflow_created_since(alert_w
wait_until(ui, lambda: ui.read_calls == ["alert-1"], lambda: f"Opening marked {ui.read_calls} as read.")
ui.page.wait_for_timeout(QUIET_MS)
extra_reads = workflow_list_reads(ui, scope) - list_reads
- assert extra_reads == 1, f"Open workflow read the list {extra_reads} more times, not once."
+ assert extra_reads == 1, f"Open run read the list {extra_reads} more times, not once."
assert [entry.path for entry in ui.requests if entry.path.endswith("/items")] == [
f"{workflows_api(scope)}/{workflow_id}/runs/{run_id}/items",
]
@@ -552,9 +553,9 @@ def test_open_workflow_reads_the_list_again_for_a_workflow_created_since(alert_w
@pytest.mark.parametrize("scope", ("personal", "group"))
-def test_open_workflow_reads_the_list_only_once_more_for_a_workflow_it_lacks(alert_workflows_ui, scope):
+def test_open_run_reads_the_list_only_once_more_for_a_workflow_it_lacks(alert_workflows_ui, scope):
"""The alert's workflow is missing from the server's list as well, as when it was deleted
- after the run. Open workflow reads the list once more and stops there: nothing opens, no error
+ after the run. Open run reads the list once more and stops there: nothing opens, no error
shows and the list stays usable. Following the link again is a new navigation, which reads
the list once more."""
ui = alert_workflows_ui
@@ -565,9 +566,9 @@ def test_open_workflow_reads_the_list_only_once_more_for_a_workflow_it_lacks(ale
run_other = section.get_by_role("button", name=f"Run {other}", exact=True)
history = ui.page.get_by_role("list", name="Workflow run history", exact=True)
- extra_reads = follow_open_workflow_to_a_missing_workflow(ui, scope, "alert-1", "Review found blocking items")
+ extra_reads = follow_open_run_to_a_missing_workflow(ui, scope, "alert-1", "Review found blocking items")
expect(ui.page).to_have_url(target)
- assert extra_reads == 1, f"Open workflow read the list {extra_reads} more times, not once."
+ assert extra_reads == 1, f"Open run read the list {extra_reads} more times, not once."
expect(ui.page.get_by_role("dialog")).to_have_count(0)
expect(history).to_have_count(0)
expect(section.get_by_role("alert")).to_have_count(0)
@@ -582,7 +583,7 @@ def test_open_workflow_reads_the_list_only_once_more_for_a_workflow_it_lacks(ale
expect(run_other).to_be_enabled()
# Following the same link again is a new navigation, with one read of its own.
- extra_reads = follow_open_workflow_to_a_missing_workflow(
+ extra_reads = follow_open_run_to_a_missing_workflow(
ui, scope, "alert-2", "Review still has blocking items",
)
expect(ui.page).to_have_url(target)
diff --git a/ui_tests/test_v2_notifications_bell.py b/ui_tests/test_v2_notifications_bell.py
index b937a6bd6..f2ad15163 100644
--- a/ui_tests/test_v2_notifications_bell.py
+++ b/ui_tests/test_v2_notifications_bell.py
@@ -1,8 +1,9 @@
# test_v2_notifications_bell.py
"""
Browser regressions for the V2 notification bell, its panel and desktop notifications.
-Version: 0.261.195
+Version: 0.261.251
Implemented in: 0.261.195
+Notices about a chat-started run's results, and workflow-activity links, open the run in V2: 0.261.251
Exercises the real rail, bell, panel, chat page, preferences tab, stores and notification
runtime, bundled by fixtures/notification_bell. Only HTTP answers and the browser APIs a
@@ -315,12 +316,12 @@ def stream_body(frames):
class NotificationApi:
"""The routes the frame calls, answered the way route_backend_* answers, with their state."""
- def __init__(self, page):
+ def __init__(self, page, titles=None, *, streams=0):
self.page = page
self.notices = []
self.conversations = {
conversation_id: {"id": conversation_id, "title": title, "unread": False}
- for conversation_id, title in CONVERSATIONS.items()
+ for conversation_id, title in (CONVERSATIONS if titles is None else titles).items()
}
self.requests = []
self.list_queries = []
@@ -342,7 +343,7 @@ def __init__(self, page):
self.held_kinds = {}
self.hold_streams = False
self.held_streams = []
- self.streams = 0
+ self.streams = streams
self.errors = []
self.unexpected = []
self.expected_http_failures = set()
@@ -708,8 +709,8 @@ def count_requests(self, method, path):
class Harness(NotificationApi):
"""The frame in a browser page, driven the way a reader drives it."""
- def __init__(self, page, stylesheets):
- super().__init__(page)
+ def __init__(self, page, stylesheets, titles=None, *, streams=0):
+ super().__init__(page, titles, streams=streams)
self.stylesheets = stylesheets
def open(self, path="/chat", *, browser=None, features=None, settings=None, settings_loading=False,
@@ -2243,3 +2244,123 @@ def test_preferences_say_why_this_browser_cannot_show_a_notice(harness, case):
harness.hide()
harness.announce(**chat_reply("conv-a", "m-1"))
harness.page.wait_for_timeout(200)
+
+
+# Workflow run notices -------------------------------------------------------------------------
+
+RUN_ROUTE = "/workspace/workflows?workflow_id=wf-7&run_id=run-7"
+RUN_ACTIVITY_LINK = "/workflow-activity?workflowId=wf-7&runId=run-7&scope=personal"
+UNDELIVERABLE_NOTICE = (
+ "The chat that started this run can't show it anymore. Open the run in Workflows to see the details."
+)
+EXPIRED_NOTICE = "The run didn't finish in time to post to the chat. Open it in Workflows to see where it stands."
+
+
+def chat_delivery_notice(notice_id, title, message, *, delivery_status, link_url=RUN_ACTIVITY_LINK,
+ link_context=None, **metadata):
+ """The notice functions_workflow_chat_delivery_worker._send_notice writes when a chat-started
+ run's results cannot be posted to its chat."""
+ return notice(
+ notice_id, "workflow_chat_delivery", title, message, link_url=link_url, link_context=link_context,
+ metadata={
+ "workflow_id": "wf-7", "run_id": "run-7", "workflow_scope": "personal",
+ "delivery_status": delivery_status, **metadata,
+ },
+ color="info", icon="bi-activity",
+ )
+
+
+def test_a_chat_run_notice_reads_as_workflow_results_and_opens_its_run_in_v2(harness):
+ # The workflow's name is the one thing in it a person wrote, and it is shown as text.
+ title = 'Results from "Weekly digest" are ready'
+ harness.add(chat_delivery_notice("n-results", title, UNDELIVERABLE_NOTICE, delivery_status="undeliverable"))
+ harness.open("/elsewhere")
+ harness.open_panel()
+
+ row = harness.row("n-results")
+ expect(row).to_have_attribute("data-notification-type", "workflow_chat_delivery")
+ expect(row.locator("p").first.locator("span.truncate")).to_have_text("Workflow results")
+ icon = row.locator("xpath=./span[@aria-hidden='true']")
+ expect(icon).to_have_class(re.compile(r"(^|\s)text-info(\s|$)"))
+ expect(icon.locator("svg.lucide-workflow")).to_have_count(1)
+ expect(row.locator("svg.lucide-bell")).to_have_count(0)
+ expect(harness.action("n-results", "open")).to_have_text(title)
+ expect(row).to_contain_text(UNDELIVERABLE_NOTICE)
+ expect(row.locator("[data-notification-link-error]")).to_have_count(0)
+ expect(row.locator("img")).to_have_count(0)
+
+ # 6b-1 links it to the classic run page; the notice and the link agree, so it opens in V2.
+ harness.action("n-results", "open").click()
+ expect(current_route(harness)).to_have_text(RUN_ROUTE)
+ expect(harness.panel).to_have_count(0)
+ harness.wait_for(lambda: harness.read_calls == ["n-results"], "Opening the notice should mark it read.")
+ harness.page.wait_for_timeout(200)
+ classic_reads = harness.count_requests("GET", "/workflow-activity")
+ assert classic_reads == 0
+ assert harness.set_active_calls == []
+ xss = harness.js("() => window.__xss")
+ assert xss is None
+
+
+def test_a_workflow_notice_without_a_link_opens_the_run_it_names(harness):
+ harness.add(
+ chat_delivery_notice(
+ "n-expired", "\"Weekly digest\" didn't finish in time to post to chat", EXPIRED_NOTICE,
+ delivery_status="expired", link_url="",
+ ),
+ # Only workflow notices open a run they name without linking to it.
+ notice(
+ "n-announcement", "system_announcement", "Weekly digest ran",
+ metadata={"workflow_id": "wf-7", "run_id": "run-7", "workflow_scope": "personal"},
+ ),
+ # Without its workspace there is no telling which Workflows page the run is on.
+ chat_delivery_notice(
+ "n-unscoped", "\"Weekly digest\" didn't finish", UNDELIVERABLE_NOTICE,
+ delivery_status="undeliverable", link_url="", workflow_scope=None,
+ ),
+ # A Microsoft 365 action is resolved on classic pages, so V2 does not guess at one.
+ chat_delivery_notice(
+ "n-m365", "\"Weekly digest\" was cancelled", UNDELIVERABLE_NOTICE,
+ delivery_status="undeliverable", link_url="", link_context={"m365_pending_action_id": "act-7"},
+ ),
+ )
+ harness.open("/elsewhere")
+ harness.open_panel()
+
+ for notice_id in ("n-announcement", "n-unscoped", "n-m365"):
+ expect(harness.action(notice_id, "open")).to_have_count(0)
+ expect(harness.row(notice_id).locator("[data-notification-link-error]")).to_have_count(0)
+
+ harness.action("n-expired", "open").click()
+ expect(current_route(harness)).to_have_text(RUN_ROUTE)
+ harness.wait_for(lambda: harness.read_calls == ["n-expired"], "Opening the notice should mark it read.")
+
+
+@pytest.mark.parametrize("written_in", ["metadata", "link_context"])
+def test_a_microsoft_365_notice_keeps_its_classic_run_page(harness, written_in):
+ # Everything else about it would open the run in V2; the Microsoft 365 action keeps it classic,
+ # wherever the action id was written.
+ marker = {"m365_pending_action_id": "act-7"}
+ harness.add(notice(
+ "n-classic", "system_announcement", "Microsoft 365 action awaiting review",
+ "Review the calendar invite before it is sent.", link_url=RUN_ACTIVITY_LINK,
+ link_context=marker if written_in == "link_context" else None,
+ metadata={
+ "workflow_id": "wf-7", "run_id": "run-7", "workflow_scope": "personal",
+ **(marker if written_in == "metadata" else {}),
+ },
+ ))
+ harness.open("/elsewhere")
+ harness.open_panel()
+ harness.action("n-classic", "open").click()
+
+ harness.page.wait_for_url(f"{ORIGIN}{RUN_ACTIVITY_LINK}")
+ expect(harness.page.get_by_role("heading", name="Classic interface")).to_be_visible()
+ assert harness.read_calls == ["n-classic"]
+ assert harness.set_active_calls == []
+ # A full page load cancels whatever is still on its way, so the read goes first.
+ order = [(method, target) for method, target, _ in harness.requests]
+ read_at = order.index(("POST", "/api/notifications/n-classic/read"))
+ page_at = order.index(("GET", RUN_ACTIVITY_LINK))
+ assert read_at < page_at
+
diff --git a/ui_tests/test_v2_orchestration_workflow_proposal_card.py b/ui_tests/test_v2_orchestration_workflow_proposal_card.py
index 9c6879caa..11937fd74 100644
--- a/ui_tests/test_v2_orchestration_workflow_proposal_card.py
+++ b/ui_tests/test_v2_orchestration_workflow_proposal_card.py
@@ -1,8 +1,9 @@
# test_v2_orchestration_workflow_proposal_card.py
"""
Real-component browser tests for the workflow proposal card under an orchestration answer.
-Version: 0.261.244
+Version: 0.261.251
Implemented in: 0.261.207; merge task wording added in 0.261.241; merge kinds in 0.261.242; Word in 0.261.243; PowerPoint in 0.261.244
+Next and last run on a created card: 0.261.251 (microsoft/simplechat#1546)
Refs: microsoft/simplechat#1547, microsoft/simplechat#1619
The production MessageList, WorkflowProposalCards, ConfirmDialog and WorkflowEditorDialog run in
@@ -14,12 +15,17 @@
`functional_tests/test_orchestration_workflow_proposal_routes.py` and
`functional_tests/test_orchestration_workflow_proposal_editor_round_trip.py`.
+A created card also says when its workflow runs next and how the last run went. It reads them
+from `GET /api/user/workflows` and `GET /api/user/workflows//runs`, stubbed here in the shapes
+`route_backend_workflows.py` returns, in a browser set to New York time.
+
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: python -m pytest .\\ui_tests\\test_v2_orchestration_workflow_proposal_card.py -q
"""
import copy
+import hashlib
import re
import sys
from datetime import datetime, timedelta, timezone
@@ -41,6 +47,7 @@
sys.path.insert(0, str(APP_ROOT))
import functions_workflow_schedules # noqa: E402 (the server's own schedule choices)
+import functions_workflow_result_masking as masking # noqa: E402 (the result descriptor's version)
pytestmark = pytest.mark.ui
@@ -251,9 +258,9 @@ def editor_options():
class ProposalApi:
"""The proposal routes for one run, answered from `self.proposal` the way the server decides."""
- def __init__(self, assets):
+ def __init__(self, assets, record=None):
self.assets = assets
- self.proposal = proposal()
+ self.proposal = proposal() if record is None else record
self.draft = copy.deepcopy(DRAFT)
self.requests = []
self.unexpected = []
@@ -892,5 +899,301 @@ def test_no_card_and_no_request_outside_a_personal_proposal_answer(card_ui, kind
assert not api.calls("GET") and not api.writes(), api.requests
+# --- When a created workflow runs next, and how its last run went (phase 6b) ---
+# The card reads the workflow and its recent runs once, through the routes the Workflows page uses,
+# answered here from `RecurringApi.workflows` and `.runs`. Follow up reads the run's result
+# descriptor from its result-context route, as Ask in chat does.
+WORKFLOWS_PATH = "/api/user/workflows"
+RUNS_PATH = f"/api/user/workflows/{WORKFLOW_ID}/runs"
+RESULT_CONTEXT = re.compile(r"/api/user/workflows/([^/]+)/runs/([^/]+)/result-context")
+RESULT_SHA = hashlib.sha256(b"Monday email review, week of Sep 14").hexdigest()
+NEXT_RUN_AT = "2026-10-05T12:00:00+00:00"
+NEXT_RUN_TEXT = r"^Mon, Oct 5, 8:00\sAM EDT$"
+HOSTILE_NAME = 'Monday email review'
+
+
+def workflow_record(**fields):
+ """The created workflow as the workflow list returns it: the saved draft and its run fields."""
+ record = copy.deepcopy(DRAFT)
+ record.update(
+ id=WORKFLOW_ID, is_enabled=True, next_run_at=NEXT_RUN_AT, last_run_status=None, last_run_at=None,
+ )
+ record.update(fields)
+ return record
+
+
+def history_row(run_id, status, *, started_at, completed_at=None, definition_version=2):
+ """One run as the workflow's run history lists it."""
+ return {
+ "id": run_id, "workflow_id": WORKFLOW_ID, "status": status, "trigger_source": "scheduled",
+ "started_at": started_at, "completed_at": completed_at, "definition_version": definition_version,
+ }
+
+
+def weekly_runs():
+ """Newest first: last Monday's run failed, and the one before it completed."""
+ return [
+ history_row("run-weekly-2", "failed", started_at="2026-09-21T12:00:00+00:00",
+ completed_at="2026-09-21T12:01:00+00:00"),
+ history_row("run-weekly-1", "completed", started_at="2026-09-14T12:00:00+00:00",
+ completed_at="2026-09-14T12:03:00+00:00"),
+ ]
+
+
+class RecurringApi(ProposalApi):
+ """The proposal routes for a created proposal, plus its workflow, the workflow's runs and a result."""
+
+ def __init__(self, assets):
+ super().__init__(assets, proposal("created_enabled"))
+ self.workflows = [workflow_record()]
+ self.runs = weekly_runs()
+ self.read_errors = {}
+
+ def handle(self, route):
+ request = route.request
+ parsed = urlsplit(request.url)
+ path = parsed.path
+ result = RESULT_CONTEXT.fullmatch(path)
+ if (f"{parsed.scheme}://{parsed.netloc}" != ORIGIN or request.method != "GET"
+ or not (path in (WORKFLOWS_PATH, RUNS_PATH) or result)):
+ super().handle(route)
+ return
+ self.requests.append({"method": "GET", "path": path, "query": parse_qs(parsed.query), "body": None})
+ self.expect(not parsed.query, f"GET {path}?{parsed.query}")
+ if path in self.read_errors:
+ self.error(route, self.read_errors[path], "Workflows aren't available right now.", "unavailable")
+ elif path == WORKFLOWS_PATH:
+ route.fulfill(json={"workflows": copy.deepcopy(self.workflows)})
+ elif path == RUNS_PATH:
+ route.fulfill(json={"workflow_id": WORKFLOW_ID, "runs": copy.deepcopy(self.runs)})
+ else:
+ workflow_id, run_id = unquote(result[1]), unquote(result[2])
+ run = next((row for row in self.runs if row["id"] == run_id), None)
+ if workflow_id != WORKFLOW_ID or run is None:
+ self.unexpected.append(f"GET {path}")
+ route.fulfill(status=404, json={"error": "Unmocked request"})
+ return
+ route.fulfill(json={"workflow_result": {
+ "version": masking.WORKFLOW_RESULT_VERSION, "workflow_id": workflow_id, "run_id": run_id,
+ "workflow_name": NAME, "status": run["status"], "completed_at": run["completed_at"],
+ "result_sha256": RESULT_SHA, "available": True,
+ }})
+
+ def summary_reads(self):
+ return [call for call in self.requests if call["path"] in (WORKFLOWS_PATH, RUNS_PATH)]
+
+ def result_reads(self):
+ return [call for call in self.requests if RESULT_CONTEXT.fullmatch(call["path"])]
+
+
+@pytest.fixture
+def recurring_ui(editor_browser, editor_assets):
+ # New York time, from the Monday the proposal was created, so every time shown is fixed.
+ context = editor_browser.new_context(
+ viewport={"width": 1440, "height": 900}, locale="en-US", timezone_id="America/New_York",
+ )
+ page = context.new_page()
+ page.clock.install(time=CLOCK_START)
+ api = RecurringApi(editor_assets)
+ errors = []
+ dialogs = []
+ page.route("**/*", api.handle)
+ page.on("pageerror", lambda error: errors.append(str(error)))
+
+ def on_dialog(dialog):
+ dialogs.append(dialog.message)
+ dialog.dismiss()
+
+ page.on("dialog", on_dialog)
+
+ def console_error(message):
+ if message.type != "error":
+ return
+ path = urlsplit(message.location.get("url", "")).path
+ if any(path == expected and str(status) in message.text for expected, status in api.expected_errors):
+ return
+ errors.append(message.text)
+
+ page.on("console", console_error)
+ try:
+ yield page, api
+ hostile = page.evaluate("() => window.__hostile ?? null")
+ assert hostile is None, hostile
+ finally:
+ context.close()
+ assert not api.unexpected, f"Unexpected browser requests: {api.unexpected}"
+ assert not errors, f"Unexpected browser errors: {errors}"
+ assert not dialogs, f"Unexpected browser dialogs: {dialogs}"
+
+
+def mount_with_workflows(page, api, *, workflows=True, results=True):
+ """`mount` on the chat page, for a reader who may use workflows and, optionally, their results in chat."""
+ page.goto(ORIGIN + HARNESS)
+ page.wait_for_function("() => Boolean(window.OrchHarness)")
+ for url in api.assets:
+ if url.endswith(".css"):
+ page.add_style_tag(url=ORIGIN + url)
+ page.evaluate(
+ """(spec) => {
+ const H = window.OrchHarness;
+ H.reset();
+ H.stores.bootstrap.useBootstrapStore.setState({ data: {
+ version: '0.261.251', settings: {}, branding: { app_title: 'SimpleChat' },
+ features: {
+ enable_chat_orchestration: true,
+ allow_user_workflows: spec.workflows,
+ enable_chat_workflow_results: spec.results,
+ },
+ user: { id: 'proposal-tester', display_name: 'Proposal Tester' },
+ scope: { groups: [], public_workspaces: [] },
+ orchestration: { enabled: true, capabilities: [] },
+ catalogs: { models: [], agents: [], prompts: [] },
+ } });
+ H.stores.chat.useChatStore.setState({
+ activeConversationId: spec.conversation, activeConversationKind: 'personal',
+ messagesLoading: false, messagesError: null, streaming: false, streamingContent: '',
+ streamError: null, thoughts: [], messages: spec.messages,
+ conversations: [{ id: spec.conversation, title: 'Weekly email' }],
+ });
+ H.mount('mount-a', 'MessageList', {}, { strictMode: true, initialEntries: ['/chat'] });
+ }""",
+ {"conversation": CONVERSATION, "workflows": workflows, "results": results, "messages": answer()},
+ )
+ expect(page.get_by_text("Every Monday at 8, review my email", exact=False)).to_be_visible()
+
+
+def run_line(summary, term):
+ return summary.locator(f"div:has(> dt:text-is('{term}')) > dd")
+
+
+def chosen_result(page):
+ return page.evaluate("() => window.OrchHarness.stores.chat.useChatStore.getState().workflowResultContext")
+
+
+def test_a_created_card_says_when_it_runs_next_and_how_the_last_run_went(recurring_ui):
+ page, api = recurring_ui
+ mount_with_workflows(page, api)
+ article = card(page)
+ summary = article.locator("[data-workflow-proposal-runs='']")
+ # Only the next and last runs: the card's When line already names the schedule.
+ expect(summary.locator("dt")).to_have_text(["Next run", "Last run"])
+ # Monday at 8:00 in New York, a week after the card was created, with its time zone.
+ expect(run_line(summary, "Next run")).to_have_text(re.compile(NEXT_RUN_TEXT))
+ # The newest run, however it ended.
+ expect(run_line(summary, "Last run")).to_have_text(re.compile(r"^Failed · Mon, Sep 21, 8:01\sAM EDT$"))
+ # Results come from the newest run that finished with some.
+ results = summary.get_by_role("link", name=f"Open latest results of {NAME}", exact=True)
+ expect(results).to_have_text("Open latest results")
+ expect(results).to_have_attribute(
+ "href", f"/workspace/workflows?workflow_id={WORKFLOW_ID}&run_id=run-weekly-1")
+ follow_up = summary.get_by_role(
+ "button", name=f"Follow up on the {NAME} run of Mon, Sep 14, 8:03 AM in a new chat", exact=True)
+ expect(follow_up).to_be_visible()
+
+ # Read when the card opens, and never polled.
+ assert {call["path"] for call in api.summary_reads()} == {WORKFLOWS_PATH, RUNS_PATH}, api.requests
+ reads = len(api.summary_reads())
+ page.clock.run_for(600000)
+ page.wait_for_timeout(300)
+ assert len(api.summary_reads()) == reads, "The run summary polled."
+
+ # Follow up opens a new chat about that run, reading its result descriptor fresh.
+ follow_up.focus()
+ page.keyboard.press("Enter")
+ wait_for(page, lambda: chosen_result(page), "Follow up did not choose the run's result.")
+ assert [call["path"] for call in api.result_reads()] == [
+ f"/api/user/workflows/{WORKFLOW_ID}/runs/run-weekly-1/result-context"]
+ chosen = chosen_result(page)
+ assert chosen["conversation_id"] is None, chosen
+ descriptor = chosen["descriptor"]
+ assert (descriptor["workflow_id"], descriptor["run_id"], descriptor["result_sha256"]) == (
+ WORKFLOW_ID, "run-weekly-1", RESULT_SHA), descriptor
+ expect(page.get_by_role("region", name="Workflow proposals")).to_have_count(0)
+ assert not api.writes()
+
+
+@pytest.mark.parametrize("state,record,runs,next_run,last_run", [
+ ("created_paused", {"is_enabled": False}, [], None, r"^No runs yet$"),
+ ("created_enabled", {"next_run_at": "2026-09-28T11:00:00+00:00"}, [], r"^Due now$", r"^No runs yet$"),
+ ("created_enabled", {"last_run_status": "skipped", "last_run_at": "2026-09-21T12:00:00+00:00"}, [],
+ NEXT_RUN_TEXT, r"^Skipped · Mon, Sep 21, 8:00\sAM EDT$"),
+ ("created_enabled", {}, [history_row("run-odd", "exploded", started_at="2026-09-21T12:00:00+00:00")],
+ NEXT_RUN_TEXT, r"^Status unavailable · Mon, Sep 21, 8:00\sAM EDT$"),
+], ids=["paused", "due now", "only the workflow record has run", "unknown run status"])
+def test_the_run_lines_follow_the_workflow_and_never_guess(recurring_ui, state, record, runs, next_run, last_run):
+ page, api = recurring_ui
+ api.proposal = proposal(state)
+ api.workflows = [workflow_record(**record)]
+ api.runs = runs
+ mount_with_workflows(page, api)
+ summary = card(page).locator("[data-workflow-proposal-runs='']")
+ expect(run_line(summary, "Last run")).to_have_text(re.compile(last_run))
+ if next_run is None:
+ # A paused workflow has no next run, whatever its record still holds.
+ expect(summary.locator("dt")).to_have_text(["Last run"])
+ else:
+ expect(run_line(summary, "Next run")).to_have_text(re.compile(next_run))
+ # None of these runs finished with results, so there is nothing to open or follow up on.
+ expect(summary.get_by_role("link")).to_have_count(0)
+ expect(summary.get_by_role("button")).to_have_count(0)
+ expect(summary).not_to_contain_text("exploded")
+
+
+@pytest.mark.parametrize("variant", ["results in chat off", "structured run", "workflows off"])
+def test_the_summary_and_follow_up_need_their_features(recurring_ui, variant):
+ page, api = recurring_ui
+ if variant == "structured run":
+ # Chat can't answer from a structured (v3) run's result yet.
+ api.runs[1]["definition_version"] = 3
+ mount_with_workflows(page, api, workflows=variant != "workflows off", results=variant != "results in chat off")
+ article = card(page)
+ if variant == "workflows off":
+ # The workflow routes would refuse this reader, so nothing is read or shown.
+ expect(article.get_by_role("status")).to_have_text("Created")
+ page.wait_for_timeout(400)
+ expect(article.locator("[data-workflow-proposal-runs]")).to_have_count(0)
+ assert not api.summary_reads(), api.requests
+ return
+ summary = article.locator("[data-workflow-proposal-runs='']")
+ expect(summary.get_by_role("link", name=f"Open latest results of {NAME}", exact=True)).to_have_attribute(
+ "href", f"/workspace/workflows?workflow_id={WORKFLOW_ID}&run_id=run-weekly-1")
+ expect(summary.get_by_role("button")).to_have_count(0)
+ expect(summary).not_to_contain_text("Follow up")
+
+
+@pytest.mark.parametrize("failure", ["runs read fails", "workflow list fails", "workflow not listed"])
+def test_a_failed_read_says_run_details_are_unavailable(recurring_ui, failure):
+ page, api = recurring_ui
+ if failure == "runs read fails":
+ api.read_errors[RUNS_PATH] = 503
+ elif failure == "workflow list fails":
+ api.read_errors[WORKFLOWS_PATH] = 503
+ else:
+ # Deleted since the card was created: nothing is filled in from the proposal instead.
+ api.workflows = [workflow_record(id="wf-someone-else")]
+ mount_with_workflows(page, api)
+ article = card(page)
+ expect(article.locator("[data-workflow-proposal-runs='unavailable']")).to_have_text(
+ "Run details aren't available right now.")
+ expect(article.locator("[data-workflow-proposal-runs='']")).to_have_count(0)
+ expect(article).not_to_contain_text("Last run")
+ # The rest of the card still works.
+ expect(article.get_by_role("link", name="Open workflow", exact=True)).to_be_visible()
+
+
+def test_a_hostile_workflow_name_stays_text_in_the_run_summary(recurring_ui):
+ page, api = recurring_ui
+ api.proposal["workflow"]["name"] = HOSTILE_NAME
+ api.workflows = [workflow_record(name=HOSTILE_NAME)]
+ mount_with_workflows(page, api)
+ article = card(page, HOSTILE_NAME)
+ summary = article.locator("[data-workflow-proposal-runs='']")
+ expect(summary.get_by_role("link", name=f"Open latest results of {HOSTILE_NAME}", exact=True)).to_be_visible()
+ expect(summary.get_by_role(
+ "button", name=f"Follow up on the {HOSTILE_NAME} run of Mon, Sep 14, 8:03 AM in a new chat", exact=True,
+ )).to_be_visible()
+ expect(article.locator("img")).to_have_count(0)
+
+
if __name__ == "__main__":
raise SystemExit(pytest.main([__file__, "-q"]))
diff --git a/ui_tests/test_v2_workflow_alert_notices.py b/ui_tests/test_v2_workflow_alert_notices.py
index af62fc33a..2fc59289c 100644
--- a/ui_tests/test_v2_workflow_alert_notices.py
+++ b/ui_tests/test_v2_workflow_alert_notices.py
@@ -1,12 +1,13 @@
# test_v2_workflow_alert_notices.py
"""
Browser regressions for the V2 workflow alert notice, its alert card and their runtime.
-Version: 0.261.236
+Version: 0.261.251
Implemented in: 0.261.199
Open and Dismiss up front, everything else under Show more: 0.261.228
Must-acknowledge alerts, sounds and team delivery: 0.261.235
Hanging from the bell rather than My Workspace, whatever the chat rail's scroll, with a row of
its own under the rail's header for an alert that needs acknowledgment: 0.261.236
+Open run in Open workflow's place for an alert that names its run: 0.261.251
Exercises the real rail, bell, notice, card, live region, stores and both notification
runtimes, bundled by fixtures/workflow_alerts. Only HTTP answers and the browser APIs a
@@ -1444,7 +1445,9 @@ def test_open_is_the_one_big_action_and_the_rest_waits_under_show_more(tab, serv
expect(card.locator("[data-workflow-alert-chip]").filter(has_text="Word file: Initial Briefing.docx")).to_be_visible()
others = card.locator("[data-workflow-alert-links]")
expect(others.locator("[data-workflow-alert-link]")).to_have_text(["Open workflow conversation"])
- expect(others.locator("[data-workflow-alert-open-workflow]")).to_have_text("Open workflow")
+ # The alert names its run, so the route action is Open run, in Open workflow's place.
+ expect(others.locator("[data-workflow-alert-open-run]")).to_have_text("Open run")
+ expect(others.locator("[data-workflow-alert-open-workflow]")).to_have_count(0)
# Open settles every alert of the entry and goes to the conversation the workflow created.
primary.click()
@@ -1748,7 +1751,7 @@ def test_alert_text_is_plain_text_and_links_stay_on_this_site(tab, server, alert
assert tab.js("() => window.__xss") is None
-def test_open_workflow_goes_to_the_run_in_its_workspace(tab, server, alert):
+def test_open_run_goes_to_the_run_in_its_workspace(tab, server, alert):
tab.open()
def open_card(notice_id, **fields):
@@ -1756,42 +1759,57 @@ def open_card(notice_id, **fields):
tab.poll()
tab.open_button.click()
expect(tab.card).to_be_visible()
- # Open run arrives with the run page. Ask about this needs Use Workflow Results
- # In Chat, which this bootstrap leaves off (ui_tests/test_chat_workflow_results.py).
- expect(tab.card.locator("[data-workflow-alert-open-run]")).to_have_count(0)
+ # Ask about this needs Use Workflow Results In Chat, which this bootstrap leaves off
+ # (ui_tests/test_chat_workflow_results.py).
expect(tab.card.locator("[data-workflow-alert-follow-up]")).to_have_count(0)
- # Open goes to the conversation; Open workflow waits under Show more.
+ # Open goes to the conversation; Open run or Open workflow waits under Show more.
show_more = tab.card.locator("[data-workflow-alert-show-more]")
if show_more.count():
show_more.click()
- return tab.card.locator("[data-workflow-alert-open-workflow]")
+ return tab.card
+ # An alert that names its run offers Open run in Open workflow's place, at the same address.
personal = open_card("o1", workflow_id="wf-nightly", title="Deploy gate is red")
- expect(personal).to_have_text("Open workflow")
- personal.click()
+ expect(personal.locator("[data-workflow-alert-open-workflow]")).to_have_count(0)
+ open_run = personal.locator("[data-workflow-alert-open-run]")
+ expect(open_run).to_have_text("Open run")
+ open_run.click()
tab.wait_for(
lambda: tab.route_text() == "/workspace/workflows?workflow_id=wf-nightly&run_id=run-1",
- f"Open workflow went to {tab.route_text()}.",
+ f"Open run went to {tab.route_text()}.",
)
expect(tab.page.get_by_text("Your workflows", exact=True)).to_be_visible()
tab.wait_for(lambda: server.read_calls == ["o1"], f"Opening marked {server.read_calls} as read.")
group = open_card("o2", workflow_id="wf-team", scope="group", group_id="grp-7", title="Team budget is over")
- group.click()
+ group.locator("[data-workflow-alert-open-run]").click()
tab.wait_for(
lambda: tab.route_text() == "/groups/grp-7/workflows?workflow_id=wf-team&run_id=run-1",
- f"Open workflow went to {tab.route_text()}.",
+ f"Open run went to {tab.route_text()}.",
)
expect(tab.page.get_by_text("Workflows of group grp-7", exact=True)).to_be_visible()
tab.wait_for(lambda: server.read_calls == ["o1", "o2"], f"Opening marked {server.read_calls} as read.")
+ # An alert without a run keeps Open workflow, which names the workflow alone.
+ no_run = open_card("o4", workflow_id="wf-weekly", run_id="", title="Weekly check needs a look")
+ expect(no_run.locator("[data-workflow-alert-open-run]")).to_have_count(0)
+ open_workflow = no_run.locator("[data-workflow-alert-open-workflow]")
+ expect(open_workflow).to_have_text("Open workflow")
+ open_workflow.click()
+ tab.wait_for(
+ lambda: tab.route_text() == "/workspace/workflows?workflow_id=wf-weekly",
+ f"Open workflow went to {tab.route_text()}.",
+ )
+ tab.wait_for(lambda: server.read_calls == ["o1", "o2", "o4"], f"Opening marked {server.read_calls} as read.")
+
# An alert that cannot be placed offers no guess at where its workflow lives.
nowhere = open_card("o3", workflow_id="wf-lost", scope=None, link_targets=[], title="Nobody knows where")
- expect(nowhere).to_have_count(0)
+ expect(nowhere.locator("[data-workflow-alert-open-run]")).to_have_count(0)
+ expect(nowhere.locator("[data-workflow-alert-open-workflow]")).to_have_count(0)
expect(tab.card.locator("[data-workflow-alert-links]")).to_have_count(0)
tab.page.keyboard.press("Escape")
expect(tab.card).to_have_count(0)
- assert server.read_calls == ["o1", "o2"]
+ assert server.read_calls == ["o1", "o2", "o4"]
# Must-acknowledge, sizes, sound and device switches --------------------------------------------
diff --git a/ui_tests/test_v2_workflow_run_card.py b/ui_tests/test_v2_workflow_run_card.py
new file mode 100644
index 000000000..abb714b3a
--- /dev/null
+++ b/ui_tests/test_v2_workflow_run_card.py
@@ -0,0 +1,1177 @@
+# test_v2_workflow_run_card.py
+"""
+UI test for the V2 workflow run card, the app-shell run tracker and delivered-message footers.
+Version: 0.261.251
+Implemented in: 0.261.251
+
+This test ensures that a saved workflow run a chat-orchestration plan started is shown and settled
+correctly in V2. It mounts the real app shell, chat list and chat page with the real stores and the
+real run tracker, stubs every server route at the network layer, and checks:
+
+- the live run card under the plan's answer: each state the status route reports, Check now and
+ its time, Cancel behind a confirmation, Retry as the durable runtime resume from a fresh
+ runtime read with a fresh request id (never /resume-failed), and Review and approve held while
+ another action on the same run is under way;
+- that the tracker's runs for the answer still show, live, when the plan's own run list failed to
+ load or names none of them, that they wait for the list to answer, that a step the list says
+ can't be opened stays closed, and that a plan run that is gone shows no card at all;
+- the running tag in the chat list, and how it gives way to the unread dot;
+- how a delivery that lands during the page session is settled: in another chat, and in the open
+ chat after any active stream or orchestration turn ends, without overriding the user's choice
+ of source, and without replacing a question the user sent while the chat was being re-read;
+- the footer on each delivered message (Follow up, Retry workflow run, Open run), and that the
+ plain chat Retry and Edit are absent on delivered messages;
+- that nothing reads run status when the feature flags are off, that one tracker tick is one
+ request, and that deliveries already in the first read stay quiet.
+
+Refs #1546 (Phase 6), #1543 (Chat Orchestration Workflows), #1610 (6b-1, the server half).
+"""
+
+import copy
+import hashlib
+import json
+import re
+import sys
+from datetime import datetime, timedelta, timezone
+from pathlib import Path
+from urllib.parse import parse_qs, urlsplit
+
+import pytest
+from playwright.sync_api import expect
+
+ROOT = Path(__file__).resolve().parents[1]
+FUNCTIONAL_TESTS = ROOT / "functional_tests"
+for _entry in (ROOT, FUNCTIONAL_TESTS):
+ if str(_entry) not in sys.path:
+ sys.path.insert(0, str(_entry))
+
+from test_support.versioning import assert_app_version_at_least # noqa: E402
+from ui_tests.fixtures.agent_delegation.harness_build import ensure_bundle # noqa: E402
+from ui_tests.fixtures.playwright_connection import connect_options # noqa: E402,F401
+from ui_tests.test_v2_notifications_bell import Harness, STATIC, V2_SOURCE # noqa: E402
+
+
+pytestmark = pytest.mark.ui
+
+IMPLEMENTED_IN = "0.261.251"
+FIXTURE = ROOT / "ui_tests" / "fixtures" / "workflow_run_tracking"
+BUNDLE = FIXTURE / "harness.bundle.js"
+
+CHAT = "chat-1"
+CHAT_TITLE = "Weekly planning"
+OTHER = "chat-2"
+OTHER_TITLE = "Budget review"
+ORUN = "orun-1"
+TURN = "turn-1"
+WORKFLOW = "wf-digest"
+RUN = "wrun-1"
+STEP = "step-1"
+NAME = "Weekly digest"
+HOSTILE = ''
+
+STATUS = "/api/v2/orchestration/workflow-runs/status"
+LINKS = f"/api/v2/orchestration/runs/{ORUN}/workflow-runs"
+PROPOSALS = f"/api/v2/orchestration/runs/{ORUN}/workflow-proposals"
+RUN_BASE = f"/api/user/workflows/{WORKFLOW}/runs/{RUN}"
+RUN_HREF = f"/workspace/workflows?workflow_id={WORKFLOW}&run_id={RUN}"
+M365_HREF = "/profile?tab=settings#m365-connection-status"
+
+FEATURES = {
+ "enable_desktop_notifications": True,
+ "enable_chat_orchestration": True,
+ "allow_user_workflows": True,
+ "enable_chat_orchestration_workflow_runs": True,
+ "enable_chat_workflow_results": True,
+}
+SETTINGS = {"desktopNotificationsEnabled": True}
+
+CLOCK_START = datetime(2026, 1, 5, 9, 7, 0, tzinfo=timezone.utc)
+REQUESTED_AT = "2026-01-05T09:00:00Z"
+STARTED_AT = "2026-01-05T09:00:05Z"
+COMPLETED_AT = "2026-01-05T09:05:00Z"
+
+UUID = re.compile(r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}")
+STATIC_FOOTNOTE = "Status when this message loaded. Open the run for its progress and results."
+READ_ERROR = "Couldn't check the run status right now. Try again."
+LOAD_ERROR = "Could not load the workflow runs this plan started."
+QUESTION = "What changed since last week?"
+RETRY_REFUSAL = (
+ "A workflow run posted this message, so it can't be retried here. "
+ "To run the workflow again, open the run in Workflows."
+)
+RUNTIME_FAILED = {
+ "runtime": {"schema_version": 1, "version": 9, "state": "failed", "can_resume": True},
+ "can_decide": False,
+}
+RUNTIME_RESUMED = {
+ "runtime": {"schema_version": 1, "version": 10, "state": "running", "can_resume": False},
+ "can_decide": False,
+}
+
+# The one tracker the harness's app shell starts; a test reads its state and kicks it directly.
+ALIAS = (
+ "Object.defineProperty(window, 'NotificationHarness', "
+ "{ configurable: true, get: () => window.WorkflowRunTrackingHarness });"
+)
+H = "window.WorkflowRunTrackingHarness"
+
+PHASES = {
+ "queued": "running", "running": "running", "waiting": "needs_you",
+ "completed": "finished", "completed_partial": "finished",
+ "failed": "failed", "expired": "failed", "cancelled": "cancelled",
+}
+ACTIVE = {"queued", "running", "waiting"}
+FAILURES = {
+ "failed": ("The run stopped before it finished.", "failed"),
+ "expired": ("It reached its time limit.", "deadline_exceeded"),
+}
+APPROVAL = {"reason": "approval", "action": "approve", "gate_id": "gate-1"}
+
+
+def merge(base, overrides):
+ for key, value in overrides.items():
+ if isinstance(value, dict) and isinstance(base.get(key), dict):
+ merge(base[key], value)
+ else:
+ base[key] = value
+ return base
+
+
+def status_row(status, *, conversation=CHAT, run=RUN, step=STEP, orun=ORUN, name=NAME, **overrides):
+ """One row as GET /api/v2/orchestration/workflow-runs/status projects it (6b-1)."""
+ active = status in ACTIVE
+ error, code = FAILURES.get(status, (None, None))
+ row = {
+ "workflow_id": WORKFLOW, "workflow_scope": "personal", "run_id": run,
+ "conversation_id": conversation, "orchestration_run_id": orun, "step_id": step,
+ "workflow_name": name,
+ "status": status, "phase": PHASES.get(status, "running"),
+ "runtime_version": 7, "step_index": 1, "step_count": 4, "step_label": None,
+ "waiting": None, "live": active,
+ "requested_at": REQUESTED_AT, "started_at": STARTED_AT,
+ "completed_at": None if active or status == "expired" else COMPLETED_AT,
+ "elapsed_seconds": 95,
+ "delivery": {"status": "not_applicable", "generation": None, "message_id": None,
+ "delivered_at": None, "reason": None},
+ "error": error, "error_code": code, "retry_blocked": None,
+ "actions": {"cancel": active, "retry": False, "approve": False, "open_run": True},
+ }
+ return merge(row, copy.deepcopy(overrides))
+
+
+def delivery_id(run=RUN, conversation=CHAT, generation=1):
+ """6b-1's delivered message id: a hash of the run, the chat and the generation."""
+ canonical = json.dumps([run, conversation, generation], ensure_ascii=False, separators=(",", ":"))
+ return "assistant_workflow_delivery_" + hashlib.sha256(canonical.encode("utf-8")).hexdigest()[:40]
+
+
+def delivered(*, run=RUN, conversation=CHAT, generation=1, at="2026-01-05T09:05:30Z"):
+ return {"status": "delivered", "generation": generation,
+ "message_id": delivery_id(run, conversation, generation), "delivered_at": at, "reason": None}
+
+
+def answer_messages(conversation=CHAT):
+ """The plan's question and its saved answer, which started the workflow."""
+ return [
+ {"id": "user-1", "conversation_id": conversation, "role": "user",
+ "content": "Run my weekly digest now.", "metadata": {"orchestration_turn_id": TURN}},
+ {"id": "answer-1", "conversation_id": conversation, "role": "assistant",
+ "content": "Started `Weekly digest`. I'll post the results here when the run finishes.",
+ "metadata": {"orchestration": {
+ "run_id": ORUN, "turn_id": TURN, "outcome": "completed", "finalization_status": "saved",
+ "message_saved": True,
+ "plan_summary": {
+ "plan_id": "plan-1", "turn_id": TURN, "status": "completed",
+ "intent_summary": "Run my weekly digest now", "step_count": 2,
+ "capabilities_used": ["compose", "workflow_run"],
+ },
+ }}},
+ ]
+
+
+NOTE_TEXT = {
+ "result": "The digest found three new files.",
+ "failed": f"`{NAME}` failed: the run stopped before it finished.",
+ "cancelled": f"`{NAME}` was cancelled.",
+ "status": f"`{NAME}` finished. Open the run to see its results.",
+}
+
+
+def delivered_message(kind="result", *, conversation=CHAT, run=RUN, generation=1, available=True):
+ """A message 6b-1 posts into the chat, with its workflow_delivery metadata."""
+ metadata = {"workflow_delivery": {
+ "version": 1, "kind": kind, "workflow_id": WORKFLOW, "workflow_scope": "personal",
+ "run_id": run, "generation": generation,
+ "run_status": {"result": "completed", "failed": "failed", "cancelled": "cancelled"}.get(kind, "completed"),
+ "orchestration_run_id": ORUN, "step_id": STEP, "requested_at": REQUESTED_AT,
+ }}
+ if kind in ("result", "analysis"):
+ metadata["workflow_result"] = {
+ "version": "workflow-result-v1", "workflow_id": WORKFLOW, "run_id": run,
+ "result_sha256": "a" * 64, "status": "completed", "workflow_name": NAME,
+ "completed_at": COMPLETED_AT, "available": available,
+ }
+ label = f"Results from `{NAME}` · you asked on Mon Jan 5, 2026, 9:00 AM UTC"
+ return {
+ "id": delivery_id(run, conversation, generation), "conversation_id": conversation,
+ "role": "assistant", "content": f"{label}\n\n{NOTE_TEXT[kind]}", "metadata": metadata,
+ "citations": [], "user_message": None,
+ }
+
+
+class RunHarness(Harness):
+ """The bell harness's network stubs, with the run status, run links and run action routes."""
+
+ def __init__(self, page, stylesheets):
+ # Stream numbers start above the seeded chat's ids, so a question sent here and its reply never reuse them.
+ super().__init__(page, stylesheets, {CHAT: CHAT_TITLE, OTHER: OTHER_TITLE}, streams=100)
+ self.messages_by_chat = {CHAT: answer_messages(), OTHER: []}
+ self.link_items = [{"step_id": STEP, "name": NAME, "state": "running", "reason": None,
+ "workflow_id": WORKFLOW, "workflow_run_id": RUN}]
+ self.status_rows = []
+ self.available = True
+ self.truncated = False
+ self.status_error = None
+ self.clock = CLOCK_START
+ self.global_checked = []
+ self.runtime_status = 200
+ self.runtime_payload = copy.deepcopy(RUNTIME_FAILED)
+ self.resume_status = 200
+ self.resume_code = None
+ self.resume_bodies = []
+ self.hold_resume = False
+ self.held_resumes = []
+ self.cancel_status = 200
+ self.cancel_calls = 0
+ self.retry_calls = []
+ self.links_status = 200
+ self.hold_links = False
+ self.held_links = []
+ self.hold_messages = False
+ self.held_messages = []
+
+ # Routes -----------------------------------------------------------------------------------
+
+ def page_route(self, route, method, path, query):
+ if method == "GET" and path == "/harness.html":
+ route.fulfill(path=str(FIXTURE / "harness.html"), content_type="text/html")
+ return True
+ if method == "GET" and path == "/harness.bundle.js":
+ route.fulfill(path=str(BUNDLE), content_type="application/javascript")
+ return True
+ return super().page_route(route, method, path, query)
+
+ def conversation_route(self, route, method, path, query):
+ if method == "GET" and path == "/api/get_messages":
+ messages = copy.deepcopy(self.messages_by_chat.get(query.get("conversation_id", ""), []))
+ if self.hold_messages:
+ # Read now and answered on release, as a slow server would.
+ self.held_messages.append((route, messages))
+ else:
+ route.fulfill(json={"messages": messages})
+ return True
+ return super().conversation_route(route, method, path, query)
+
+ def other_route(self, route, method, path, query):
+ if method == "GET" and path == STATUS:
+ return self.answer_status(route, path, query)
+ if method == "GET" and path == LINKS:
+ if self.hold_links:
+ self.held_links.append(route)
+ return True
+ return self.answer_links(route)
+ if method == "GET" and path == PROPOSALS:
+ route.fulfill(json={"run_id": ORUN, "proposals": []})
+ return True
+ if method == "GET" and path == f"{RUN_BASE}/runtime":
+ return self.answer(route, path, self.runtime_status, self.runtime_payload)
+ if method == "POST" and path == f"{RUN_BASE}/runtime/resume":
+ self.resume_bodies.append(route.request.post_data_json)
+ if self.hold_resume:
+ self.held_resumes.append(route)
+ return True
+ if self.resume_status == 200:
+ return self.answer(route, path, 200, RUNTIME_RESUMED)
+ payload = {"error": "Raw server text that must not be shown."}
+ if self.resume_code:
+ payload["code"] = self.resume_code
+ return self.answer(route, path, self.resume_status, payload)
+ if method == "POST" and path == f"{RUN_BASE}/cancel":
+ self.cancel_calls += 1
+ if self.cancel_status == 200:
+ return self.answer(route, path, 200, {"success": True})
+ return self.answer(route, path, self.cancel_status, {"error": "Raw server text that must not be shown."})
+ retry = re.fullmatch(r"/api/message/([^/]+)/retry", path)
+ if method == "POST" and retry:
+ self.retry_calls.append(retry.group(1))
+ return self.answer(route, path, 400, {"error": RETRY_REFUSAL, "code": "workflow_delivery_retry_unsupported"})
+ return super().other_route(route, method, path, query)
+
+ def answer(self, route, path, status, payload):
+ if status >= 400:
+ self.expected_http_failures.add((path, status))
+ route.fulfill(status=status, json=payload)
+ return True
+
+ def release_resumes(self):
+ """Answer the held resumes the way the server accepts one."""
+ assert self.held_resumes, "No resume was held."
+ held, self.held_resumes = self.held_resumes, []
+ for route in held:
+ self.answer(route, f"{RUN_BASE}/runtime/resume", 200, RUNTIME_RESUMED)
+
+ def answer_links(self, route):
+ if self.links_status != 200:
+ return self.answer(route, LINKS, self.links_status, {"error": "Raw server text that must not be shown."})
+ route.fulfill(json={"run_id": ORUN, "workflow_runs": copy.deepcopy(self.link_items)})
+ return True
+
+ def release_links(self):
+ """Answer the held reads of the plan's runs with the list as it is now."""
+ assert self.held_links, "No read of the plan's runs was held."
+ held, self.held_links = self.held_links, []
+ for route in held:
+ self.answer_links(route)
+
+ def release_messages(self):
+ """Answer the held message reads with the messages each one was cut from when it arrived."""
+ assert self.held_messages, "No message read was held."
+ held, self.held_messages = self.held_messages, []
+ for route, messages in held:
+ route.fulfill(json={"messages": messages})
+
+ def answer_stream(self, route, body):
+ """Save the question and its reply, as the server does, then finish the reply."""
+ conversation = body.get("conversation_id") or CHAT
+ number = self.streams + 1
+ self.messages_by_chat.setdefault(conversation, []).extend([
+ {"id": f"user-{number}", "conversation_id": conversation, "role": "user",
+ "content": body.get("message", ""), "metadata": {}},
+ {"id": f"reply-{number}", "conversation_id": conversation, "role": "assistant",
+ "content": "Here is the plan.", "metadata": {}},
+ ])
+ super().answer_stream(route, body)
+
+ def answer_status(self, route, path, query):
+ if self.status_error:
+ return self.answer(route, path, self.status_error, {
+ "error": "Workflow run status isn't available right now. Try again later.",
+ "code": "workflow_run_status_unavailable",
+ })
+ conversation = query.get("conversation_id")
+ rows = [row for row in self.status_rows if conversation is None or row["conversation_id"] == conversation]
+ checked_at = self.peek()
+ self.clock += timedelta(seconds=1)
+ if conversation is None:
+ self.global_checked.append(checked_at)
+ return self.answer(route, path, 200, {
+ "available": self.available, "runs": copy.deepcopy(rows),
+ "checked_at": checked_at, "truncated": self.truncated,
+ })
+
+ def peek(self):
+ """The checked_at the next status response carries."""
+ return self.clock.strftime("%Y-%m-%dT%H:%M:%SZ")
+
+ # Opening ----------------------------------------------------------------------------------
+
+ def open(self, *, features=None, settings=None, browser=None, wait_card=True, wait_tracker=True):
+ self.page.add_init_script(script=ALIAS)
+ super().open(
+ "/chat", browser=browser, active=CHAT,
+ features=dict(FEATURES if features is None else features),
+ settings=dict(SETTINGS if settings is None else settings),
+ )
+ self.js(f"""() => {{
+ const boot = {H}.stores.bootstrap.useBootstrapStore;
+ boot.setState({{ data: {{ ...boot.getState().data, orchestration: {{ enabled: true, capabilities: [] }} }} }});
+ }}""")
+ self.js(f"(id) => {H}.stores.chat.useChatStore.getState().selectConversation(id)", CHAT)
+ expect(self.page.get_by_text("Run my weekly digest now.", exact=True)).to_be_visible()
+ if wait_card:
+ expect(self.card).to_be_visible()
+ if wait_tracker:
+ self.wait_for(lambda: bool(self.tracker_checked()), "the tracker read every chat's runs once")
+
+ # Reading ----------------------------------------------------------------------------------
+
+ @property
+ def card(self):
+ return self.page.get_by_role("region", name="Started workflows")
+
+ @property
+ def live_row(self):
+ return self.card.get_by_role("listitem")
+
+ def footer(self, message_id):
+ return self.page.locator(f"#workflow-delivery-{message_id}")
+
+ def expect_no_plain_retry(self, message_id):
+ """The delivered message keeps its own actions, but not the plain chat Retry or Edit."""
+ message = self.page.locator(f"#message-{message_id}")
+ expect(message.get_by_role("button", name="Copy", exact=True)).to_have_count(1)
+ expect(message.get_by_role("button", name="Retry", exact=True)).to_have_count(0)
+ expect(message.get_by_role("button", name="Review orchestration recovery")).to_have_count(0)
+ # The question in the same chat still offers Edit, so its absence below is not a closed menu.
+ self.expect_edit_offered("user-1", True)
+ self.expect_edit_offered(message_id, False)
+
+ def expect_edit_offered(self, message_id, offered):
+ """Open the message's More actions menu, check it opened, and look for Edit."""
+ message = self.page.locator(f"#message-{message_id}")
+ more = message.get_by_role("button", name="More actions", exact=True)
+ copy_with_sources = message.get_by_role("button", name="Copy with sources", exact=True)
+ more.click()
+ expect(copy_with_sources).to_be_visible()
+ expect(message.get_by_role("button", name="Edit", exact=True)).to_have_count(1 if offered else 0)
+ more.click()
+ expect(copy_with_sources).to_have_count(0)
+
+ def tag(self, label=f"Running {NAME}"):
+ return self.page.get_by_role("img", name=label, exact=True)
+
+ def rail_row(self, title):
+ return self.page.locator("li", has_text=title).last
+
+ def unread_dot(self, title):
+ return self.rail_row(title).locator('span[aria-label="Unread"]')
+
+ def tracker_checked(self):
+ return self.js(f"() => {H}.stores.workflowRunTracker.useWorkflowRunTrackerStore.getState().snapshot.globalCheckedAt")
+
+ def replies(self):
+ return self.js(f"() => {H}.completedReplies.map((reply) => ({{ ...reply }}))")
+
+ def workflow_replies(self):
+ return [reply for reply in self.replies() if reply.get("source") == "workflow"]
+
+ def tracked_runs(self):
+ return self.js(f"""() => Object.values(
+ {H}.stores.workflowRunTracker.useWorkflowRunTrackerStore.getState().snapshot.runs,
+ ).map((tracked) => tracked.row.run_id)""")
+
+ def chat_state(self, key):
+ return self.js(f"(key) => {H}.stores.chat.useChatStore.getState()[key]", key)
+
+ def local_time(self, iso):
+ return self.js("(iso) => new Date(iso).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })", iso)
+
+ def status_queries(self):
+ """The chat each status read asked about, in order; None for a read of every chat."""
+ found = []
+ for entry in self.requests:
+ parts = urlsplit(entry[1])
+ if entry[0] == "GET" and parts.path == STATUS:
+ found.append(parse_qs(parts.query).get("conversation_id", [None])[-1])
+ return found
+
+ def global_reads(self):
+ return self.status_queries().count(None)
+
+ def message_reads(self, conversation=CHAT):
+ count = 0
+ for entry in self.requests:
+ parts = urlsplit(entry[1])
+ if entry[0] == "GET" and parts.path == "/api/get_messages" \
+ and parse_qs(parts.query).get("conversation_id", [None])[-1] == conversation:
+ count += 1
+ return count
+
+ def mark_reads(self, conversation):
+ return self.count_requests("POST", f"/api/conversations/{conversation}/mark-read")
+
+ def count_reads(self):
+ return sum(
+ 1 for entry in self.requests
+ if entry[0] == "GET" and urlsplit(entry[1]).path == "/api/notifications/count"
+ )
+
+ # Acting -----------------------------------------------------------------------------------
+
+ def check_now(self):
+ button = self.card.get_by_role("button", name="Check now")
+ expect(button).not_to_have_attribute("aria-disabled", "true")
+ reads = self.status_queries().count(CHAT)
+ button.click()
+ self.wait_for(lambda: self.status_queries().count(CHAT) > reads, "Check now read this chat's runs")
+
+ def global_check(self):
+ """One tracker tick, as its timer would run it."""
+ reads = self.global_reads()
+ checked = self.tracker_checked()
+ self.js(f"() => {H}.tracker.kickWorkflowRunTracker({{ immediate: true }})")
+ self.wait_for(lambda: self.global_reads() > reads, "the tracker read every chat's runs")
+ self.wait_for(lambda: self.tracker_checked() != checked, "the tracker took in its read")
+
+ def deliver(self, conversation=CHAT, *, run=RUN, kind="result", at=None):
+ """6b-1 posts a run's result: the message, the unread mark and the delivered row."""
+ message = delivered_message(kind, conversation=conversation, run=run)
+ self.messages_by_chat.setdefault(conversation, []).append(message)
+ self.conversations[conversation]["unread"] = True
+ index = next(i for i, row in enumerate(self.status_rows) if row["run_id"] == run)
+ previous = self.status_rows[index]
+ self.status_rows[index] = status_row(
+ "completed" if kind != "failed" else "failed", conversation=conversation, run=run,
+ step=previous["step_id"], orun=previous["orchestration_run_id"],
+ delivery=delivered(run=run, conversation=conversation, at=at or self.peek()),
+ )
+ return message["id"]
+
+ def hold_stream(self, held):
+ self.js(f"(held) => {H}.stores.chat.useChatStore.setState({{ streaming: held }})", held)
+
+ def orchestrate_off(self):
+ """Switch the composer to plain chat, so Send streams a reply rather than starting a plan."""
+ toggle = self.page.get_by_role("button", name="Orchestrate", exact=True)
+ expect(toggle).to_have_attribute("aria-pressed", "true")
+ toggle.click()
+ expect(toggle).to_have_attribute("aria-pressed", "false")
+
+ def hold_orchestration(self, held):
+ self.js(f"""(held) => {H}.stores.orchestration.useOrchestrationStore.setState({{ inFlight: held ? {{
+ 'orun-x': {{ conversationId: '{CHAT}', turnId: 't', runId: 'orun-x', planId: 'p',
+ startedAt: Date.now(), resumed: false }},
+ }} : {{}} }})""", held)
+
+
+@pytest.fixture(scope="module")
+def run_assets():
+ index = STATIC / "v2" / "index.html"
+ assert index.is_file(), "Build the V2 SPA first: npm --prefix application/v2_ui run build"
+ built = index.stat().st_mtime
+ stale = [item for item in V2_SOURCE.rglob("*") if item.is_file() and item.stat().st_mtime > built]
+ if stale:
+ pytest.fail(
+ f"The V2 bundle is stale ({stale[0].relative_to(ROOT)} changed after it was built). "
+ "Run npm --prefix application/v2_ui run build",
+ )
+ hrefs = re.findall(r']*href="([^"]+\.css)"', index.read_text(encoding="utf-8"))
+ stylesheets = [STATIC / href.removeprefix("/static/") for href in hrefs]
+ assert stylesheets and all(sheet.is_file() for sheet in stylesheets), stylesheets
+ ensure_bundle(entry=FIXTURE / "harness_entry.tsx", bundle=BUNDLE)
+ yield stylesheets
+ BUNDLE.unlink(missing_ok=True)
+ # esbuild writes the entry's imported CSS beside the bundle.
+ BUNDLE.with_suffix(".css").unlink(missing_ok=True)
+
+
+@pytest.fixture
+def ui(page, run_assets):
+ api = RunHarness(page, run_assets)
+ yield api
+ assert not api.errors, api.errors
+ assert not api.unexpected, api.unexpected
+ injected = api.page.evaluate("() => window.__xss")
+ assert injected is None
+ assert not [entry for entry in api.requests if "resume-failed" in entry[1]]
+
+
+def test_version_is_at_least_the_implementing_release():
+ assert_app_version_at_least(IMPLEMENTED_IN)
+
+
+# The run card -----------------------------------------------------------------------------------
+
+
+def test_the_card_follows_each_live_state_the_status_route_reports(ui):
+ ui.status_rows = [status_row("running")]
+ ui.open()
+ row = ui.live_row
+ expect(row.get_by_role("status").first).to_contain_text("Running")
+ expect(row).to_contain_text("Step 2 of 4")
+ expect(row).to_contain_text("1 min elapsed")
+ expect(row.get_by_role("button", name=f"Cancel run of {NAME}", exact=True)).to_be_visible()
+ expect(row.get_by_role("link", name=f"Open run of {NAME}", exact=True)).to_have_attribute("href", RUN_HREF)
+
+ # Recovery is still running: it names the reason without asking anything of the user.
+ ui.status_rows = [status_row("running", waiting={"reason": "recovery", "action": "open_run", "gate_id": None})]
+ ui.check_now()
+ expect(row).to_contain_text("Recovering after an interruption.")
+ expect(row.get_by_role("status").first).to_contain_text("Running")
+ expect(row).not_to_contain_text("Needs you")
+
+ ui.status_rows = [status_row("waiting", waiting=APPROVAL, actions={"approve": True})]
+ ui.check_now()
+ expect(row.get_by_role("status").first).to_contain_text("Needs you")
+ expect(row).to_contain_text("Waiting for your approval.")
+ expect(row).not_to_contain_text("Step 2 of 4")
+ approve = row.get_by_role("link", name=f"Review and approve {NAME}", exact=True)
+ expect(approve).to_have_attribute("href", RUN_HREF)
+ expect(row.get_by_role("link", name=f"Open run of {NAME}", exact=True)).to_have_count(0)
+
+ ui.status_rows = [status_row("waiting", waiting={
+ "reason": "microsoft_365_reconnect", "action": "reconnect", "gate_id": None})]
+ ui.check_now()
+ expect(row).to_contain_text("Reconnect Microsoft 365 to continue.")
+ expect(row.get_by_role("link", name="Reconnect Microsoft 365", exact=True)).to_have_attribute("href", M365_HREF)
+ expect(row.get_by_role("link", name=f"Review and approve {NAME}", exact=True)).to_have_count(0)
+
+ row.get_by_role("link", name=f"Open run of {NAME}", exact=True).click()
+ expect(ui.page.locator("[data-workflows-page]")).to_contain_text(f"?workflow_id={WORKFLOW}&run_id={RUN}")
+
+
+TERMINAL_CASES = [
+ pytest.param(status_row("completed"), "Completed", "The results are in the workflow's run history.",
+ id="completed-not-applicable"),
+ pytest.param(status_row("completed_partial", delivery={
+ "status": "undeliverable", "generation": 1, "reason": "chat_unavailable"}),
+ "Partly completed", "The results are in the workflow's run history.", id="partial-undeliverable"),
+ pytest.param(status_row("completed", delivery={"status": "pending", "generation": 1}),
+ "Completed", "Posting results…", id="posting"),
+ pytest.param(status_row("completed", delivery=delivered()), "Completed", "Results were posted to this chat.",
+ id="delivered-not-loaded"),
+ pytest.param(status_row("failed"), "Failed", "The run stopped before it finished.", id="failed"),
+ pytest.param(status_row("expired", delivery={"status": "expired"}), "Timed out", "It reached its time limit.",
+ id="expired"),
+ pytest.param(status_row("cancelled"), "Cancelled", "The run was cancelled.", id="cancelled"),
+ pytest.param(status_row("mystery"), "Status unavailable", None, id="unknown-status"),
+ pytest.param(status_row("failed", error=None), "Status unavailable", None, id="failed-without-error"),
+]
+
+
+@pytest.mark.parametrize("row_data,label,text", TERMINAL_CASES)
+def test_finished_and_unknown_states_read_plainly_and_fail_closed(ui, row_data, label, text):
+ ui.status_rows = [row_data]
+ ui.open()
+ row = ui.live_row
+ expect(row.get_by_role("status").first).to_contain_text(label)
+ if text:
+ expect(row).to_contain_text(text)
+ expect(row.get_by_role("link", name=f"Open run of {NAME}", exact=True)).to_have_attribute("href", RUN_HREF)
+ expect(row.get_by_role("button", name=f"Cancel run of {NAME}", exact=True)).to_have_count(0)
+ expect(row.get_by_role("button", name=f"Retry run of {NAME}", exact=True)).to_have_count(0)
+ expect(row.get_by_role("button", name=re.compile("Results posted below"))).to_have_count(0)
+
+
+def test_check_now_shows_when_it_checked_and_keeps_the_last_state_on_an_error(ui):
+ ui.status_rows = [status_row("running")]
+ ui.open()
+ checked = ui.card.get_by_text(re.compile(r"^Checked "))
+ expect(checked).to_have_text(f"Checked {ui.local_time('2026-01-05T09:07:00Z')}")
+ expect(checked).to_have_attribute("aria-live", "polite")
+
+ ui.clock = datetime(2026, 1, 5, 9, 12, 0, tzinfo=timezone.utc)
+ ui.check_now()
+ expect(checked).to_have_text(f"Checked {ui.local_time('2026-01-05T09:12:00Z')}")
+
+ ui.status_error = 503
+ ui.status_rows = [status_row("failed")]
+ ui.check_now()
+ expect(ui.card.get_by_text(READ_ERROR, exact=True)).to_be_visible()
+ expect(ui.live_row.get_by_role("status").first).to_contain_text("Running")
+ expect(checked).to_have_text(f"Checked {ui.local_time('2026-01-05T09:12:00Z')}")
+
+ ui.status_error = None
+ ui.check_now()
+ expect(ui.live_row.get_by_role("status").first).to_contain_text("Failed")
+ expect(ui.card.get_by_text(READ_ERROR, exact=True)).to_have_count(0)
+
+
+def test_cancel_asks_first_and_reports_each_outcome(ui):
+ ui.status_rows = [status_row("running")]
+ ui.open()
+ row = ui.live_row
+ cancel = row.get_by_role("button", name=f"Cancel run of {NAME}", exact=True)
+ dialog = ui.page.get_by_role("dialog").or_(ui.page.get_by_role("alertdialog"))
+
+ cancel.click()
+ expect(dialog).to_contain_text("Cancel this run?")
+ expect(dialog).to_contain_text(f"This asks {NAME} to stop. Anything it already did stays done.")
+ expect(dialog).to_contain_text("A cancelled run can't be retried.")
+ ui.page.wait_for_timeout(300)
+ assert ui.cancel_calls == 0
+ dialog.get_by_role("button", name="Keep running", exact=True).click()
+ expect(dialog).to_have_count(0)
+ assert ui.cancel_calls == 0
+
+ outcomes = [
+ (200, "Cancel requested."),
+ (404, "This run is no longer available."),
+ (409, "This run already finished or changed. Check its status."),
+ (500, "Couldn't cancel the run. Try again."),
+ ]
+ for status, text in outcomes:
+ ui.cancel_status = status
+ calls = ui.cancel_calls
+ expect(cancel).not_to_have_attribute("aria-disabled", "true")
+ cancel.click()
+ dialog.get_by_role("button", name="Cancel run", exact=True).click()
+ ui.wait_for(lambda: ui.cancel_calls == calls + 1, f"Cancel sent one request for {status}")
+ expect(row).to_contain_text(text)
+ assert ui.cancel_calls == len(outcomes)
+
+
+RESUME_REFUSALS = [
+ (409, "stale_version", "The run changed since it was checked. Check its status and try again."),
+ (409, "invalid_state", "This run can't be retried in its current state."),
+ (409, "request_conflict", "Another request changed this run. Check its status and try again."),
+ (409, "workflow_deleting", "The workflow is being deleted, so this run can't be retried."),
+ (409, "workflow_definition_changed", "The workflow changed after this run started. Start a new run from Workflows."),
+ (409, "workflow_already_running", "Another run of this workflow is in progress. Retry when it finishes."),
+ (409, "deadline_exceeded", "This run reached its time limit, so it can't be retried."),
+ (409, "workflow_deleted", "The workflow was deleted, so this run can't be retried."),
+ (409, "a_new_code", "This run can't be retried right now. Check its status."),
+ (400, None, "The retry request wasn't accepted."),
+ (403, None, "You don't have access to this run."),
+ (404, None, "This run is no longer available."),
+ (503, None, "Workflows aren't available right now. Try again later."),
+ (500, None, "Couldn't retry the run. Try again."),
+]
+
+
+def test_retry_resumes_from_a_fresh_runtime_read_and_explains_each_refusal(ui):
+ ui.status_rows = [status_row("failed", actions={"retry": True})]
+ ui.open()
+ row = ui.live_row
+ retry = row.get_by_role("button", name=f"Retry run of {NAME}", exact=True)
+
+ retry.click()
+ ui.wait_for(lambda: len(ui.resume_bodies) == 1, "Retry sent one resume")
+ first = ui.resume_bodies[0]
+ assert set(first) == {"expected_version", "request_id"}, first
+ assert first["expected_version"] == 9, "Retry resumes from the fresh runtime read, not the row's version"
+ assert UUID.fullmatch(first["request_id"]), first
+ expect(row).to_contain_text("Retry requested.")
+
+ for status, code, text in RESUME_REFUSALS:
+ ui.resume_status, ui.resume_code = status, code
+ sent = len(ui.resume_bodies)
+ expect(retry).not_to_have_attribute("aria-disabled", "true")
+ retry.click()
+ ui.wait_for(lambda: len(ui.resume_bodies) == sent + 1, f"Retry sent one resume for {status} {code}")
+ expect(row).to_contain_text(text)
+ shown = row.inner_text()
+ assert "Raw server text" not in shown
+ request_ids = [body["request_id"] for body in ui.resume_bodies]
+ assert len(set(request_ids)) == len(request_ids), "Each Retry sends a fresh request id"
+
+ # A run the runtime says can't be resumed, or a runtime read that's refused, sends nothing.
+ sent = len(ui.resume_bodies)
+ ui.runtime_payload = {"runtime": {"schema_version": 1, "version": 9, "state": "failed", "can_resume": False},
+ "can_decide": False}
+ retry.click()
+ expect(row).to_contain_text("This run can't be retried anymore.")
+ ui.runtime_status = 403
+ retry.click()
+ expect(row).to_contain_text("You don't have access to this run.")
+ ui.page.wait_for_timeout(300)
+ assert len(ui.resume_bodies) == sent
+
+
+@pytest.mark.parametrize("overrides,available,text", [
+ pytest.param({"actions": {"retry": False}}, True, None, id="actions-retry-false"),
+ pytest.param({"retry_blocked": "workflow_definition_changed"}, True,
+ "The workflow changed after this run started. Start a new run from Workflows.", id="blocked"),
+ pytest.param({"actions": {"retry": True}}, False,
+ "Starting workflows from chat is turned off, so Retry isn't available here.", id="gate-off"),
+])
+def test_retry_shows_only_where_the_status_row_allows_it(ui, overrides, available, text):
+ ui.available = available
+ ui.status_rows = [status_row("failed", **overrides)]
+ ui.open()
+ row = ui.live_row
+ expect(row.get_by_role("status").first).to_contain_text("Failed")
+ if text:
+ expect(row).to_contain_text(text)
+ expect(row.get_by_role("button", name=f"Retry run of {NAME}", exact=True)).to_have_count(0)
+ assert ui.resume_bodies == []
+
+
+def test_server_names_render_as_text(ui):
+ ui.link_items[0]["name"] = HOSTILE
+ ui.status_rows = [
+ status_row("running"),
+ status_row("running", conversation=OTHER, run="wrun-2", step="step-2", orun="orun-2", name=HOSTILE),
+ ]
+ ui.open()
+ expect(ui.card.get_by_text(HOSTILE, exact=True).first).to_be_visible()
+ tag = ui.tag(f"Running {HOSTILE}")
+ expect(tag).to_be_visible()
+ expect(tag).to_have_attribute("title", f"Running {HOSTILE}")
+ expect(ui.page.locator("img[src='x']")).to_have_count(0)
+
+
+@pytest.mark.parametrize("links_status,link_items", [(500, None), (200, [])], ids=["list-failed", "list-empty"])
+def test_runs_the_plans_list_does_not_name_still_show_live(ui, links_status, link_items):
+ ui.links_status = links_status
+ if link_items is not None:
+ ui.link_items = link_items
+ ui.status_rows = [
+ status_row("running"),
+ status_row("queued", run="wrun-0", step="step-0", name=HOSTILE, requested_at="2026-01-05T08:59:00Z"),
+ ]
+ ui.open()
+ expect(ui.card.get_by_text(LOAD_ERROR, exact=True)).to_have_count(1 if links_status != 200 else 0)
+ expect(ui.card.get_by_role("button", name="Try again", exact=True)).to_have_count(1 if links_status != 200 else 0)
+
+ # Oldest request first, and the name the status row carries renders as text.
+ rows = ui.live_row
+ expect(rows).to_have_count(2)
+ expect(rows.nth(0)).to_contain_text(HOSTILE)
+ expect(rows.nth(0).get_by_role("status").first).to_contain_text("Queued")
+ expect(rows.nth(1).get_by_role("status").first).to_contain_text("Running")
+ expect(rows.nth(1).get_by_role("link", name=f"Open run of {NAME}", exact=True)).to_have_attribute("href", RUN_HREF)
+ expect(ui.card.get_by_text(re.compile(r"^Checked "))).to_be_visible()
+ ui.wait_for(lambda: CHAT in ui.status_queries(), "the card read its chat's runs")
+ ui.check_now()
+
+
+def test_a_plan_run_that_is_gone_shows_nothing_even_with_tracked_runs(ui):
+ ui.links_status = 404
+ ui.status_rows = [status_row("running")]
+ ui.open(wait_card=False)
+ ui.wait_for(lambda: ui.count_requests("GET", LINKS) > 0, "the card read the plan's runs")
+ assert ui.tracked_runs() == [RUN]
+ ui.page.wait_for_timeout(500)
+ expect(ui.card).to_have_count(0)
+ assert CHAT not in ui.status_queries()
+
+
+def test_a_step_the_plans_list_says_cannot_open_stays_closed_with_a_tracked_run(ui):
+ ui.link_items = [{"step_id": STEP, "name": NAME, "state": "unavailable", "reason": "content_review",
+ "workflow_id": None, "workflow_run_id": None}]
+ ui.status_rows = [status_row("running")]
+ # The tracked run waits for the list rather than showing, with its link, before the list answers.
+ ui.hold_links = True
+ ui.open(wait_card=False)
+ ui.wait_for(lambda: ui.held_links, "the card asked for the plan's runs")
+ assert ui.tracked_runs() == [RUN]
+ ui.page.wait_for_timeout(500)
+ expect(ui.card).to_have_count(0)
+
+ ui.hold_links = False
+ ui.release_links()
+ expect(ui.card.get_by_text(
+ "This response is in content review, so its workflow link is not available.", exact=True,
+ )).to_be_visible()
+ expect(ui.live_row).to_have_count(1)
+ expect(ui.card.get_by_role("link")).to_have_count(0)
+ expect(ui.card.get_by_role("button", name="Check now")).to_have_count(0)
+ ui.page.wait_for_timeout(500)
+ assert CHAT not in ui.status_queries()
+
+
+# Flags and the one tracker ----------------------------------------------------------------------
+
+
+@pytest.mark.parametrize("flag", ["allow_user_workflows", "enable_chat_orchestration_workflow_runs"])
+def test_flags_off_keep_the_static_links_and_read_no_status(ui, flag):
+ ui.status_rows = [status_row("running"),
+ status_row("running", conversation=OTHER, run="wrun-2", step="step-2", orun="orun-2")]
+ ui.open(features={**FEATURES, flag: False}, wait_tracker=False)
+ expect(ui.card.get_by_text(STATIC_FOOTNOTE, exact=True)).to_be_visible()
+ expect(ui.card.get_by_role("button", name="Check now")).to_have_count(0)
+ ui.page.wait_for_timeout(1000)
+ assert ui.status_queries() == []
+ expect(ui.page.get_by_role("img", name=re.compile(r"^Running "))).to_have_count(0)
+
+
+def test_one_tracker_tick_reads_every_run_in_one_request(ui):
+ ui.status_rows = [
+ status_row("running"),
+ status_row("running", conversation=OTHER, run="wrun-2", step="step-2", orun="orun-2"),
+ status_row("waiting", conversation=OTHER, run="wrun-3", step="step-3", orun="orun-3",
+ waiting=APPROVAL, actions={"approve": True}),
+ ]
+ ui.open()
+ ui.page.wait_for_timeout(500)
+ before = len(ui.status_queries())
+ ui.global_check()
+ ui.page.wait_for_timeout(500)
+ assert ui.status_queries()[before:] == [None], "one tick is one read of every chat's runs"
+
+
+# The running tag --------------------------------------------------------------------------------
+
+
+def test_the_running_tag_names_the_runs_and_gives_way_to_the_unread_dot(ui):
+ ui.status_rows = [
+ status_row("running", conversation=OTHER, run="wrun-2", step="step-2", orun="orun-2"),
+ status_row("queued", conversation=OTHER, run="wrun-3", step="step-3", orun="orun-3"),
+ ]
+ ui.open()
+ tag = ui.tag("Running 2 workflows")
+ expect(tag).to_be_visible()
+ expect(tag).to_have_attribute("title", "Running 2 workflows")
+ row_text = tag.evaluate("(element) => element.closest('li')?.textContent || ''")
+ assert OTHER_TITLE in row_text
+ expect(ui.unread_dot(OTHER_TITLE)).to_have_count(0)
+
+ ui.conversations[OTHER]["unread"] = True
+ ui.js(f"() => {H}.stores.chat.useChatStore.getState().loadConversations({{ reset: true }})")
+ expect(ui.unread_dot(OTHER_TITLE)).to_be_visible()
+ expect(tag).to_have_count(0)
+
+
+# Deliveries -------------------------------------------------------------------------------------
+
+
+def test_a_delivery_in_another_chat_marks_it_unread_once(ui):
+ ui.status_rows = [status_row("running", conversation=OTHER, run="wrun-2", step="step-2", orun="orun-2")]
+ ui.open()
+ tag = ui.tag()
+ expect(tag).to_be_visible()
+ row_text = tag.evaluate("(element) => element.closest('li')?.textContent || ''")
+ assert OTHER_TITLE in row_text
+ ui.blur()
+
+ message_id = ui.deliver(OTHER, run="wrun-2")
+ counts = ui.count_reads()
+ ui.global_check()
+ ui.wait_for(lambda: len(ui.replies()) == 1, "the delivery was announced")
+ reply = ui.replies()[0]
+ assert {key: reply.get(key) for key in ("conversationId", "messageId", "runId", "source")} == {
+ "conversationId": OTHER, "messageId": message_id, "runId": "wrun-2", "source": "workflow",
+ }, reply
+ expect(ui.unread_dot(OTHER_TITLE)).to_be_visible()
+ expect(tag).to_have_count(0)
+ ui.wait_for(lambda: ui.count_reads() > counts, "the bell count was refreshed")
+ ui.wait_for(lambda: len(ui.notifications()) == 1, "one desktop notification")
+ notifications = ui.notifications()
+ assert notifications[0]["tag"] == f"simplechat-conversation-{OTHER}"
+ assert ui.mark_reads(OTHER) == 0
+ assert ui.message_reads(OTHER) == 0
+
+ ui.global_check()
+ ui.page.wait_for_timeout(300)
+ replies, notifications = ui.replies(), ui.notifications()
+ assert len(replies) == 1
+ assert len(notifications) == 1
+
+
+@pytest.mark.parametrize("chose_source", [False, True], ids=["no-source-chosen", "source-cleared"])
+def test_a_delivery_in_the_open_chat_reloads_it_and_marks_it_read(ui, chose_source):
+ ui.status_rows = [status_row("running")]
+ ui.open()
+ expect(ui.live_row.get_by_role("status").first).to_contain_text("Running")
+ # The plan's answer and the question keep their plain controls; only the delivery loses them.
+ expect(ui.page.locator("#message-answer-1").get_by_role("button", name="Review orchestration recovery")) \
+ .to_have_count(1)
+ expect(ui.page.locator("#message-user-1").get_by_role("button", name="Retry", exact=True)).to_have_count(1)
+ if chose_source:
+ ui.js(f"() => {H}.stores.chat.useChatStore.getState().clearWorkflowResultContext()")
+
+ message_id = ui.deliver()
+ reads = ui.message_reads()
+ ui.global_check()
+ expect(ui.page.get_by_text(NOTE_TEXT["result"])).to_be_visible()
+ assert ui.message_reads() == reads + 1
+ ui.wait_for(lambda: len(ui.replies()) == 1, "the delivery was announced")
+ reply = ui.replies()[0]
+ assert (reply["conversationId"], reply["messageId"], reply["runId"], reply["source"]) == (
+ CHAT, message_id, RUN, "workflow")
+ ui.wait_for(lambda: ui.mark_reads(CHAT) == 1, "the watched reply was marked read")
+ expect(ui.unread_dot(CHAT_TITLE)).to_have_count(0)
+
+ expect(ui.card.get_by_role("button", name=f"Results posted below for {NAME}", exact=True)).to_be_visible()
+ footer = ui.footer(message_id)
+ expect(footer.get_by_role("link", name=f"Open run of {NAME}", exact=True)).to_have_attribute("href", RUN_HREF)
+ follow_up = footer.get_by_role("button", name=f"Follow up on {NAME}", exact=True)
+ expect(follow_up).to_be_visible()
+ ui.expect_no_plain_retry(message_id)
+
+ chip = ui.page.locator('[data-workflow-result-chip="selected"]')
+ if chose_source:
+ expect(chip).to_have_count(0)
+ follow_up.click()
+ expect(chip).to_be_visible()
+ expect(ui.page.locator("#composer-input")).to_be_focused()
+ else:
+ expect(chip).to_be_visible()
+
+
+@pytest.mark.parametrize("hold", ["stream", "orchestration"])
+def test_the_open_chat_waits_for_its_reply_to_finish_before_reloading(ui, hold):
+ ui.status_rows = [status_row("running")]
+ ui.open()
+ held = ui.hold_stream if hold == "stream" else ui.hold_orchestration
+ held(True)
+ ui.deliver()
+ reads = ui.message_reads()
+ ui.global_check()
+ ui.page.wait_for_timeout(2600)
+ assert ui.message_reads() == reads, "the open chat must not reload while its reply is still coming"
+ replies = ui.replies()
+ assert replies == []
+
+ held(False)
+ expect(ui.page.get_by_text(NOTE_TEXT["result"])).to_be_visible(timeout=5000)
+ assert ui.message_reads() == reads + 1
+ ui.wait_for(lambda: len(ui.replies()) == 1, "the delivery was announced after the reply finished")
+
+
+@pytest.mark.parametrize("reply", ["still-coming", "finished"])
+def test_a_question_sent_during_the_re_read_stays_and_the_result_lands_once_after_its_reply(ui, reply):
+ ui.status_rows = [status_row("running")]
+ ui.open()
+ ui.orchestrate_off()
+ message_id = ui.deliver()
+ ui.hold_messages = True
+ reads = ui.message_reads()
+ ui.global_check()
+ ui.wait_for(lambda: ui.held_messages, "the open chat was re-read for the result")
+
+ # The reader sends a question while that re-read is still out.
+ ui.hold_streams = True
+ ui.send(QUESTION)
+ ui.wait_for(lambda: ui.held_streams, "the question was sent")
+ question = ui.page.get_by_text(QUESTION, exact=True)
+ answer = ui.page.get_by_text("Here is the plan.", exact=True)
+ note = ui.page.get_by_text(NOTE_TEXT["result"], exact=True)
+ expect(question).to_have_count(1)
+ ui.hold_messages = False
+
+ if reply == "finished":
+ # A quick reply can finish first. The re-read that comes back after it is older than both.
+ ui.release_stream()
+ expect(answer).to_have_count(1)
+ ui.wait_for(lambda: ui.chat_state("streaming") is False, "the reply finished")
+ assert ui.message_reads() == reads + 1
+
+ # The re-read comes back without the question, so it must not replace what is on screen.
+ ui.release_messages()
+ if reply == "still-coming":
+ ui.page.wait_for_timeout(1000)
+ expect(question).to_have_count(1)
+ expect(note).to_have_count(0)
+ assert ui.chat_state("streaming") is True
+ assert ui.message_reads() == reads + 1, "nothing is re-read again while the reply is still coming"
+ assert ui.workflow_replies() == []
+ ui.release_stream()
+
+ expect(note).to_have_count(1, timeout=5000)
+ ui.wait_for(lambda: len(ui.workflow_replies()) == 1, "the result was announced once the reply finished")
+ expect(question).to_have_count(1)
+ expect(answer).to_have_count(1)
+ assert ui.message_reads() == reads + 2
+ assert ui.workflow_replies()[0]["messageId"] == message_id
+
+ ui.global_check()
+ ui.page.wait_for_timeout(300)
+ assert len(ui.workflow_replies()) == 1
+ expect(note).to_have_count(1)
+
+
+def test_deliveries_already_in_the_first_read_stay_quiet(ui):
+ ui.status_rows = [
+ status_row("completed", conversation=OTHER, run="wrun-2", step="step-2", orun="orun-2",
+ delivery=delivered(run="wrun-2", conversation=OTHER, at="2026-01-05T09:06:30Z")),
+ status_row("completed", conversation=OTHER, run="wrun-3", step="step-3", orun="orun-3",
+ delivery=delivered(run="wrun-3", conversation=OTHER, at="2026-01-05T09:07:00Z")),
+ status_row("running", conversation=OTHER, run="wrun-4", step="step-4", orun="orun-4"),
+ ]
+ ui.open(browser={"focused": False})
+ ui.global_check()
+ ui.page.wait_for_timeout(300)
+ replies, notifications = ui.replies(), ui.notifications()
+ assert replies == []
+ assert notifications == []
+
+ # A delivery in the same second as the first read, but absent from it, is new.
+ message_id = ui.deliver(OTHER, run="wrun-4", at=ui.global_checked[0])
+ ui.global_check()
+ ui.wait_for(lambda: len(ui.replies()) == 1, "the new delivery was announced")
+ replies = ui.replies()
+ assert replies[0]["messageId"] == message_id
+ ui.global_check()
+ ui.page.wait_for_timeout(300)
+ replies, notifications = ui.replies(), ui.notifications()
+ assert [reply["messageId"] for reply in replies] == [message_id]
+ assert len(notifications) == 1
+
+
+# Delivered-message footers ----------------------------------------------------------------------
+
+
+@pytest.mark.parametrize("row_generation,offered", [(1, True), (2, False)], ids=["same-generation", "older-note"])
+def test_a_failed_note_offers_retry_only_for_its_own_generation(ui, row_generation, offered):
+ note = delivered_message("failed")
+ ui.messages_by_chat[CHAT].append(note)
+ ui.status_rows = [status_row("failed", actions={"retry": True},
+ delivery=delivered(generation=row_generation))]
+ ui.open()
+ footer = ui.footer(note["id"])
+ expect(footer.get_by_role("link", name=f"Open run of {NAME}", exact=True)).to_have_attribute("href", RUN_HREF)
+ expect(footer.get_by_role("button", name=re.compile("^Follow up"))).to_have_count(0)
+ ui.expect_no_plain_retry(note["id"])
+ retry = footer.get_by_role("button", name=f"Retry workflow run of {NAME}", exact=True)
+ if not offered:
+ ui.page.wait_for_timeout(500)
+ expect(retry).to_have_count(0)
+ return
+ expect(retry).to_be_visible()
+ retry.click()
+ ui.wait_for(lambda: len(ui.resume_bodies) == 1, "the footer's Retry sent one resume")
+ assert ui.resume_bodies[0]["expected_version"] == 9
+ expect(footer).to_contain_text("Retry requested.")
+
+
+@pytest.mark.parametrize("results_in_chat,available,offered", [
+ (True, True, True),
+ (False, True, False),
+ (True, False, False),
+], ids=["on", "flag-off", "descriptor-unavailable"])
+def test_follow_up_needs_the_flag_and_an_available_result(ui, results_in_chat, available, offered):
+ message = delivered_message("result", available=available)
+ ui.messages_by_chat[CHAT].append(message)
+ ui.status_rows = [status_row("completed", delivery=delivered())]
+ ui.open(features={**FEATURES, "enable_chat_workflow_results": results_in_chat})
+ footer = ui.footer(message["id"])
+ expect(footer.get_by_role("link", name=re.compile("^Open run"))).to_have_attribute("href", RUN_HREF)
+ expect(footer.get_by_role("button", name=re.compile("^Follow up"))).to_have_count(1 if offered else 0)
+ expect(footer.get_by_role("button", name=re.compile("^Retry workflow run"))).to_have_count(0)
+
+
+def test_a_plain_retry_that_gets_through_shows_the_servers_refusal(ui):
+ note = delivered_message("failed")
+ ui.messages_by_chat[CHAT].append(note)
+ ui.status_rows = [status_row("failed", delivery=delivered())]
+ ui.open()
+ expect(ui.footer(note["id"])).to_be_visible()
+ ui.js(f"(id) => {{ void {H}.stores.chat.useChatStore.getState().retryMessage(id); }}", note["id"])
+ ui.wait_for(lambda: ui.chat_state("streamError") == RETRY_REFUSAL, "the server's refusal was shown")
+ assert ui.retry_calls == [note["id"]]
+ expect(ui.page.get_by_text(RETRY_REFUSAL)).to_be_visible()
+
+
+def test_review_and_approve_waits_while_a_retry_is_under_way(ui):
+ """A read that lands mid-Retry and finds a gate can't open it until the Retry is answered."""
+ ui.status_rows = [status_row("failed", actions={"retry": True})]
+ ui.open()
+ row = ui.live_row
+ ui.hold_resume = True
+ row.get_by_role("button", name=f"Retry run of {NAME}", exact=True).click()
+ ui.wait_for(lambda: ui.held_resumes, "Retry sent its resume")
+
+ ui.status_rows = [status_row("waiting", waiting=APPROVAL, actions={"approve": True})]
+ ui.check_now()
+ expect(row.get_by_role("status").first).to_contain_text("Needs you")
+ approve = row.get_by_role("link", name=f"Review and approve {NAME}", exact=True)
+ expect(approve).to_have_attribute("aria-disabled", "true")
+ expect(approve).to_have_attribute("href", RUN_HREF)
+ cancel = row.get_by_role("button", name=f"Cancel run of {NAME}", exact=True)
+ expect(cancel).to_have_attribute("aria-disabled", "true")
+
+ # Neither a click nor Enter opens the run while the Retry is under way.
+ workflows_page = ui.page.locator("[data-workflows-page]")
+ approve.click(force=True)
+ ui.page.wait_for_timeout(300)
+ expect(workflows_page).to_have_count(0)
+ approve.focus()
+ ui.page.keyboard.press("Enter")
+ ui.page.wait_for_timeout(300)
+ expect(workflows_page).to_have_count(0)
+ expect(approve).to_be_visible()
+
+ ui.hold_resume = False
+ ui.release_resumes()
+ expect(row).to_contain_text("Retry requested.")
+ expect(approve).not_to_have_attribute("aria-disabled", "true")
+ expect(cancel).not_to_have_attribute("aria-disabled", "true")
+ assert len(ui.resume_bodies) == 1
+ approve.click()
+ expect(workflows_page).to_contain_text(f"?workflow_id={WORKFLOW}&run_id={RUN}")
+
+
+if __name__ == "__main__":
+ raise SystemExit(pytest.main([__file__, "-q"]))
diff --git a/ui_tests/test_v2_workflow_run_tracker_spa.py b/ui_tests/test_v2_workflow_run_tracker_spa.py
new file mode 100644
index 000000000..62f4c56f0
--- /dev/null
+++ b/ui_tests/test_v2_workflow_run_tracker_spa.py
@@ -0,0 +1,385 @@
+# test_v2_workflow_run_tracker_spa.py
+"""
+UI test for the V2 app-shell workflow run tracker in the real production SPA.
+Version: 0.261.251
+Implemented in: 0.261.251
+
+This test ensures that the one run tracker the V2 app shell owns starts and stops at the right
+moments in the real built SPA, served from `application/single_app/static/v2` with every server
+route stubbed at the network layer:
+
+- it never reads run status before the app has started, or after the app failed to start, even
+ when a later bootstrap refresh succeeds behind the error page;
+- it never reads run status when the feature flags are off, and the chat keeps Phase 5's static
+ run links;
+- moving between pages never starts a second tracker;
+- a delivery that lands during the page session raises one desktop notification and marks that
+ chat unread, and a reload never announces it again.
+
+Refs #1546 (Phase 6), #1543 (Chat Orchestration Workflows), #1610 (6b-1, the server half).
+
+Build the SPA first:
+npm --prefix .\\application\\v2_ui run build
+Run: python -m pytest .\\ui_tests\\test_v2_workflow_run_tracker_spa.py -q
+"""
+
+import copy
+import json
+import os
+import re
+import sys
+import time
+from datetime import datetime, timedelta, timezone
+from pathlib import Path
+
+import pytest
+from playwright.sync_api import expect
+
+ROOT = Path(__file__).resolve().parents[1]
+FUNCTIONAL_TESTS = ROOT / "functional_tests"
+for _entry in (ROOT, FUNCTIONAL_TESTS, ROOT / "ui_tests" / "fixtures"):
+ if str(_entry) not in sys.path:
+ sys.path.insert(0, str(_entry))
+
+from test_support.versioning import assert_app_version_at_least # noqa: E402
+from ui_tests.fixtures.playwright_connection import connect_options # noqa: E402,F401
+from ui_tests.fixtures.workflow_editor import SPA_INDEX, WORKFLOW_ID # noqa: E402
+from ui_tests.fixtures.workspace_authoring import ORIGIN, OWNER_ID, STATIC_ROOT # noqa: E402
+from ui_tests.test_v2_notifications_bell import FAKE_BROWSER # noqa: E402
+from ui_tests.test_v2_orchestration_workflow_run_links import ( # noqa: E402
+ FOOTNOTE,
+ SPA_CHAT_ID,
+ SPA_HREF,
+ SPA_RUN_ID,
+ SPA_WORKFLOW_NAME,
+ SPA_WORKFLOW_RUN_ID,
+ ChatRunLinksFixture,
+)
+from ui_tests.test_v2_workflow_run_card import delivered, status_row # noqa: E402
+
+
+pytestmark = pytest.mark.ui
+
+IMPLEMENTED_IN = "0.261.251"
+STATUS_PATH = "/api/v2/orchestration/workflow-runs/status"
+BOOTSTRAP_PATH = "/api/v2/bootstrap"
+CHAT_PATH = f"/chat?conversationId={SPA_CHAT_ID}"
+CHECKED_AT = datetime(2026, 1, 5, 9, 10, 0, tzinfo=timezone.utc)
+
+DIGEST_CHAT = "digest-chat"
+DIGEST_TITLE = "Weekly digest chat"
+DIGEST_RUN = "digest-run"
+DIGEST_NAME = "Weekly digest"
+BUDGET_CHAT = "budget-chat"
+BUDGET_TITLE = "Budget review"
+BUDGET_RUN = "budget-run"
+BUDGET_NAME = "Monthly budget"
+
+# Hide the page and show it again without moving focus, so the tracker checks again at once.
+RECHECK = """() => {
+ const env = window.__harnessEnv;
+ env.visible = false;
+ document.dispatchEvent(new Event('visibilitychange'));
+ env.visible = true;
+ document.dispatchEvent(new Event('visibilitychange'));
+}"""
+NOTIFICATIONS = "() => window.Notification.instances.map(({title, body, tag}) => ({title, body, tag}))"
+
+
+def require_fresh_bundle():
+ """The build check the fixture's open() makes, for loads that can't wait for network idle."""
+ if not SPA_INDEX.is_file():
+ pytest.fail("Build the real V2 SPA before running the run tracker UI tests.")
+ expected_js = os.getenv("SIMPLECHAT_UI_EXPECTED_JS", "")
+ expected_css = os.getenv("SIMPLECHAT_UI_EXPECTED_CSS", "")
+ if expected_js or expected_css:
+ if not expected_js or not expected_css:
+ pytest.fail("Set both SIMPLECHAT_UI_EXPECTED_JS and SIMPLECHAT_UI_EXPECTED_CSS.")
+ index = SPA_INDEX.read_text(encoding="utf-8")
+ for filename, attribute, suffix in ((expected_js, "src", "js"), (expected_css, "href", "css")):
+ if not re.fullmatch(rf"[A-Za-z0-9_.-]+\.{suffix}", filename):
+ pytest.fail("Expected build assets must be local filenames.")
+ asset = STATIC_ROOT / "v2" / "assets" / filename
+ reference = f"/static/v2/assets/{filename}"
+ if not asset.is_file() or not re.search(rf'{attribute}=["\']{re.escape(reference)}["\']', index):
+ pytest.fail("The explicitly released SPA assets are missing or have changed.")
+ return
+ built = SPA_INDEX.stat().st_mtime
+ source_root = ROOT / "application" / "v2_ui" / "src"
+ if any(source.stat().st_mtime > built for source in source_root.rglob("*") if source.is_file()):
+ pytest.fail("The production V2 bundle is stale; rebuild it before running this suite.")
+
+
+def wait_for(page, predicate, message, timeout=10):
+ """Poll a fixture-side condition. Route handlers only run during Playwright calls."""
+ deadline = time.monotonic() + timeout
+ while not predicate():
+ if time.monotonic() > deadline:
+ pytest.fail(message)
+ page.wait_for_timeout(50)
+
+
+def running_tag(page, label):
+ return page.get_by_role("img", name=label, exact=True)
+
+
+def rail_row(page, title):
+ return page.locator("li").filter(has=page.get_by_role("button", name=f"Actions for {title}", exact=True))
+
+
+def unread_dot(page, title):
+ return rail_row(page, title).locator('span[aria-label="Unread"]')
+
+
+def digest_row(status="running", **overrides):
+ """The run another chat started, as 6b-1's status route projects it."""
+ return status_row(
+ status, conversation=DIGEST_CHAT, run=DIGEST_RUN, step="digest-step", orun="digest-orun",
+ name=DIGEST_NAME, workflow_id=WORKFLOW_ID, **overrides,
+ )
+
+
+class TrackerSpaFixture(ChatRunLinksFixture):
+ """The real chat page whose plan started a saved workflow, with 6b-1's status route."""
+
+ def __init__(self, page):
+ super().__init__(page)
+ self.tracker_flags = True
+ self.desktop_notifications = False
+ self.hold_bootstrap = False
+ self.status_rows = []
+ self.status_served = 0
+ for identifier, title, updated in (
+ (DIGEST_CHAT, DIGEST_TITLE, "2026-09-05T11:00:00Z"),
+ (BUDGET_CHAT, BUDGET_TITLE, "2026-09-05T10:00:00Z"),
+ ):
+ self.conversations.append({
+ "id": identifier, "title": title, "user_id": OWNER_ID, "last_updated": updated,
+ })
+ self.messages[identifier] = [{
+ "id": f"{identifier}-message", "role": "user", "content": f"An earlier message in {title}.",
+ "conversation_id": identifier, "timestamp": updated,
+ }]
+
+ def _bootstrap(self):
+ payload = super()._bootstrap()
+ if self.tracker_flags:
+ payload["features"].update({
+ "allow_user_workflows": True, "enable_chat_orchestration_workflow_runs": True,
+ })
+ if self.desktop_notifications:
+ payload["features"]["enable_desktop_notifications"] = True
+ return payload
+
+ def _dispatch(self, route, entry):
+ if entry.method == "GET" and entry.path == BOOTSTRAP_PATH and self.hold_bootstrap:
+ self.pending_responses.append((route, entry))
+ elif entry.method == "GET" and entry.path == STATUS_PATH:
+ self._status(route, entry)
+ else:
+ super()._dispatch(route, entry)
+
+ def _status(self, route, entry):
+ """Answer the batched status read: one chat's runs, or every run still worth tracking."""
+ if set(entry.query) - {"conversation_id"} or len(entry.query.get("conversation_id", [])) > 1:
+ self.unexpected_requests.append(f"GET {entry.path} {entry.query}")
+ conversation = (entry.query.get("conversation_id") or [None])[0]
+ runs = [row for row in self.status_rows if conversation in (None, row["conversation_id"])]
+ checked_at = self.next_checked_at()
+ self.status_served += 1
+ self._json(route, {
+ "available": True, "runs": copy.deepcopy(runs), "checked_at": checked_at, "truncated": False,
+ })
+
+ def next_checked_at(self):
+ """The `checked_at` the next status read answers with: one second later per read."""
+ moment = CHECKED_AT + timedelta(seconds=self.status_served)
+ return moment.strftime("%Y-%m-%dT%H:%M:%SZ")
+
+ @property
+ def status_requests(self):
+ return [entry for entry in self.requests if entry.method == "GET" and entry.path == STATUS_PATH]
+
+ @property
+ def global_reads(self):
+ return [entry for entry in self.status_requests if entry.query == {}]
+
+ @property
+ def bootstrap_requests(self):
+ return [entry for entry in self.requests if entry.method == "GET" and entry.path == BOOTSTRAP_PATH]
+
+ @property
+ def held_bootstraps(self):
+ return [entry for _, entry in self.pending_responses if entry.path == BOOTSTRAP_PATH]
+
+ def load(self, path, *, font_size="m"):
+ """Open a page without waiting for network idle, so a held response can't stall the load."""
+ require_fresh_bundle()
+ self.preferences.update({
+ "darkModeEnabled": False, "v2RailCollapsed": False, "v2WorkspaceRailCollapsed": False,
+ "fontSizePreference": font_size,
+ })
+ self.page.set_viewport_size({"width": 1440, "height": 900})
+ self.page.goto(f"{ORIGIN}/v2{path}", wait_until="commit")
+
+ def assert_real_spa(self):
+ assert any(path.endswith(".js") for path in self.loaded_assets), "Real SPA JavaScript was not loaded."
+ assert any(path.endswith(".css") for path in self.loaded_assets), "Production CSS was not loaded."
+
+
+@pytest.fixture
+def tracker_ui(page):
+ fixture = TrackerSpaFixture(page)
+ yield fixture
+ fixture.assert_clean()
+
+
+def test_the_tracker_waits_for_the_app_to_start(tracker_ui):
+ """While the bootstrap is still loading, nothing reads run status; once it loads, one read does."""
+ ui = tracker_ui
+ page = ui.page
+ ui.hold_bootstrap = True
+ ui.load(CHAT_PATH)
+ wait_for(page, lambda: ui.held_bootstraps, "The app never asked for its bootstrap.")
+ page.wait_for_timeout(1000)
+ assert not ui.status_requests, [entry.query for entry in ui.status_requests]
+
+ ui.hold_bootstrap = False
+ ui.release_responses()
+ expect(page.get_by_role("region", name="Started workflows", exact=True)).to_be_visible()
+ wait_for(page, lambda: ui.global_reads, "The tracker never read run status after the app started.")
+ page.wait_for_timeout(1000)
+ assert len(ui.global_reads) == 1, ui.global_reads
+ ui.assert_real_spa()
+
+
+def test_the_tracker_stays_off_after_the_app_fails_to_start(tracker_ui):
+ """A bootstrap refresh that succeeds behind the error page never starts the tracker."""
+ ui = tracker_ui
+ page = ui.page
+ ui.reject_next("GET", BOOTSTRAP_PATH, status=503)
+ ui.load(CHAT_PATH)
+ heading = page.get_by_role("heading", name="Could not start SimpleChat", exact=True)
+ expect(heading).to_be_visible()
+
+ # The app refreshes its bootstrap when the window gains focus; that refresh succeeds here.
+ page.evaluate("() => window.dispatchEvent(new Event('focus'))")
+ wait_for(page, lambda: len(ui.bootstrap_requests) >= 2, "The app never refreshed its bootstrap.")
+ page.wait_for_timeout(1000)
+ expect(heading).to_be_visible()
+ assert not ui.status_requests, [entry.query for entry in ui.status_requests]
+ ui.assert_real_spa()
+
+
+def test_flags_off_keeps_phase_5_links_and_reads_no_status(tracker_ui):
+ """Without both feature flags the answer keeps its static run links, and nothing polls."""
+ ui = tracker_ui
+ page = ui.page
+ ui.tracker_flags = False
+ ui.open(CHAT_PATH)
+ region = page.get_by_role("region", name="Started workflows", exact=True)
+ expect(region).to_be_visible()
+ expect(region).to_contain_text(FOOTNOTE)
+ expect(region.get_by_role("link", name=f"Open run of {SPA_WORKFLOW_NAME}", exact=True)).to_have_attribute(
+ "href", f"/v2{SPA_HREF}",
+ )
+ expect(region.get_by_role("button", name="Check now", exact=True)).to_have_count(0)
+ assert ui.link_reads == [{"conversation_id": [SPA_CHAT_ID]}]
+ page.wait_for_timeout(1000)
+ assert not ui.status_requests, [entry.query for entry in ui.status_requests]
+
+
+def test_moving_between_pages_never_starts_a_second_tracker(tracker_ui):
+ """Following the run link and coming back re-reads the chat's runs but never restarts the tracker."""
+ ui = tracker_ui
+ page = ui.page
+ ui.status_rows = [status_row(
+ "completed", conversation=SPA_CHAT_ID, run=SPA_WORKFLOW_RUN_ID, step="run_review", orun=SPA_RUN_ID,
+ name=SPA_WORKFLOW_NAME, workflow_id=WORKFLOW_ID,
+ delivery=delivered(run=SPA_WORKFLOW_RUN_ID, conversation=SPA_CHAT_ID),
+ )]
+ ui.open(CHAT_PATH)
+ region = page.get_by_role("region", name="Started workflows", exact=True)
+ expect(region).to_contain_text("Results were posted to this chat.")
+ expect(region).to_contain_text("Checked")
+ page.wait_for_timeout(1000)
+ assert len(ui.global_reads) == 1, ui.global_reads
+
+ link = region.get_by_role("link", name=f"Open run of {SPA_WORKFLOW_NAME}", exact=True)
+ link.focus()
+ page.keyboard.press("Enter")
+ expect(page).to_have_url(f"{ORIGIN}/v2{SPA_HREF}")
+ history = page.get_by_role("list", name="Workflow run history", exact=True)
+ expect(history.locator('li[aria-current="true"]')).to_have_count(1)
+
+ page.go_back()
+ expect(page).to_have_url(f"{ORIGIN}/v2{CHAT_PATH}")
+ region = page.get_by_role("region", name="Started workflows", exact=True)
+ expect(region).to_contain_text("Results were posted to this chat.")
+ page.wait_for_timeout(1000)
+ assert len(ui.global_reads) == 1, ui.global_reads
+ assert not [entry for entry in ui.writes if entry.path != "/api/user/settings"], ui.writes
+
+
+def test_a_delivery_is_announced_once_and_never_again_after_a_reload(tracker_ui):
+ """A delivery seen landing raises one notification and an unread dot; a reload repeats neither."""
+ ui = tracker_ui
+ page = ui.page
+ ui.desktop_notifications = True
+ ui.preferences["desktopNotificationsEnabled"] = True
+ # The open chat is a plain one, so the only status reads are the tracker's own.
+ ui.messages[SPA_CHAT_ID] = [{
+ "id": "spa-user", "role": "user", "content": "A plain question.",
+ "conversation_id": SPA_CHAT_ID, "timestamp": "2026-09-16T11:59:00Z",
+ }]
+ # The window is visible but not focused, so the page isn't being watched.
+ page.add_init_script(script=f"({FAKE_BROWSER})({json.dumps({'visible': True, 'focused': False})})")
+ ui.status_rows = [digest_row(delivery={"status": "pending"})]
+ # A non-default font size proves the user's settings loaded; the notification needs them.
+ ui.load(CHAT_PATH, font_size="l")
+ expect(page.locator("html")).to_have_attribute("data-font-size", "l")
+ expect(rail_row(page, DIGEST_TITLE).get_by_role("img", name=f"Running {DIGEST_NAME}", exact=True)).to_be_visible()
+ ui.assert_real_spa()
+
+ ui.status_rows = [digest_row(
+ "completed",
+ delivery=delivered(run=DIGEST_RUN, conversation=DIGEST_CHAT, generation=1, at=ui.next_checked_at()),
+ )]
+ page.evaluate(RECHECK)
+ wait_for(page, lambda: page.evaluate(NOTIFICATIONS), "The delivery raised no desktop notification.")
+ expect(unread_dot(page, DIGEST_TITLE)).to_have_count(1)
+ expect(running_tag(page, f"Running {DIGEST_NAME}")).to_have_count(0)
+ page.wait_for_timeout(300)
+ notifications = page.evaluate(NOTIFICATIONS)
+ assert notifications == [{
+ "title": "SimpleChat", "body": DIGEST_TITLE, "tag": f"simplechat-conversation-{DIGEST_CHAT}",
+ }]
+
+ # The real server marks the digest chat unread too. The fixture leaves the flag off, so any
+ # unread dot after the reload can only come from the client settling the delivery again.
+ ui.status_rows.append(status_row(
+ "running", conversation=BUDGET_CHAT, run=BUDGET_RUN, step="budget-step", orun="budget-orun",
+ name=BUDGET_NAME, workflow_id=WORKFLOW_ID, delivery={"status": "pending"},
+ ))
+ ui.defer_next("GET", STATUS_PATH)
+ page.reload(wait_until="commit")
+ expect(page.locator("html")).to_have_attribute("data-font-size", "l")
+ expect(rail_row(page, DIGEST_TITLE)).to_have_count(1)
+ wait_for(page, lambda: ui.pending_responses, "The tracker never read run status after the reload.")
+ held = [entry for _, entry in ui.pending_responses]
+ assert [(entry.path, entry.query) for entry in held] == [(STATUS_PATH, {})], held
+ ui.release_responses()
+ # The first read after the reload landed: it shows the other chat's run in flight.
+ expect(rail_row(page, BUDGET_TITLE).get_by_role("img", name=f"Running {BUDGET_NAME}", exact=True)).to_be_visible()
+ page.wait_for_timeout(500)
+ notifications = page.evaluate(NOTIFICATIONS)
+ assert notifications == []
+ expect(unread_dot(page, DIGEST_TITLE)).to_have_count(0)
+
+
+def test_tracker_spa_version_is_at_least_the_implementation():
+ assert_app_version_at_least(IMPLEMENTED_IN)
+
+
+if __name__ == "__main__":
+ raise SystemExit(pytest.main([__file__, "-q"]))