diff --git a/src/backend/server/routes/von_routes.py b/src/backend/server/routes/von_routes.py index 23e37c01..783617b4 100644 --- a/src/backend/server/routes/von_routes.py +++ b/src/backend/server/routes/von_routes.py @@ -565,10 +565,19 @@ def _normalise_selected_workflow_execution_event( "error", "execution_mode", "result_summary", + "effect_status", + "mutation_outcome", + "outcome_finality", + "failure_reason", + "next_action", ): value = _progress_str(event.get(key)) or _progress_str(update.get(key)) if value: normalised[key] = value + for key in ("changed", "semantic_effect"): + value = event.get(key) + if isinstance(value, bool): + normalised[key] = value state_attempt = _progress_number(event.get("state_attempt")) if state_attempt is not None: normalised["state_attempt"] = int(max(0.0, state_attempt)) @@ -3839,6 +3848,7 @@ def _build_turn_execution_diagnostics( "tool_pending_count", "tool_call_start_count", "tool_call_end_count", + "selected_workflow_execution", "timing_spans", ) latest_progress = ( diff --git a/src/backend/services/adaptive_turn_service.py b/src/backend/services/adaptive_turn_service.py index 3bc9648f..f944bb04 100644 --- a/src/backend/services/adaptive_turn_service.py +++ b/src/backend/services/adaptive_turn_service.py @@ -48,6 +48,9 @@ from src.backend.services.thinking_semantic_projection_service import ( build_semantic_operation_projection, ) +from src.backend.services.tool_evidence_projection_service import ( + project_nested_workflow_progress_evidence, +) from src.backend.services.turn_evidence_store import ( EVIDENCE_SLICE_SCHEMA_VERSION, TrustedTurnScope, @@ -3111,6 +3114,179 @@ def _emit(progress_tracker: Any, payload: Mapping[str, Any]) -> None: emit(dict(payload)) +def _bounded_workflow_progress_text( + value: Any, + *, + limit: int = 320, +) -> str | None: + if not isinstance(value, str): + return None + cleaned = " ".join(value.split()).strip() + if not cleaned: + return None + if len(cleaned) <= limit: + return cleaned + return f"{cleaned[: max(0, limit - 3)].rstrip()}..." + + +def _humanise_workflow_recovery_action(value: Any) -> str | None: + action_type = _bounded_workflow_progress_text(value, limit=120) + if not action_type: + return None + return action_type.replace("_", " ").strip().capitalize() or None + + +def _build_represented_workflow_execution_event( + *, + payload: Any, + workflow_id: str, + workflow_name: str | None, +) -> tuple[dict[str, Any], dict[str, Any] | None]: + """Project one represented execution into the existing Thinking contract. + + Workflow-authored progress facts remain the semantic authority. This bridge + only carries their bounded projection, plus typed execution and recovery + state already present in the durable workflow receipt. + """ + + receipt = dict(payload) if isinstance(payload, Mapping) else {} + workflow_execution_raw = receipt.get("workflow_execution") + workflow_execution = ( + dict(workflow_execution_raw) + if isinstance(workflow_execution_raw, Mapping) + else {} + ) + workflow_instance_raw = receipt.get("workflow_instance") + workflow_instance = ( + dict(workflow_instance_raw) + if isinstance(workflow_instance_raw, Mapping) + else {} + ) + latest_step_raw = workflow_execution.get("latest_step_result_envelope") + latest_step = ( + dict(latest_step_raw) if isinstance(latest_step_raw, Mapping) else {} + ) + diagnostics_raw = latest_step.get("diagnostics") + diagnostics = ( + dict(diagnostics_raw) if isinstance(diagnostics_raw, Mapping) else {} + ) + + progress_evidence = project_nested_workflow_progress_evidence(receipt) + progress_facts = ( + list(progress_evidence.get("facts") or []) + if isinstance(progress_evidence, Mapping) + else [] + ) + + effect_status = _bounded_workflow_progress_text( + receipt.get("effect_status"), + limit=80, + ) + final_status = ( + _bounded_workflow_progress_text(receipt.get("final_status"), limit=80) + or _bounded_workflow_progress_text( + workflow_execution.get("final_status"), limit=80 + ) + or _bounded_workflow_progress_text( + workflow_execution.get("current_status"), limit=80 + ) + or _bounded_workflow_progress_text( + workflow_instance.get("status"), limit=80 + ) + ) + effect_status_key = (effect_status or "").lower() + final_status_key = (final_status or "").lower() + if effect_status_key == "succeeded": + event_status = "workflow_execution_complete" + elif effect_status_key == "partial": + event_status = "workflow_execution_waiting" + elif effect_status_key == "indeterminate": + event_status = "workflow_execution_indeterminate" + elif effect_status_key in {"failed", "not_started"}: + event_status = "workflow_execution_failed" + elif final_status_key in {"completed", "complete", "succeeded", "success"}: + event_status = "workflow_execution_complete" + elif final_status_key in {"pending", "queued", "running", "paused"}: + event_status = "workflow_execution_waiting" + else: + event_status = "workflow_execution_failed" + + error = ( + _bounded_workflow_progress_text( + workflow_execution.get("failure_reason"), limit=320 + ) + or _bounded_workflow_progress_text( + receipt.get("failure_reason"), limit=320 + ) + or _bounded_workflow_progress_text(diagnostics.get("error"), limit=320) + or _bounded_workflow_progress_text( + workflow_execution.get("error"), limit=320 + ) + or _bounded_workflow_progress_text(receipt.get("error_code"), limit=160) + ) + recovery_affordances = receipt.get("recovery_affordances") + next_action = None + if isinstance(recovery_affordances, Sequence) and not isinstance( + recovery_affordances, + (str, bytes, bytearray), + ): + for affordance in recovery_affordances: + if not isinstance(affordance, Mapping): + continue + next_action = _humanise_workflow_recovery_action( + affordance.get("action_type") + ) + if next_action: + break + + event: dict[str, Any] = { + "schema_version": "selected_workflow_execution_event.v1", + "status": event_status, + "event_kind": event_status, + "workflow_id": workflow_id, + "selected_workflow_id": workflow_id, + "selected_execution_mode": "adaptive_turn_capability", + } + optional_text = { + "selected_workflow_name": workflow_name, + "state_id": latest_step.get("state_id") + or workflow_execution.get("current_state"), + "action_id": latest_step.get("action_id"), + "action_status": latest_step.get("action_status"), + "action_outcome": latest_step.get("action_outcome"), + "final_state": workflow_execution.get("current_state") + or workflow_instance.get("current_state"), + "error": error, + "effect_status": effect_status, + "mutation_outcome": receipt.get("mutation_outcome"), + "outcome_finality": receipt.get("outcome_finality"), + "next_action": next_action, + } + for key, value in optional_text.items(): + text_value = _bounded_workflow_progress_text(value) + if text_value: + event[key] = text_value + for key in ("changed", "semantic_effect"): + if isinstance(receipt.get(key), bool): + event[key] = receipt[key] + if isinstance(latest_step.get("state_attempt"), (int, float)) and not isinstance( + latest_step.get("state_attempt"), bool + ): + event["state_attempt"] = max(0, int(latest_step["state_attempt"])) + if isinstance(diagnostics.get("duration_ms"), (int, float)) and not isinstance( + diagnostics.get("duration_ms"), bool + ): + event["duration_ms"] = max(0, int(diagnostics["duration_ms"])) + if progress_facts: + event["progress_facts"] = progress_facts + progress_payload = ( + dict(progress_evidence) + if isinstance(progress_evidence, Mapping) + else None + ) + return event, progress_payload + + def _check_cancellation(progress_tracker: Any) -> None: if progress_tracker is None: return @@ -5113,21 +5289,31 @@ def contained( lifecycle_status="running", capability_display_name=item.capability_display_name, ) - _emit( - progress_tracker, - { - "status": "tool_call_start", - "event_kind": "tool_call_start", - "stage": "adaptive_research", - "phase": "adaptive_research", - "tool": canonical_name, - "subtask": semantic_operation["capability"]["label"], - "execution_method": execution_method_name, - "call_id": call.call_id, - "result_summary": semantic_operation["summary"], - "semantic_operation": semantic_operation, - }, - ) + start_progress: dict[str, Any] = { + "status": "tool_call_start", + "event_kind": "tool_call_start", + "stage": "adaptive_research", + "phase": "adaptive_research", + "tool": canonical_name, + "subtask": semantic_operation["capability"]["label"], + "execution_method": execution_method_name, + "call_id": call.call_id, + "result_summary": semantic_operation["summary"], + "semantic_operation": semantic_operation, + } + if item.capability_kind == "represented_workflow" and ( + item.represented_workflow_id + ): + start_progress["selected_workflow_execution_event"] = { + "schema_version": "selected_workflow_execution_event.v1", + "status": "workflow_execution_start", + "event_kind": "workflow_execution_start", + "workflow_id": item.represented_workflow_id, + "selected_workflow_id": item.represented_workflow_id, + "selected_workflow_name": item.capability_display_name, + "selected_execution_mode": "adaptive_turn_capability", + } + _emit(progress_tracker, start_progress) with override_current_actor( scope.user_concept_id, scope.organisation_concept_id, @@ -5437,6 +5623,20 @@ def contained( else "ok" ) ) + selected_workflow_execution_event: dict[str, Any] | None = None + workflow_progress_evidence: dict[str, Any] | None = None + if ( + contained_result.capability_kind == "represented_workflow" + and contained_result.represented_workflow_id + ): + ( + selected_workflow_execution_event, + workflow_progress_evidence, + ) = _build_represented_workflow_execution_event( + payload=raw_payload, + workflow_id=contained_result.represented_workflow_id, + workflow_name=contained_result.capability_display_name, + ) envelope = evidence_store.record( canonical_name, call.call_id, @@ -5471,6 +5671,10 @@ def contained( status=effect_status or status, ) envelope_payload = envelope.to_mapping() + if workflow_progress_evidence is not None: + envelope_payload["workflow_progress_evidence"] = dict( + workflow_progress_evidence + ) if isinstance(raw_payload, Mapping): try: from src.backend.services.tool_evidence_projection_service import ( @@ -5591,6 +5795,14 @@ def contained( invocation["binding_diagnostics"] = dict( contained_result.binding_diagnostics ) + if workflow_progress_evidence is not None: + invocation["workflow_progress_evidence"] = dict( + workflow_progress_evidence + ) + if selected_workflow_execution_event is not None: + invocation["selected_workflow_execution_event"] = dict( + selected_workflow_execution_event + ) if is_effect: invocation.update( { @@ -5711,22 +5923,29 @@ def contained( success=status == "ok", result=semantic_result, ) - _emit( - progress_tracker, - { - "status": "tool_completed", - "event_kind": "tool_call_end", - "stage": "adaptive_research", - "phase": "adaptive_research", - "tool": canonical_name, - "subtask": semantic_operation["capability"]["label"], - "execution_method": execution_method_name, - "call_id": call.call_id, - "success": status == "ok", - "result_summary": semantic_operation["summary"], - "semantic_operation": semantic_operation, - }, - ) + completed_progress: dict[str, Any] = { + "status": "tool_completed", + "event_kind": "tool_call_end", + "stage": "adaptive_research", + "phase": "adaptive_research", + "tool": canonical_name, + "subtask": semantic_operation["capability"]["label"], + "execution_method": execution_method_name, + "call_id": call.call_id, + "success": status == "ok", + "result_summary": semantic_operation["summary"], + "semantic_operation": semantic_operation, + } + if selected_workflow_execution_event is not None: + completed_progress["selected_workflow_execution_event"] = dict( + selected_workflow_execution_event + ) + progress_facts = selected_workflow_execution_event.get( + "progress_facts" + ) + if isinstance(progress_facts, list) and progress_facts: + completed_progress["progress_facts"] = list(progress_facts) + _emit(progress_tracker, completed_progress) correlated_results = [ result for result in batch_results if isinstance(result, ToolResult) diff --git a/src/frontend/web/von_interface/static/js/chatTab.js b/src/frontend/web/von_interface/static/js/chatTab.js index e9133e90..d76c2051 100644 --- a/src/frontend/web/von_interface/static/js/chatTab.js +++ b/src/frontend/web/von_interface/static/js/chatTab.js @@ -9941,7 +9941,15 @@ function renderThinkingWorkflowStageDiagnosticDataHTML(data, workflowDiscovery = data.latest_selected_workflow_event, workflowDiscovery ) - } + }, + { label: 'Latest state id', value: data.latest_selected_workflow_event?.state_id }, + { label: 'Latest action id', value: data.latest_selected_workflow_event?.action_id }, + { label: 'Action status', value: data.latest_selected_workflow_event?.action_status }, + { label: 'Action outcome', value: data.latest_selected_workflow_event?.action_outcome }, + { label: 'Effect status', value: data.latest_selected_workflow_event?.effect_status }, + { label: 'Mutation outcome', value: data.latest_selected_workflow_event?.mutation_outcome }, + { label: 'Outcome finality', value: data.latest_selected_workflow_event?.outcome_finality }, + { label: 'Next action', value: data.latest_selected_workflow_event?.next_action } ); sections.push(buildThinkingDiagnosticListHTML( 'Workflow execution events', @@ -10444,7 +10452,12 @@ function renderThinkingWorkflowStageExpertDataHTML(data, workflowDiscovery = nul data.latest_selected_workflow_event, workflowDiscovery ) - } + }, + { label: 'Latest state id', value: data.latest_selected_workflow_event?.state_id }, + { label: 'Latest action id', value: data.latest_selected_workflow_event?.action_id }, + { label: 'Action outcome', value: data.latest_selected_workflow_event?.action_outcome }, + { label: 'Effect status', value: data.latest_selected_workflow_event?.effect_status }, + { label: 'Next action', value: data.latest_selected_workflow_event?.next_action } ); sections.push(buildThinkingDiagnosticListHTML( 'Workflow execution events', @@ -10953,34 +10966,28 @@ function normaliseThinkingSelectedWorkflowExecution(rawExecution, workflowDiscov final_state: normaliseThinkingActivityString(entry.final_state) || null, error: normaliseThinkingActivityString(entry.error) || null, execution_mode: normaliseThinkingActivityString(entry.execution_mode) || null, + effect_status: normaliseThinkingActivityString(entry.effect_status) || null, + mutation_outcome: normaliseThinkingActivityString(entry.mutation_outcome) || null, + outcome_finality: normaliseThinkingActivityString(entry.outcome_finality) || null, + failure_reason: normaliseThinkingActivityString(entry.failure_reason) || null, + next_action: normaliseThinkingActivityString(entry.next_action) || null, + changed: typeof entry.changed === 'boolean' ? entry.changed : null, + semantic_effect: typeof entry.semantic_effect === 'boolean' ? entry.semantic_effect : null, duration_ms: Number.isFinite(entry.duration_ms) ? Number(entry.duration_ms) : null, sequence_no: Number.isFinite(entry.sequence_no) ? Number(entry.sequence_no) : null, at_utc: normaliseThinkingActivityString(entry.at_utc) || null, progress_facts: normaliseThinkingProgressFacts(entry.progress_facts), })); - let latestEvent = events.length > 0 - ? events[events.length - 1] - : ( - rawExecution.latest_event && typeof rawExecution.latest_event === 'object' - ? normaliseThinkingSelectedWorkflowExecution({ events: [rawExecution.latest_event] }, workflowDiscovery)?.latest_event - : null - ); - if ( - latestEvent - && (!Array.isArray(latestEvent.progress_facts) || latestEvent.progress_facts.length === 0) - && rawExecution.latest_event - && typeof rawExecution.latest_event === 'object' - ) { - const rawLatestProgressFacts = normaliseThinkingProgressFacts( - rawExecution.latest_event.progress_facts - ); - if (rawLatestProgressFacts.length > 0) { - latestEvent = { - ...latestEvent, - progress_facts: rawLatestProgressFacts - }; - } - } + const normalisedExplicitLatestEvent = ( + rawExecution.latest_event && typeof rawExecution.latest_event === 'object' + ? normaliseThinkingSelectedWorkflowExecution( + { events: [rawExecution.latest_event] }, + workflowDiscovery + )?.latest_event + : null + ); + const latestEvent = normalisedExplicitLatestEvent + || (events.length > 0 ? events[events.length - 1] : null); const workflowId = normaliseThinkingActivityString(rawExecution.selected_workflow_id) || normaliseThinkingActivityString(rawExecution.workflow_id) || normaliseThinkingActivityString(latestEvent?.selected_workflow_id) @@ -11017,6 +11024,26 @@ function normaliseThinkingSelectedWorkflowExecution(rawExecution, workflowDiscov }; } +function formatThinkingWorkflowStateLabel(stateId, workflowId = null) { + const cleanStateId = normaliseThinkingActivityString(stateId); + if (!cleanStateId) { + return ''; + } + + const representedStepPrefix = '#V#workflow_step_'; + if (!cleanStateId.startsWith(representedStepPrefix)) { + return formatThinkingActivityFallbackLabel(cleanStateId); + } + + let stateSlug = cleanStateId.slice(representedStepPrefix.length); + const workflowSlug = normaliseThinkingActivityString(workflowId) + .replace(/^#V#/, ''); + if (workflowSlug && stateSlug.startsWith(`${workflowSlug}_`)) { + stateSlug = stateSlug.slice(workflowSlug.length + 1); + } + return formatThinkingActivityFallbackLabel(stateSlug); +} + function formatThinkingSelectedWorkflowExecutionEvent(event, workflowDiscovery = null) { if (!event || typeof event !== 'object') { return ''; @@ -11026,24 +11053,85 @@ function formatThinkingSelectedWorkflowExecutionEvent(event, workflowDiscovery = event.selected_workflow_name, workflowDiscovery ); - const stateText = event.state_id ? formatThinkingActivityFallbackLabel(event.state_id) : ''; + const stateText = formatThinkingWorkflowStateLabel( + event.state_id, + event.selected_workflow_id || event.workflow_id + ); const actionText = event.action_id ? formatThinkingActivityFallbackLabel(event.action_id) : ''; const outcome = event.action_outcome || event.action_status || event.outcome || ''; const outcomeText = outcome ? formatThinkingActivityFallbackLabel(outcome) : ''; const durationText = formatThinkingDiagnosticDuration(event.duration_ms); + const effectText = (() => { + if (event.semantic_effect === false) { + return 'Read-only workflow'; + } + if (event.semantic_effect !== true) { + return ''; + } + switch (normaliseThinkingActivityString(event.effect_status).toLowerCase()) { + case 'succeeded': + return 'Requested effect completed'; + case 'not_started': + return 'Effect not started'; + case 'failed': + return 'Effect failed'; + case 'partial': + return 'Effect still pending'; + case 'indeterminate': + return 'Effect outcome unknown'; + default: + return ''; + } + })(); + const nextActionText = event.next_action ? `Next: ${event.next_action}` : ''; switch (event.status) { case 'workflow_execution_selected': return workflowText ? `Selected ${workflowText}` : 'Selected workflow'; case 'workflow_execution_start': return workflowText ? `Started ${workflowText}` : 'Started selected workflow'; case 'workflow_execution_complete': - return [workflowText ? `Completed ${workflowText}` : 'Completed selected workflow', event.final_state].filter(Boolean).join(' · '); + return [ + workflowText ? `Completed ${workflowText}` : 'Completed selected workflow', + stateText + ? `Last step: ${stateText}` + : (actionText ? `Last step: ${actionText}` : event.final_state), + effectText, + nextActionText + ].filter(Boolean).join(' · '); case 'workflow_execution_failed': - return [workflowText ? `Failed ${workflowText}` : 'Selected workflow failed', event.final_state || event.error].filter(Boolean).join(' · '); + return [ + workflowText ? `Failed ${workflowText}` : 'Selected workflow failed', + stateText || actionText || event.final_state, + event.failure_reason || event.error, + effectText, + nextActionText + ].filter(Boolean).join(' · '); + case 'workflow_execution_waiting': + return [ + workflowText ? `Waiting for ${workflowText}` : 'Waiting for selected workflow', + stateText || actionText, + effectText, + nextActionText + ].filter(Boolean).join(' · '); + case 'workflow_execution_indeterminate': + return [ + workflowText ? `Outcome unknown for ${workflowText}` : 'Selected workflow outcome unknown', + stateText || actionText, + event.failure_reason || event.error, + effectText, + nextActionText + ].filter(Boolean).join(' · '); case 'workflow_step_start': return [actionText ? `Running ${actionText}` : 'Running workflow step', stateText].filter(Boolean).join(' · '); case 'workflow_step_complete': - return [actionText ? `Finished ${actionText}` : 'Finished workflow step', stateText, outcomeText, durationText].filter(Boolean).join(' · '); + return [ + actionText ? `Finished ${actionText}` : 'Finished workflow step', + stateText, + outcomeText, + durationText, + effectText, + nextActionText + ].filter(Boolean).join(' · '); default: return [formatThinkingActivityFallbackLabel(event.status), actionText || stateText || workflowText, outcomeText].filter(Boolean).join(' · '); } @@ -11738,6 +11826,10 @@ function buildSelectedWorkflowExecutionStageRow(summary, workflowDiscovery = nul selected_workflow_id: workflowId || null, selected_workflow_name: normaliseThinkingActivityString(summary?.selected_workflow_name) || null, workflow_instance_id: instanceId || null, + selected_workflow_event_count: Number.isFinite(summary?.event_count) + ? Number(summary.event_count) + : (Array.isArray(summary?.events) ? summary.events.length : 0), + latest_selected_workflow_event: summary?.latest_event || null, selected_workflow_execution: summary?.events ? { ...summary } : null, custom_workflow_execution: { ...summary }, progress_facts: normaliseThinkingProgressFacts( diff --git a/src/frontend/web/von_interface/static/js/test/chatTab.test.js b/src/frontend/web/von_interface/static/js/test/chatTab.test.js index 6d645f48..271c2fac 100644 --- a/src/frontend/web/von_interface/static/js/test/chatTab.test.js +++ b/src/frontend/web/von_interface/static/js/test/chatTab.test.js @@ -6192,6 +6192,8 @@ describe('thinking activity history normalisation', () => { selected_workflow_id: '#V#research_triage_workflow', state_id: 'read_message', action_id: 'gmail_read', + effect_status: 'succeeded', + semantic_effect: false, progress_facts: [ { schema_version: 'workflow_progress_projection.v1', @@ -6253,6 +6255,7 @@ describe('thinking activity history normalisation', () => { }); const defaultText = defaultContainer.textContent || ''; expect(defaultText).toContain('Email subject: Research digest: arXiv attention paper'); + expect(defaultText).toContain('Read-only workflow'); expect(defaultText).not.toContain('Paper concept'); expect(defaultText).not.toContain('Sender email'); @@ -6277,6 +6280,129 @@ describe('thinking activity history normalisation', () => { expect(debugText).toContain('reason: redaction_policy_missing'); }); + test('renders a represented workflow failure boundary and typed next action', () => { + const html = __testOnly_renderThinkingCardBodyHTML({ + thinkingCardMode: 'default', + latestProgress: { + phase: 'adaptive_research', + stage: 'adaptive_research', + selected_workflow_execution: { + schema_version: 'selected_workflow_execution.v1', + selected_workflow_id: '#V#student_research_description_workflow', + selected_workflow_name: 'Student research description workflow', + event_count: 2, + latest_event: { + status: 'workflow_execution_failed', + event_kind: 'workflow_execution_failed', + workflow_id: '#V#student_research_description_workflow', + selected_workflow_id: '#V#student_research_description_workflow', + selected_workflow_name: 'Student research description workflow', + state_id: '#V#workflow_step_student_research_description_workflow_extract_attachment', + action_id: 'extract_pdf_text', + action_status: 'failed', + action_outcome: 'failure', + error: 'The attached PDF could not be read.', + effect_status: 'failed', + semantic_effect: true, + changed: false, + next_action: 'Inspect workflow instance', + progress_facts: [ + { + schema_version: 'workflow_progress_projection.v1', + fact_id: 'attachment_name', + label: 'Attachment', + status: 'available', + present: true, + visibility: 'default', + value: 'research-description.pdf' + } + ] + }, + events: [] + } + } + }); + + const container = document.createElement('div'); + container.innerHTML = html; + const text = container.textContent || ''; + + expect(text).toContain('Failed Student research description workflow'); + expect(text).toContain('Extract attachment'); + expect(text).toContain('The attached PDF could not be read.'); + expect(text).toContain('Effect failed'); + expect(text).toContain('Next: Inspect workflow instance'); + expect(text).toContain('Attachment: research-description.pdf'); + expect(text).not.toContain('Requested effect completed'); + }); + + test('renders a represented step concept as its workflow-local human label', () => { + const html = __testOnly_renderThinkingCardBodyHTML({ + thinkingCardMode: 'default', + latestProgress: { + status: 'completed', + selected_workflow_execution: { + schema_version: 'selected_workflow_execution.v1', + selected_workflow_id: '#V#operational_marker_absence_probe_workflow', + selected_workflow_name: 'Operational Marker Absence Probe Workflow', + event_count: 2, + latest_event: { + status: 'workflow_execution_complete', + event_kind: 'workflow_execution_complete', + workflow_id: '#V#operational_marker_absence_probe_workflow', + selected_workflow_id: '#V#operational_marker_absence_probe_workflow', + selected_workflow_name: 'Operational Marker Absence Probe Workflow', + state_id: '#V#workflow_step_operational_marker_absence_probe_workflow_project_probe_result', + action_id: 'workflow_control.context_project', + action_status: 'success', + action_outcome: 'success' + }, + events: [] + } + } + }); + + const container = document.createElement('div'); + container.innerHTML = html; + const text = container.textContent || ''; + + expect(text).toContain('Last step: Project probe result'); + expect(text).not.toContain('Last step: Workflow control.context project'); + expect(text).not.toContain('#V#workflow step'); + + for (const mode of ['expert', 'debug']) { + const technicalContainer = document.createElement('div'); + technicalContainer.innerHTML = __testOnly_renderThinkingCardBodyHTML({ + thinkingCardMode: mode, + latestProgress: { + status: 'completed', + selected_workflow_execution: { + schema_version: 'selected_workflow_execution.v1', + selected_workflow_id: '#V#operational_marker_absence_probe_workflow', + selected_workflow_name: 'Operational Marker Absence Probe Workflow', + event_count: 2, + latest_event: { + status: 'workflow_execution_complete', + event_kind: 'workflow_execution_complete', + workflow_id: '#V#operational_marker_absence_probe_workflow', + selected_workflow_id: '#V#operational_marker_absence_probe_workflow', + state_id: '#V#workflow_step_operational_marker_absence_probe_workflow_project_probe_result', + action_id: 'workflow_control.context_project', + action_status: 'success', + action_outcome: 'success' + }, + events: [] + } + } + }); + const technicalText = technicalContainer.textContent || ''; + expect(technicalText).toContain('Latest state id'); + expect(technicalText).toContain('#V#workflow_step_operational_marker_absence_probe_workflow_project_probe_result'); + expect(technicalText).toContain('Latest action id'); + expect(technicalText).toContain('workflow_control.context_project'); + } + }); + test('surfaces interpretable execution path for general tool-use turns', () => { const html = __testOnly_renderThinkingCardBodyHTML({ thinkingCardMode: 'expert', diff --git a/tests/backend/test_adaptive_turn_service.py b/tests/backend/test_adaptive_turn_service.py index d675b15e..a49b931a 100644 --- a/tests/backend/test_adaptive_turn_service.py +++ b/tests/backend/test_adaptive_turn_service.py @@ -6654,6 +6654,8 @@ def test_represented_workflow_is_discovered_and_invoked_as_bound_capability( display_name="Represented test workflow", description="Produce the represented test work product.", relevance_score=0.94, + semantic_effect=True, + semantic_effect_source="represented_workflow_declaration", input_schema={ "type": "object", "properties": { @@ -6685,6 +6687,42 @@ def _execute_workflow(**kwargs: Any) -> dict[str, Any]: "instance_id": "workflow-instance-1", "created_new": True, "final_status": "completed", + "workflow_execution": { + "final_status": "completed", + "current_state": "record_description", + "latest_step_result_envelope": { + "schema_version": "workflow_step_result_envelope.v1", + "workflow_id": "#V#represented_test_workflow", + "state_id": "record_description", + "action_id": "upsert_research_description", + "action_status": "success", + "action_outcome": "success", + "state_attempt": 1, + "diagnostics": {"duration_ms": 125}, + "progress_facts": [ + { + "schema_version": "workflow_progress_projection.v1", + "fact_id": "student_name", + "label": "Student", + "status": "available", + "present": True, + "visibility": "default", + "value": "Nathan Doe", + "source_path": "context.student_name", + }, + { + "schema_version": "workflow_progress_projection.v1", + "fact_id": "description_date", + "label": "Description date", + "status": "available", + "present": True, + "visibility": "default", + "value": "2026-08-05", + "source_path": "context.description_date", + }, + ], + }, + }, } gateway = _workflow_gateway( @@ -6803,6 +6841,38 @@ def _execute_workflow(**kwargs: Any) -> dict[str, Any]: assert invocation["instance_id"] == "workflow-instance-1" assert invocation["workflow_id"] == "#V#represented_test_workflow" assert invocation["plan_profile"]["shape"] == "represented_workflow" + assert invocation["workflow_progress_evidence"]["facts"] == [ + { + "schema_version": "workflow_progress_projection.v1", + "fact_id": "student_name", + "label": "Student", + "status": "available", + "present": True, + "redacted": False, + "truncated": False, + "source_path": "context.student_name", + "payload_source_path": ( + "workflow_execution.latest_step_result_envelope.progress_facts" + ), + "visibility": "default", + "value": "Nathan Doe", + }, + { + "schema_version": "workflow_progress_projection.v1", + "fact_id": "description_date", + "label": "Description date", + "status": "available", + "present": True, + "redacted": False, + "truncated": False, + "source_path": "context.description_date", + "payload_source_path": ( + "workflow_execution.latest_step_result_envelope.progress_facts" + ), + "visibility": "default", + "value": "2026-08-05", + }, + ] selection_trace = next( item for item in result.aux_llm_calls @@ -6839,9 +6909,98 @@ def _execute_workflow(**kwargs: Any) -> dict[str, Any]: assert semantic_operation["arguments"][0]["value"] == ( "#V#represented_test_workflow" ) + start_execution = workflow_events[0]["selected_workflow_execution_event"] + assert start_execution == { + "schema_version": "selected_workflow_execution_event.v1", + "status": "workflow_execution_start", + "event_kind": "workflow_execution_start", + "workflow_id": "#V#represented_test_workflow", + "selected_workflow_id": "#V#represented_test_workflow", + "selected_workflow_name": "Represented test workflow", + "selected_execution_mode": "adaptive_turn_capability", + } + completed_execution = workflow_events[1][ + "selected_workflow_execution_event" + ] + assert completed_execution["status"] == "workflow_execution_complete" + assert completed_execution["state_id"] == "record_description" + assert completed_execution["action_id"] == "upsert_research_description" + assert completed_execution["action_outcome"] == "success" + assert completed_execution["effect_status"] == "succeeded" + assert completed_execution["semantic_effect"] is True + assert [fact["label"] for fact in completed_execution["progress_facts"]] == [ + "Student", + "Description date", + ] + assert workflow_events[1]["progress_facts"] == completed_execution[ + "progress_facts" + ] assert result.response_text == "The represented work product was completed." +def test_represented_workflow_failure_progress_is_actionable() -> None: + from src.backend.services.adaptive_turn_service import ( + _build_represented_workflow_execution_event, + ) + + event, progress_evidence = _build_represented_workflow_execution_event( + payload={ + "final_status": "failed", + "effect_status": "failed", + "semantic_effect": True, + "changed": False, + "mutation_outcome": "partial", + "outcome_finality": "terminal_for_turn", + "recovery_affordances": [ + {"action_type": "inspect_workflow_instance"} + ], + "workflow_execution": { + "current_state": "extract_attachment", + "error": "Attachment text extraction failed.", + "latest_step_result_envelope": { + "state_id": "extract_attachment", + "action_id": "extract_pdf_text", + "action_status": "failed", + "action_outcome": "failure", + "diagnostics": { + "error": "The attached PDF could not be read.", + "duration_ms": 430, + }, + "progress_facts": [ + { + "schema_version": "workflow_progress_projection.v1", + "fact_id": "attachment_name", + "label": "Attachment", + "status": "available", + "present": True, + "visibility": "default", + "value": "research-description.pdf", + "source_path": "context.attachment_name", + } + ], + }, + }, + }, + workflow_id="#V#student_research_description_workflow", + workflow_name="Student research description workflow", + ) + + assert event["status"] == "workflow_execution_failed" + assert event["state_id"] == "extract_attachment" + assert event["action_id"] == "extract_pdf_text" + assert event["action_outcome"] == "failure" + assert event["error"] == "The attached PDF could not be read." + assert event["effect_status"] == "failed" + assert event["semantic_effect"] is True + assert event["changed"] is False + assert event["next_action"] == "Inspect workflow instance" + assert event["progress_facts"][0]["label"] == "Attachment" + assert progress_evidence is not None + assert progress_evidence["facts"][0]["value"] == ( + "research-description.pdf" + ) + + def test_represented_workflow_nonfinite_wait_is_typed_not_started_feedback( monkeypatch, ) -> None: diff --git a/tests/backend/test_thinking_llm_call_timestamps_and_precedence.py b/tests/backend/test_thinking_llm_call_timestamps_and_precedence.py index 21168fb8..8c25cc38 100644 --- a/tests/backend/test_thinking_llm_call_timestamps_and_precedence.py +++ b/tests/backend/test_thinking_llm_call_timestamps_and_precedence.py @@ -380,6 +380,42 @@ def test_build_turn_execution_record_persists_primary_and_aux_llm_logs(): # --------------------------------------------------------------------------- +def test_selected_workflow_event_preserves_effect_and_recovery_meaning(): + from src.backend.server.routes.von_routes import ( + _normalise_selected_workflow_execution_event, + ) + + event = _normalise_selected_workflow_execution_event( + { + "status": "tool_completed", + "selected_workflow_execution_event": { + "status": "workflow_execution_failed", + "event_kind": "workflow_execution_failed", + "workflow_id": "#V#student_research_description_workflow", + "selected_workflow_name": ( + "Student research description workflow" + ), + "effect_status": "failed", + "mutation_outcome": "partial", + "outcome_finality": "terminal_for_turn", + "semantic_effect": True, + "changed": False, + "next_action": "Inspect workflow instance", + }, + }, + sequence_no=7, + at_utc="2026-08-06T10:00:00Z", + ) + + assert event is not None + assert event["effect_status"] == "failed" + assert event["mutation_outcome"] == "partial" + assert event["outcome_finality"] == "terminal_for_turn" + assert event["semantic_effect"] is True + assert event["changed"] is False + assert event["next_action"] == "Inspect workflow instance" + + def test_selected_workflow_evidence_sources_collects_lineage(): from src.backend.server.routes.von_routes import _selected_workflow_evidence_sources diff --git a/tests/backend/test_tool_progress_liveness.py b/tests/backend/test_tool_progress_liveness.py index 17a2747d..c9c32136 100644 --- a/tests/backend/test_tool_progress_liveness.py +++ b/tests/backend/test_tool_progress_liveness.py @@ -1125,6 +1125,64 @@ def test_serialisation_exposes_a_bounded_timing_trace(monkeypatch) -> None: assert serialised["timing_summary"]["slowest_spans"] +def test_completed_turn_diagnostics_preserve_selected_workflow_execution() -> None: + selected_workflow_execution = { + "schema_version": "selected_workflow_execution.v1", + "workflow_id": "#V#operational_marker_absence_probe_workflow", + "selected_workflow_id": "#V#operational_marker_absence_probe_workflow", + "selected_workflow_name": "Operational Marker Absence Probe Workflow", + "event_count": 2, + "latest_event": { + "schema_version": "selected_workflow_execution_event.v1", + "sequence_no": 12, + "at_utc": "2026-08-06T15:47:00Z", + "status": "workflow_execution_complete", + "event_kind": "workflow_execution_complete", + "workflow_id": "#V#operational_marker_absence_probe_workflow", + "selected_workflow_id": "#V#operational_marker_absence_probe_workflow", + "selected_workflow_name": "Operational Marker Absence Probe Workflow", + "state_id": "project_probe_result", + "action_id": "workflow_control.context_project", + "action_status": "succeeded", + "semantic_effect": False, + "progress_facts": [ + { + "label": "Marker found", + "value": False, + "visibility": "default", + "redacted": False, + } + ], + }, + "events": [], + } + + diagnostics = von_routes._build_turn_execution_diagnostics( + request_id="req-selected-workflow-archive", + prompt_text="Check whether the marker exists without changing anything.", + tool_progress_state={ + "request_id": "req-selected-workflow-archive", + "status": "completed", + "selected_workflow_execution": selected_workflow_execution, + }, + ) + + archived_execution = diagnostics["latest_progress"][ + "selected_workflow_execution" + ] + assert archived_execution == selected_workflow_execution + assert archived_execution["latest_event"]["state_id"] == "project_probe_result" + assert archived_execution["latest_event"]["semantic_effect"] is False + assert archived_execution["latest_event"]["progress_facts"] == [ + { + "label": "Marker found", + "value": False, + "visibility": "default", + "redacted": False, + } + ] + + def test_diagnostics_include_neutral_model_tool_and_support_timing() -> None: diagnostics = von_routes._build_turn_execution_diagnostics( request_id="req-diagnostics-timing",