diff --git a/api/controllers/console/dify_builder.py b/api/controllers/console/dify_builder.py index debad8381c0..1497c40e29d 100644 --- a/api/controllers/console/dify_builder.py +++ b/api/controllers/console/dify_builder.py @@ -262,7 +262,7 @@ def _stream(session_id: str, actor: Actor) -> Response | tuple[dict, int]: stream_advance_frames( session_view_to_dict(view), subscription, - expect_advance=view.run_status in {"thinking", "executing"} and not view.interrupted, + expect_advance=view.run_status == "processing" and not view.interrupted, ), mimetype="text/event-stream", headers=_SSE_HEADERS, diff --git a/api/controllers/console/dify_builder_fields.py b/api/controllers/console/dify_builder_fields.py index f774f0b6c87..e3016f48929 100644 --- a/api/controllers/console/dify_builder_fields.py +++ b/api/controllers/console/dify_builder_fields.py @@ -27,6 +27,7 @@ from core.dify_builder.contract import ( PreflightContextCard, ProgressEventData, PublishCard, + ReasoningEventData, RecoveryRef, ResourceSelectCard, RunContextCard, @@ -305,6 +306,11 @@ class DifyBuilderAgentMessageEventResponse(ResponseModel): data: AgentMessageEventData +class DifyBuilderReasoningEventResponse(ResponseModel): + event: Literal["reasoning"] + data: ReasoningEventData + + class DifyBuilderProgressEventResponse(ResponseModel): event: Literal["progress"] data: ProgressEventData @@ -332,6 +338,7 @@ class DifyBuilderStreamEventResponse( | DifyBuilderNodeEventResponse | DifyBuilderCanvasEventResponse | DifyBuilderAgentMessageEventResponse + | DifyBuilderReasoningEventResponse | DifyBuilderProgressEventResponse | DifyBuilderCommitEventResponse | DifyBuilderStateEventResponse diff --git a/api/core/dify_builder/contract.py b/api/core/dify_builder/contract.py index c71e3eff328..931333c360f 100644 --- a/api/core/dify_builder/contract.py +++ b/api/core/dify_builder/contract.py @@ -35,13 +35,12 @@ class Phase(StrEnum): class RunStatus(StrEnum): """Widened run-status vocabulary (spec §2). - ``thinking``/``executing`` => ``canvas_read_only = true`` (WORKING). + ``processing`` => ``canvas_read_only = true`` (WORKING). ``waiting_*``/``paused`` => editable (WAITING). ``complete``/``failed`` => terminal. """ - THINKING = "thinking" - EXECUTING = "executing" + PROCESSING = "processing" WAITING_INPUT = "waiting_input" WAITING_CONFIRMATION = "waiting_confirmation" PAUSED = "paused" @@ -349,30 +348,32 @@ class PreflightIssue: kind: str -@dataclass -class TraceStep: - """One step of an ``assistant_turn``'s trace (spec §4.2). +ExecutionActivityState = Literal["active", "done", "failed", "stopped"] +ExecutionActivityKind = Literal["stage", "node"] +ExecutionProgressStatus = Literal["running", "completed", "error", "stopped"] - ``state`` in pending|active|done|stopped; ``tone`` in - neutral|success|error; ``canvas_event`` fires as the step activates. + +@dataclass +class ExecutionActivity: + """One observable action that has started during a Builder operation. + + Planned future work is intentionally absent. Node activities use + ``parent_id`` to sit under the stage that owns the workflow run. """ id: str label: str - state: str - tone: str = "neutral" - canvas_event: str | None = None + state: ExecutionActivityState + kind: ExecutionActivityKind = "stage" + parent_id: str | None = None @dataclass -class Trace: - """The streamable trace nested in an ``assistant_turn`` (spec §4.2). +class ExecutionProgress: + """A snapshot containing only execution activities observed so far.""" - ``status`` in running|completed|error|stopped. - """ - - status: str - steps: list[TraceStep] = field(default_factory=list) + status: ExecutionProgressStatus + activities: list[ExecutionActivity] = field(default_factory=list) @dataclass @@ -476,7 +477,8 @@ class AssistantTurnItem(_Card): turn_id: str stage_id: str - trace: Trace + execution: ExecutionProgress + reasoning_text: str | None = None reply_text: str | None = None cards: list[str] = field(default_factory=list) card_state: str | None = None @@ -785,15 +787,36 @@ class AgentMessageEventData(_SseEventData): kind: Literal["agent_message"] = "agent_message" +@dataclass +class ReasoningEventData(_SseEventData): + """One model-provided reasoning delta for the current operation. + + Reasoning is independent from curated execution progress. ``span_id`` + identifies the cognitive call within an operation so clients can preserve + stable rendering while deltas arrive. + """ + + sse_event: ClassVar[str] = "reasoning" + + session_id: str + operation_id: str + stage_id: str + at_version: int + revision: int + span_id: str + delta: str + kind: Literal["reasoning"] = "reasoning" + + @dataclass class ProgressEventData(_SseEventData): - """A replaceable, non-durable trace snapshot for an in-flight step. + """A replaceable execution snapshot for an in-flight operation. ``revision`` is monotonic within ``operation_id``. ``at_version`` points at the next durable transition that supersedes this snapshot, allowing a client to discard delayed progress after it has already applied a commit. - The payload contains curated phase labels only; model chain-of-thought and - raw prompts are never part of this contract. + The payload contains observable actions only. Model-provided reasoning is + delivered separately through ``ReasoningEventData``. """ sse_event: ClassVar[str] = "progress" @@ -803,7 +826,7 @@ class ProgressEventData(_SseEventData): stage_id: str at_version: int revision: int - trace: Trace + execution: ExecutionProgress kind: Literal["progress"] = "progress" diff --git a/api/core/dify_builder/handlers_build.py b/api/core/dify_builder/handlers_build.py index f65b65a2ee6..071479a0e4c 100644 --- a/api/core/dify_builder/handlers_build.py +++ b/api/core/dify_builder/handlers_build.py @@ -21,6 +21,7 @@ from core.dify_builder.contract import ( ConflictPolicyOption, DecisionItem, ErrorCard, + ExecutionProgress, FormCard, NoticeItem, PlanCard, @@ -30,7 +31,6 @@ from core.dify_builder.contract import ( SummaryRow, TestResultCard, TestStat, - Trace, ) from core.dify_builder.handlers_fix import ( action_kind, @@ -51,6 +51,7 @@ from core.dify_builder.models import ( ConversationItem, DifyBuilderContext, MutationIntent, + NodeEvent, Run, Session, TestInput, @@ -135,13 +136,13 @@ def handle_capability_check(env: Env, turn: Turn, s: Session, fc: DifyBuilderCon tone="warning", ), ) - trace = progress.finish() + execution = progress.finish() turn_items = append_card( fc, AssistantTurnItem( turn_id=progress.operation_id, stage_id=str(s.current_state), - trace=trace, + execution=execution, reply_text="Let's clarify the requirements.", cards=["form", "challenge"], ), @@ -188,13 +189,13 @@ def handle_goal_analysis(env: Env, turn: Turn, s: Session, fc: DifyBuilderContex items=list(fc.plan_items), ), ) - trace = progress.finish() + execution = progress.finish() turn_items = append_card( fc, AssistantTurnItem( turn_id=progress.operation_id, stage_id=str(s.current_state), - trace=trace, + execution=execution, reply_text="Here's the initial plan.", cards=["plan"], ), @@ -236,13 +237,13 @@ def handle_initial_plan(env: Env, turn: Turn, s: Session, fc: DifyBuilderContext ], ), ) - trace = progress.finish() + execution = progress.finish() turn_items = append_card( fc, AssistantTurnItem( turn_id=progress.operation_id, stage_id=str(s.current_state), - trace=trace, + execution=execution, reply_text="Recommended resources.", cards=["resource_select"], ), @@ -298,13 +299,13 @@ def handle_resource_recommendation(env: Env, turn: Turn, s: Session, fc: DifyBui checkpoint_items = append_card( fc, CheckpointCard(checkpoint_id=checkpoint_id, label="Pre-build checkpoint", created_at="") ) - trace = progress.finish() + execution = progress.finish() turn_items = append_card( fc, AssistantTurnItem( turn_id=progress.operation_id, stage_id=str(s.current_state), - trace=trace, + execution=execution, reply_text="Plan v2 ready for approval.", cards=["plan", "checkpoint"], ), @@ -320,7 +321,7 @@ def handle_plan_approval(env: Env, turn: Turn, s: Session, fc: DifyBuilderContex """(waiting) THE BUILD. Only ``approve_repair`` (resolved from approve_plan) builds: drive apply_repair once with all create_node/connect intents (node-by-node canvas reveal via env.emit_canvas), emit the change_set + - plan v2.x + assistant_turn(with Trace.steps), transition to build.execution. + plan v2.x + assistant_turn(with execution activities), transition to build.execution. Idempotent by construction (final-review fix, Important #1): a loop-back from build.review/build.reverted (continue_adjusting/revert/retry_after_ @@ -379,13 +380,13 @@ def handle_plan_approval(env: Env, turn: Turn, s: Session, fc: DifyBuilderContex ), ) progress.fail_step("build-generate-graph") - trace = progress.finish(status="error") + execution = progress.finish(status="error") turn_items = append_card( fc, AssistantTurnItem( turn_id=progress.operation_id, stage_id=str(s.current_state), - trace=trace, + execution=execution, reply_text=( "I couldn't build a valid workflow graph -- see the error above. Adjust the plan and approve again." ), @@ -451,13 +452,13 @@ def handle_plan_approval(env: Env, turn: Turn, s: Session, fc: DifyBuilderContex ) plan_items = append_card(fc, PlanCard(title="Build plan", version_tag="v2.1", items=list(fc.plan_items))) decision_items = append_card(fc, DecisionItem(text="Approved the plan")) - trace = progress.finish() + execution = progress.finish() turn_items = append_card( fc, AssistantTurnItem( turn_id=progress.operation_id, stage_id=str(s.current_state), - trace=trace, + execution=execution, reply_text="Workflow built on the canvas.", cards=["change_set", "plan"], ), @@ -497,7 +498,7 @@ def handle_execution(env: Env, turn: Turn, s: Session, fc: DifyBuilderContext) - AssistantTurnItem( turn_id=str(uuid.uuid4()), stage_id=str(s.current_state), - trace=Trace(status="completed", steps=[]), + execution=ExecutionProgress(status="completed"), reply_text="Provide test inputs (or use mock data) to run the test.", cards=["form"], ), @@ -562,8 +563,13 @@ def handle_test_and_repair(env: Env, turn: Turn, s: Session, fc: DifyBuilderCont env.repo.save_test_input(ti) fc.test_input_ref = ti.id - emit = env.emit if env.emit is not None else (lambda _e: None) progress.activate("build-run-test") + + def emit(event: NodeEvent) -> None: + progress.observe_node("build-run-test", event) + if env.emit is not None: + env.emit(event) + try: raw = env.dify.run_draft(s.app_id, turn.actor, inputs, emit) status, per_node, dify_run_id = raw.status, raw.per_node, raw.dify_run_id @@ -607,13 +613,13 @@ def handle_test_and_repair(env: Env, turn: Turn, s: Session, fc: DifyBuilderCont items=[f"Workflow built ({len(fc.built_node_ids)} nodes)", "Tests passing"], ), ) - trace = progress.finish() + execution = progress.finish() turn_items = append_card( fc, AssistantTurnItem( turn_id=progress.operation_id, stage_id=str(s.current_state), - trace=trace, + execution=execution, reply_text="Tests passed; ready for review.", cards=["test_result", "summary"], ), @@ -657,13 +663,13 @@ def handle_test_and_repair(env: Env, turn: Turn, s: Session, fc: DifyBuilderCont frozen=False, ), ) - trace = progress.finish() + execution = progress.finish() turn_items = append_card( fc, AssistantTurnItem( turn_id=progress.operation_id, stage_id=str(s.current_state), - trace=trace, + execution=execution, reply_text="The run failed on its inputs — provide test data and retry.", cards=["test_result", "form"], ), @@ -717,13 +723,13 @@ def handle_test_and_repair(env: Env, turn: Turn, s: Session, fc: DifyBuilderCont if intents else [] ) - trace = progress.finish() + execution = progress.finish() turn_items = append_card( fc, AssistantTurnItem( turn_id=progress.operation_id, stage_id=str(s.current_state), - trace=trace, + execution=execution, reply_text=( "Test failed — here's a proposed fix to review." if intents @@ -811,13 +817,13 @@ def handle_review(env: Env, turn: Turn, s: Session, fc: DifyBuilderContext) -> S fc.verify_run_id = "" decision_items = append_card(fc, DecisionItem(text="Continue adjusting")) plan_items = append_card(fc, PlanCard(title="Build plan", version_tag="v1", items=list(fc.plan_items))) - trace = progress.finish() + execution = progress.finish() turn_items = append_card( fc, AssistantTurnItem( turn_id=progress.operation_id, stage_id=str(s.current_state), - trace=trace, + execution=execution, reply_text="Revised plan.", cards=["plan"], ), @@ -929,13 +935,13 @@ def handle_reverted(env: Env, turn: Turn, s: Session, fc: DifyBuilderContext) -> fc.test_input_ref = "" fc.verify_run_id = "" plan_items = append_card(fc, PlanCard(title="Build plan", version_tag="v1", items=list(fc.plan_items))) - trace = progress.finish() + execution = progress.finish() turn_items = append_card( fc, AssistantTurnItem( turn_id=progress.operation_id, stage_id=str(s.current_state), - trace=trace, + execution=execution, reply_text="Restarting the plan.", cards=["plan"], ), diff --git a/api/core/dify_builder/handlers_edit.py b/api/core/dify_builder/handlers_edit.py index dc03da09a7b..4c6849ee1ac 100644 --- a/api/core/dify_builder/handlers_edit.py +++ b/api/core/dify_builder/handlers_edit.py @@ -17,6 +17,7 @@ from core.dify_builder.contract import ( CheckpointCard, DecisionItem, ErrorCard, + ExecutionProgress, FormCard, PlanCard, PublishCard, @@ -24,8 +25,6 @@ from core.dify_builder.contract import ( SummaryRow, TestResultCard, TestStat, - Trace, - TraceStep, ) from core.dify_builder.handlers_fix import ( action_kind, @@ -42,7 +41,7 @@ from core.dify_builder.handlers_fix import ( start_schema, testdata_form_fields, ) -from core.dify_builder.models import DifyBuilderContext, Run, Session, TestInput, Turn +from core.dify_builder.models import DifyBuilderContext, NodeEvent, Run, Session, TestInput, Turn from core.dify_builder.progress import ProgressReporter from core.dify_builder.runner import Env, Handler, StepResult from core.dify_builder.state import PcState @@ -135,13 +134,13 @@ def handle_capability_check(env: Env, turn: Turn, s: Session, fc: DifyBuilderCon full_diff_open=False, ), ) - trace = progress.finish() + execution = progress.finish() turn_items = append_card( fc, AssistantTurnItem( turn_id=progress.operation_id, stage_id=str(s.current_state), - trace=trace, + execution=execution, reply_text="Here's the impact of your change.", cards=["summary", "form", "challenge", "change_set"], ), @@ -193,13 +192,13 @@ def handle_impact_analysis(env: Env, turn: Turn, s: Session, fc: DifyBuilderCont checkpoint_items = append_card( fc, CheckpointCard(checkpoint_id=checkpoint_id, label="Pre-edit checkpoint", created_at="") ) - trace = progress.finish() + execution = progress.finish() turn_items = append_card( fc, AssistantTurnItem( turn_id=progress.operation_id, stage_id=str(s.current_state), - trace=trace, + execution=execution, reply_text="Change plan ready for approval.", cards=["plan", "checkpoint"], ), @@ -211,27 +210,10 @@ def handle_impact_analysis(env: Env, turn: Turn, s: Session, fc: DifyBuilderCont ) -_EDIT_TRACE_STEPS = [ - TraceStep( - id="edit-prepare", - label="Prepare canvas changes", - state="pending", - tone="neutral", - ), - TraceStep( - id="edit-highlight", - label="Highlight edit targets", - state="pending", - tone="neutral", - canvas_event="highlight_edit_target", - ), - TraceStep( - id="edit-apply", - label="Apply the change plan", - state="pending", - tone="success", - canvas_event="apply_edit_plan", - ), +_EDIT_EXECUTION_STEPS = [ + ("edit-prepare", "Prepare canvas changes"), + ("edit-highlight", "Highlight edit targets"), + ("edit-apply", "Apply the change plan"), ] @@ -250,13 +232,12 @@ def handle_plan_approval(env: Env, turn: Turn, s: Session, fc: DifyBuilderContex if kind != "approve_repair": return StepResult(next=PcState.EDIT_PLAN_APPROVAL, context=fc) - progress = ProgressReporter( + progress = ProgressReporter.for_session( emit=env.emit_progress, operation_id=env.operation_id, - session_id=s.id, + session=s, stage_id=str(s.current_state), - at_version=s.version + 1, - steps=_EDIT_TRACE_STEPS, + steps=_EDIT_EXECUTION_STEPS, ) progress.activate("edit-prepare") emit_canvas(env, "create_checkpoint") @@ -283,13 +264,13 @@ def handle_plan_approval(env: Env, turn: Turn, s: Session, fc: DifyBuilderContex fc, CheckpointCard(checkpoint_id=fc.checkpoint_id, label="Pre-edit checkpoint", created_at="") ) decision_items = append_card(fc, DecisionItem(text="Approved the change plan")) - trace = progress.finish() + execution = progress.finish() turn_items = append_card( fc, AssistantTurnItem( turn_id=progress.operation_id, stage_id=str(s.current_state), - trace=trace, + execution=execution, reply_text="Applied the changes to the canvas.", cards=["change_set", "checkpoint"], ), @@ -329,7 +310,7 @@ def handle_apply_changes(env: Env, turn: Turn, s: Session, fc: DifyBuilderContex AssistantTurnItem( turn_id=str(uuid.uuid4()), stage_id=str(s.current_state), - trace=Trace(status="completed", steps=[]), + execution=ExecutionProgress(status="completed"), reply_text="Provide test inputs (or use mock data) to run the affected-path test.", cards=["form"], ), @@ -393,8 +374,13 @@ def handle_test_affected_paths(env: Env, turn: Turn, s: Session, fc: DifyBuilder env.repo.save_test_input(ti) fc.test_input_ref = ti.id - emit = env.emit if env.emit is not None else (lambda _e: None) progress.activate("edit-run-test") + + def emit(event: NodeEvent) -> None: + progress.observe_node("edit-run-test", event) + if env.emit is not None: + env.emit(event) + try: raw = env.dify.run_draft(s.app_id, turn.actor, inputs, emit) status, per_node, dify_run_id = raw.status, raw.per_node, raw.dify_run_id @@ -438,13 +424,13 @@ def handle_test_affected_paths(env: Env, turn: Turn, s: Session, fc: DifyBuilder items=["Applied the change plan", "Affected paths tested", "Tests passing"], ), ) - trace = progress.finish() + execution = progress.finish() turn_items = append_card( fc, AssistantTurnItem( turn_id=progress.operation_id, stage_id=str(s.current_state), - trace=trace, + execution=execution, reply_text="Tests passed; ready for review.", cards=["test_result", "summary"], ), @@ -488,13 +474,13 @@ def handle_test_affected_paths(env: Env, turn: Turn, s: Session, fc: DifyBuilder frozen=False, ), ) - trace = progress.finish() + execution = progress.finish() turn_items = append_card( fc, AssistantTurnItem( turn_id=progress.operation_id, stage_id=str(s.current_state), - trace=trace, + execution=execution, reply_text="The run failed on its inputs — provide test data and retry.", cards=["test_result", "form"], ), @@ -548,13 +534,13 @@ def handle_test_affected_paths(env: Env, turn: Turn, s: Session, fc: DifyBuilder if intents else [] ) - trace = progress.finish() + execution = progress.finish() turn_items = append_card( fc, AssistantTurnItem( turn_id=progress.operation_id, stage_id=str(s.current_state), - trace=trace, + execution=execution, reply_text=( "Test failed — here's a proposed fix to review." if intents @@ -670,7 +656,7 @@ def handle_review(env: Env, turn: Turn, s: Session, fc: DifyBuilderContext) -> S AssistantTurnItem( turn_id=str(uuid.uuid4()), stage_id=str(s.current_state), - trace=Trace(status="completed", steps=[]), + execution=ExecutionProgress(status="completed"), reply_text="Let's adjust the change.", cards=["form", "challenge", "change_set"], ), @@ -738,13 +724,13 @@ def handle_reverted(env: Env, turn: Turn, s: Session, fc: DifyBuilderContext) -> checkpoint_items = append_card( fc, CheckpointCard(checkpoint_id=checkpoint_id, label="Pre-edit checkpoint", created_at="") ) - trace = progress.finish() + execution = progress.finish() turn_items = append_card( fc, AssistantTurnItem( turn_id=progress.operation_id, stage_id=str(s.current_state), - trace=trace, + execution=execution, reply_text="Re-approve to apply the change.", cards=["plan", "checkpoint"], ), diff --git a/api/core/dify_builder/handlers_fix.py b/api/core/dify_builder/handlers_fix.py index 04d48dac1f7..2d2db8dde41 100644 --- a/api/core/dify_builder/handlers_fix.py +++ b/api/core/dify_builder/handlers_fix.py @@ -32,12 +32,12 @@ from core.dify_builder.contract import ( AssistantTurnItem, ChangeSetCard, DecisionItem, + ExecutionProgress, FormCard, FormField, NoticeItem, SummaryCard, TestResultCard, - Trace, ) from core.dify_builder.models import ( ApplyResult, @@ -47,6 +47,7 @@ from core.dify_builder.models import ( ConversationItem, DifyBuilderContext, Graph, + NodeEvent, NodeOutput, Run, Session, @@ -414,13 +415,13 @@ def handle_propose(env: Env, turn: Turn, s: Session, fc: DifyBuilderContext) -> if intents else "No automatic fix found — review the diagnosis and edit the canvas manually, or reject." ) - trace = progress.finish() + execution = progress.finish() items = append_card( fc, AssistantTurnItem( turn_id=progress.operation_id, stage_id=str(s.current_state), - trace=trace, + execution=execution, reply_text=reply_text, cards=[], ), @@ -504,7 +505,7 @@ def handle_await_verify(env: Env, turn: Turn, s: Session, fc: DifyBuilderContext AssistantTurnItem( turn_id=str(uuid.uuid4()), stage_id=str(s.current_state), - trace=Trace(status="completed", steps=[]), + execution=ExecutionProgress(status="completed"), reply_text="Provide test inputs (or use mock data) to run validation.", cards=["form"], ), @@ -568,8 +569,13 @@ def handle_verify(env: Env, turn: Turn, s: Session, fc: DifyBuilderContext) -> S ti = env.repo.get_test_input(fc.test_input_ref) inputs = ti.inputs - emit = env.emit if env.emit is not None else (lambda _event: None) progress.activate("fix-run-validation") + + def emit(event: NodeEvent) -> None: + progress.observe_node("fix-run-validation", event) + if env.emit is not None: + env.emit(event) + result = env.dify.run_draft(s.app_id, turn.actor, inputs, emit) if result.status == "succeeded": progress.complete("fix-run-validation") diff --git a/api/core/dify_builder/ports.py b/api/core/dify_builder/ports.py index 920f2e05386..02940cc3039 100644 --- a/api/core/dify_builder/ports.py +++ b/api/core/dify_builder/ports.py @@ -54,6 +54,7 @@ __all__ = [ "DifyPort", "MutationIntent", "NodeEvent", + "ReasoningStreamingAgent", "Repository", "Risk", ] @@ -110,6 +111,13 @@ class DifyBuilderAgent(Protocol): ) -> str: ... +@runtime_checkable +class ReasoningStreamingAgent(Protocol): + """Optional capability for agents that expose model-provided reasoning.""" + + def set_reasoning_callback(self, callback: Callable[[str, str], None] | None) -> None: ... + + @runtime_checkable class DifyPort(Protocol): """The outbound port to Dify. diff --git a/api/core/dify_builder/progress.py b/api/core/dify_builder/progress.py index 0ee71b5d9d9..ecd6115c8cf 100644 --- a/api/core/dify_builder/progress.py +++ b/api/core/dify_builder/progress.py @@ -1,23 +1,35 @@ -"""Curated, replaceable progress traces for long-running Builder handlers.""" +"""Curated, replaceable execution progress for long-running Builder handlers.""" import uuid from collections.abc import Callable, Iterable -from dataclasses import replace +from dataclasses import dataclass from types import TracebackType from typing import Literal -from core.dify_builder.contract import ProgressEventData, Trace, TraceStep -from core.dify_builder.models import Session +from core.dify_builder.contract import ExecutionActivity, ExecutionProgress, ProgressEventData +from core.dify_builder.models import NodeEvent, Session __all__ = ["ProgressReporter"] +_InternalActivityState = Literal["pending", "active", "done", "failed", "stopped"] + + +@dataclass +class _ActivityRecord: + id: str + label: str + state: _InternalActivityState = "pending" + kind: Literal["stage", "node"] = "stage" + parent_id: str | None = None + class ProgressReporter: - """Publish full trace snapshots without mutating durable session state. + """Publish snapshots of actions that have actually started. - Full snapshots make reconnect, duplicate delivery, and out-of-order event - handling deterministic on the client. Step labels are authored by the - handler and must describe observable work, never hidden model reasoning. + Handlers may declare their possible stages up front, but pending stages + stay private. A stage enters the public snapshot only when activate is + called. Workflow node events are folded into the same authoritative stream + as child activities, so clients never have to merge two timelines. """ def __init__( @@ -27,7 +39,7 @@ class ProgressReporter: session_id: str, stage_id: str, at_version: int, - steps: Iterable[TraceStep], + activities: Iterable[ExecutionActivity], operation_id: str | None = None, ) -> None: self._emit = emit @@ -36,11 +48,19 @@ class ProgressReporter: self._at_version = at_version self._operation_id = operation_id or str(uuid.uuid4()) self._revision = 0 - self._status = "running" + self._status: Literal["running", "completed", "error", "stopped"] = "running" self._finished = False - self._steps = [replace(step, state="pending") for step in steps] - if len({step.id for step in self._steps}) != len(self._steps): - raise ValueError("dify_builder: progress step ids must be unique") + self._activities = [ + _ActivityRecord( + id=activity.id, + label=activity.label, + kind=activity.kind, + parent_id=activity.parent_id, + ) + for activity in activities + ] + if len({activity.id for activity in self._activities}) != len(self._activities): + raise ValueError("dify_builder: execution activity ids must be unique") @classmethod def for_session( @@ -57,7 +77,7 @@ class ProgressReporter: session_id=session.id, stage_id=stage_id, at_version=session.version + 1, - steps=[TraceStep(id=step_id, label=label, state="pending") for step_id, label in steps], + activities=[ExecutionActivity(id=activity_id, label=label, state="active") for activity_id, label in steps], operation_id=operation_id, ) @@ -78,79 +98,107 @@ class ProgressReporter: self.finish(status="error" if exc_type is not None else "completed") return False - def activate(self, step_id: str) -> None: - """Complete the prior active step and activate ``step_id``.""" - found = False - updated: list[TraceStep] = [] - for step in self._steps: - if step.id == step_id: - found = True - updated.append(replace(step, state="active")) - elif step.state == "active": - updated.append(replace(step, state="done")) - else: - updated.append(step) - if not found: - raise ValueError(f"dify_builder: unknown progress step {step_id}") - self._steps = updated + def activate(self, activity_id: str) -> None: + """Complete prior active work and reveal the next stage.""" + activity = self._find(activity_id) + for current in self._activities: + if current.state == "active": + current.state = "done" + activity.state = "active" self._status = "running" self._publish() - def complete(self, step_id: str) -> None: - """Mark one step complete while leaving the operation running.""" - self._replace_step(step_id, state="done") + def complete(self, activity_id: str) -> None: + """Mark one revealed activity complete while the operation continues.""" + self._find(activity_id).state = "done" self._publish() def add_steps(self, steps: Iterable[tuple[str, str]]) -> None: - """Append branch-specific planned work to the next full snapshot.""" - additions = [TraceStep(id=step_id, label=label, state="pending") for step_id, label in steps] - known_ids = {step.id for step in self._steps} - if any(step.id in known_ids for step in additions) or len({step.id for step in additions}) != len(additions): - raise ValueError("dify_builder: progress step ids must be unique") - self._steps.extend(additions) + """Declare branch-specific stages without revealing them yet.""" + additions = [_ActivityRecord(id=activity_id, label=label) for activity_id, label in steps] + known_ids = {activity.id for activity in self._activities} + addition_ids = {activity.id for activity in additions} + if len(addition_ids) != len(additions) or addition_ids & known_ids: + raise ValueError("dify_builder: execution activity ids must be unique") + self._activities.extend(additions) - def fail_step(self, step_id: str) -> None: + def fail_step(self, activity_id: str) -> None: """Mark one observable activity as failed before recovery continues.""" - self._replace_step(step_id, state="stopped", tone="error") + self._find(activity_id).state = "failed" self._publish() - def finish(self, *, status: Literal["completed", "error", "stopped"] = "completed") -> Trace: + def observe_node(self, parent_id: str, event: NodeEvent) -> None: + """Fold a workflow node event into the canonical execution timeline.""" + activity_id = f"node:{event.node_id}" + activity = next((item for item in self._activities if item.id == activity_id), None) + if activity is None: + activity = _ActivityRecord( + id=activity_id, + label=event.title or event.node_id, + state="active", + kind="node", + parent_id=parent_id, + ) + self._activities.append(activity) + else: + activity.label = event.title or event.node_id + activity.parent_id = parent_id + + if event.status in {"success", "succeeded"}: + activity.state = "done" + elif event.status in {"error", "exception", "failed"}: + activity.state = "failed" + elif event.status == "running": + activity.state = "active" + else: + activity.state = "stopped" + self._publish() + + def finish( + self, + *, + status: Literal["completed", "error", "stopped"] = "completed", + ) -> ExecutionProgress: """Publish and return the final snapshot for durable assistant output.""" if self._finished: return self._snapshot() self._status = status - if status == "completed": - self._steps = [replace(step, state="done") if step.state == "active" else step for step in self._steps] - else: - self._steps = [ - replace(step, state="stopped", tone="error") if step.state == "active" else step for step in self._steps - ] - trace = self._snapshot() + for activity in self._activities: + if activity.state != "active": + continue + activity.state = "done" if status == "completed" else "failed" if status == "error" else "stopped" + execution = self._snapshot() self._finished = True - self._publish(trace) - return trace + self._publish(execution) + return execution - def _replace_step(self, step_id: str, *, state: str, tone: str | None = None) -> None: - found = False - updated: list[TraceStep] = [] - for step in self._steps: - if step.id == step_id: - found = True - updated.append(replace(step, state=state) if tone is None else replace(step, state=state, tone=tone)) - else: - updated.append(step) - if not found: - raise ValueError(f"dify_builder: unknown progress step {step_id}") - self._steps = updated + def _find(self, activity_id: str) -> _ActivityRecord: + activity = next((item for item in self._activities if item.id == activity_id), None) + if activity is None: + raise ValueError(f"dify_builder: unknown execution activity {activity_id}") + return activity - def _snapshot(self) -> Trace: - return Trace(status=self._status, steps=[replace(step) for step in self._steps]) + def _snapshot(self) -> ExecutionProgress: + return ExecutionProgress( + status=self._status, + activities=[ + ExecutionActivity( + id=activity.id, + label=activity.label, + state=activity.state, + kind=activity.kind, + parent_id=activity.parent_id, + ) + for activity in self._activities + if activity.state != "pending" + ], + ) - def _publish(self, trace: Trace | None = None) -> None: + def _publish(self, execution: ExecutionProgress | None = None) -> None: self._revision += 1 if self._emit is None: return - snapshot = trace or self._snapshot() + snapshot = execution or self._snapshot() self._emit( ProgressEventData( session_id=self._session_id, @@ -158,6 +206,18 @@ class ProgressReporter: stage_id=self._stage_id, at_version=self._at_version, revision=self._revision, - trace=Trace(status=snapshot.status, steps=[replace(step) for step in snapshot.steps]), + execution=ExecutionProgress( + status=snapshot.status, + activities=[ + ExecutionActivity( + id=activity.id, + label=activity.label, + state=activity.state, + kind=activity.kind, + parent_id=activity.parent_id, + ) + for activity in snapshot.activities + ], + ), ) ) diff --git a/api/core/dify_builder/runner.py b/api/core/dify_builder/runner.py index 4b16c57d5c1..ec81643f977 100644 --- a/api/core/dify_builder/runner.py +++ b/api/core/dify_builder/runner.py @@ -29,8 +29,9 @@ from core.dify_builder.contract import ( AgentMessageEventData, AssistantTurnItem, ErrorCard, + ExecutionProgress, ProgressEventData, - Trace, + ReasoningEventData, UserItem, ) from core.dify_builder.errors import ConflictError @@ -44,12 +45,13 @@ from core.dify_builder.models import ( Snapshot, Turn, ) -from core.dify_builder.ports import DifyBuilderAgent, DifyPort, Repository +from core.dify_builder.ports import DifyBuilderAgent, DifyPort, ReasoningStreamingAgent, Repository from core.dify_builder.state import PcState, is_terminal, is_waiting __all__ = ["CommittedTransition", "Env", "Handler", "Runner", "StepResult"] _MESSAGE_HISTORY_LIMIT = 24 +_MAX_REASONING_CHARS = 64_000 @dataclass(frozen=True) @@ -97,27 +99,58 @@ class Env: # is committed. The accumulated reply becomes authoritative in the # CAS-backed commit below. emit_message: Callable[[AgentMessageEventData], None] | None = None - # Called for low-frequency, curated trace snapshots while a handler is + # Called for low-frequency, curated execution snapshots while a handler is # doing structured cognition or external work. These events are transient; # the next CAS-backed commit remains authoritative. emit_progress: Callable[[ProgressEventData], None] | None = None + # Called for model-provided reasoning deltas. Reasoning is accumulated on + # the current operation and persisted with its assistant turn, independent + # from observable execution progress. + emit_reasoning: Callable[[ReasoningEventData], None] | None = None # Correlation metadata for the handler transition currently running. # The runner resets it before every independently committed step. + session_id: str = "" operation_id: str = "" stage_id: str = "" at_version: int = 0 event_revision: int = 0 + reasoning_text: str = "" def begin_operation(self, session: Session) -> None: + self.session_id = session.id self.operation_id = str(uuid.uuid4()) self.stage_id = str(session.current_state) self.at_version = session.version + 1 self.event_revision = 0 + self.reasoning_text = "" + if isinstance(self.agent, ReasoningStreamingAgent): + self.agent.set_reasoning_callback(self.record_reasoning) def next_event_revision(self) -> int: self.event_revision += 1 return self.event_revision + def record_reasoning(self, span_id: str, delta: str) -> None: + if not delta: + return + remaining = _MAX_REASONING_CHARS - len(self.reasoning_text) + if remaining <= 0: + return + accepted = delta[:remaining] + self.reasoning_text += accepted + if self.emit_reasoning is not None: + self.emit_reasoning( + ReasoningEventData( + session_id=self.session_id, + operation_id=self.operation_id, + stage_id=self.stage_id, + at_version=self.at_version, + revision=self.next_event_revision(), + span_id=span_id, + delta=accepted, + ) + ) + @dataclass class StepResult: @@ -153,6 +186,8 @@ class Runner: # durable. Stamp every item here so cards can never remain at the # placeholder version used while a step is being assembled. item.at_version = at_version + if item.kind == "assistant_turn" and self._env.reasoning_text: + item.payload["reasoning_text"] = self._env.reasoning_text new_version = self._env.repo.compare_and_advance( session.id, session.version, @@ -324,7 +359,7 @@ class Runner: assistant_item = AssistantTurnItem( turn_id=turn_id, stage_id=str(s.current_state), - trace=Trace(status="completed"), + execution=ExecutionProgress(status="completed"), reply_text=reply, ).to_item(seq=assistant_seq, at_version=assistant_version) fc.next_seq += 1 diff --git a/api/services/dify_builder/agent/build.py b/api/services/dify_builder/agent/build.py index 872bb2ee5dd..68155883c6a 100644 --- a/api/services/dify_builder/agent/build.py +++ b/api/services/dify_builder/agent/build.py @@ -6,6 +6,7 @@ rather than crashing the advance. build_nodes lives in the same module (added in Task A6).""" import logging +from collections.abc import Callable from typing import Any from core.app.app_config.entities import ModelConfig @@ -20,7 +21,11 @@ from services.workflow_generator_service import WorkflowGeneratorService logger = logging.getLogger(__name__) -def analyze_goal(model, goal_text: str) -> dict[str, Any]: +def analyze_goal( + model, + goal_text: str, + on_reasoning: Callable[[str], None] | None = None, +) -> dict[str, Any]: if model is None: return _degraded_form(goal_text) system = ( @@ -30,7 +35,7 @@ def analyze_goal(model, goal_text: str) -> dict[str, Any]: '"type": "text|textarea|select|bool", "options": ["..."]}], "values": {"": }}.' ) try: - data = llm.invoke_json(model, system=system, user=f"GOAL:\n{goal_text}") + data = llm.invoke_json(model, system=system, user=f"GOAL:\n{goal_text}", on_reasoning=on_reasoning) except Exception: return _degraded_form(goal_text) fields = data.get("fields") @@ -41,11 +46,17 @@ def analyze_goal(model, goal_text: str) -> dict[str, Any]: def _degraded_form(goal_text: str) -> dict[str, Any]: - return {"fields": [{"key": "goal", "label": "Goal", "type": "textarea", "options": []}], - "values": {"goal": goal_text}} + return { + "fields": [{"key": "goal", "label": "Goal", "type": "textarea", "options": []}], + "values": {"goal": goal_text}, + } -def propose_plan_v1(model, requirements: dict[str, Any]) -> list[str]: +def propose_plan_v1( + model, + requirements: dict[str, Any], + on_reasoning: Callable[[str], None] | None = None, +) -> list[str]: if model is None: return _degraded_plan() system = ( @@ -53,7 +64,12 @@ def propose_plan_v1(model, requirements: dict[str, Any]) -> list[str]: 'concise build steps. Reply with ONLY JSON: {"plan": ["step", ...]}.' ) try: - data = llm.invoke_json(model, system=system, user=f"REQUIREMENTS:\n{requirements}") + data = llm.invoke_json( + model, + system=system, + user=f"REQUIREMENTS:\n{requirements}", + on_reasoning=on_reasoning, + ) except Exception: return _degraded_plan() plan = data.get("plan") @@ -64,7 +80,12 @@ def _degraded_plan() -> list[str]: return ["Ingest the input", "Process with an LLM", "Emit the result"] -def discover_resources(model, tenant_id: str, plan_items: list[str]) -> list[ResourceOption]: +def discover_resources( + model, + tenant_id: str, + plan_items: list[str], + on_reasoning: Callable[[str], None] | None = None, +) -> list[ResourceOption]: inv = resources.list_tenant_resources(tenant_id) catalog = {r.id: ("knowledge", r) for r in inv.datasets} catalog.update({r.id: ("plugin", r) for r in inv.tools}) @@ -81,15 +102,20 @@ def discover_resources(model, tenant_id: str, plan_items: list[str]) -> list[Res listing = "\n".join(f"- {rid} ({kind}): {ref.label}" for rid, (kind, ref) in catalog.items()) user = f"PLAN:\n{chr(10).join(plan_items)}\n\nAVAILABLE:\n{listing}" try: - data = llm.invoke_json(model, system=system, user=user) + data = llm.invoke_json(model, system=system, user=user, on_reasoning=on_reasoning) picked = [rid for rid in (data.get("resource_ids") or []) if rid in catalog] if picked: chosen_ids = picked except Exception: chosen_ids = list(catalog.keys()) # explicit reset: degrade to full inventory return [ - ResourceOption(id=rid, label=catalog[rid][1].label, meta=catalog[rid][1].meta, - kind=catalog[rid][0], readiness=catalog[rid][1].readiness) + ResourceOption( + id=rid, + label=catalog[rid][1].label, + meta=catalog[rid][1].meta, + kind=catalog[rid][0], + readiness=catalog[rid][1].readiness, + ) for rid in chosen_ids ] @@ -113,15 +139,27 @@ def bind_resources( def learn_from_build( - model, goal_text: str, requirements: dict[str, Any], plan_items: list[str], - built_node_ids: list[str] + model, + goal_text: str, + requirements: dict[str, Any], + plan_items: list[str], + built_node_ids: list[str], + on_reasoning: Callable[[str], None] | None = None, ) -> str: fallback = f"Reusable skill: a {len(built_node_ids)}-node workflow for: {goal_text[:80]}" if model is None: return fallback system = "Summarize this built workflow as a one-line reusable skill descriptor. Reply with plain text." try: - return llm.invoke_text(model, system=system, user=f"GOAL: {goal_text}\nPLAN: {plan_items}") or fallback + return ( + llm.invoke_text( + model, + system=system, + user=f"GOAL: {goal_text}\nPLAN: {plan_items}", + on_reasoning=on_reasoning, + ) + or fallback + ) except Exception: return fallback @@ -131,16 +169,23 @@ _ALLOWED_NODE_TYPES: set[str] = set(BUILT_IN_NODE_TYPES) def _generator_model_config(tenant_id: str, model_config: dict[str, Any]) -> ModelConfig: if model_config: - return ModelConfig.model_validate({ - "provider": model_config.get("provider", ""), - "name": model_config.get("name", ""), - "mode": model_config.get("mode", "chat"), - "completion_params": model_config.get("completion_params", {}), - }) + return ModelConfig.model_validate( + { + "provider": model_config.get("provider", ""), + "name": model_config.get("name", ""), + "mode": model_config.get("mode", "chat"), + "completion_params": model_config.get("completion_params", {}), + } + ) inst = resolve_model_instance(tenant_id, None) # tenant default; read real provider/name off it - return ModelConfig.model_validate({ - "provider": inst.provider, "name": inst.model_name, "mode": "chat", "completion_params": {}, - }) + return ModelConfig.model_validate( + { + "provider": inst.provider, + "name": inst.model_name, + "mode": "chat", + "completion_params": {}, + } + ) # Prepended to the generator instruction so the LLM emits a WORKFLOW-shaped graph diff --git a/api/services/dify_builder/agent/chat.py b/api/services/dify_builder/agent/chat.py index be9a57979f7..9c5b4564dd9 100644 --- a/api/services/dify_builder/agent/chat.py +++ b/api/services/dify_builder/agent/chat.py @@ -40,6 +40,7 @@ def respond( graph: Graph, text: str, on_delta: Callable[[str], None] | None = None, + on_reasoning: Callable[[str], None] | None = None, ) -> str: fallback = ( f'I understand your note: "{text}". The Builder is currently at {state}. ' @@ -83,6 +84,7 @@ def respond( user=user, model_parameters=params, stop=stop, + on_reasoning=on_reasoning, ): chunks.append(delta) if on_delta is not None: diff --git a/api/services/dify_builder/agent/edit.py b/api/services/dify_builder/agent/edit.py index fee9f8cf800..c005d0b7a40 100644 --- a/api/services/dify_builder/agent/edit.py +++ b/api/services/dify_builder/agent/edit.py @@ -5,6 +5,7 @@ Fix pattern: the LLM proposes targeted intents which are dry-run-validated through graph_ops.filter_applicable before they can reach apply_repair. Degrades to an honest result on model-None / provider-error / parse-fail.""" +from collections.abc import Callable from typing import Any from core.dify_builder.models import MutationIntent @@ -40,11 +41,19 @@ def _graph_context(graph: dict) -> str: def _degraded_impact(goal_text: str) -> dict[str, Any]: - return {"fields": [{"key": "change", "label": "Change", "type": "textarea", "options": []}], - "values": {"change": goal_text}, "target_node_ids": []} + return { + "fields": [{"key": "change", "label": "Change", "type": "textarea", "options": []}], + "values": {"change": goal_text}, + "target_node_ids": [], + } -def analyze_impact(model, goal_text: str, graph: dict) -> dict[str, Any]: +def analyze_impact( + model, + goal_text: str, + graph: dict, + on_reasoning: Callable[[str], None] | None = None, +) -> dict[str, Any]: if model is None: return _degraded_impact(goal_text) system = ( @@ -54,7 +63,12 @@ def analyze_impact(model, goal_text: str, graph: dict) -> dict[str, Any]: '"values": {...}, "target_node_ids": ["", ...]}.' ) try: - data = llm.invoke_json(model, system=system, user=f"GOAL:\n{goal_text}\n\nGRAPH:\n{_graph_context(graph)}") + data = llm.invoke_json( + model, + system=system, + user=f"GOAL:\n{goal_text}\n\nGRAPH:\n{_graph_context(graph)}", + on_reasoning=on_reasoning, + ) except Exception: return _degraded_impact(goal_text) ids = _node_ids(graph) @@ -65,13 +79,21 @@ def analyze_impact(model, goal_text: str, graph: dict) -> dict[str, Any]: return {"fields": fields, "values": values, "target_node_ids": targets} -def propose_edit_plan(model, edit_rules: dict[str, Any], graph: dict) -> list[str]: +def propose_edit_plan( + model, + edit_rules: dict[str, Any], + graph: dict, + on_reasoning: Callable[[str], None] | None = None, +) -> list[str]: if model is None: return ["Apply the requested edit"] system = 'You are a Dify workflow edit planner. Reply with ONLY JSON: {"plan": ["step", ...]}.' try: data = llm.invoke_json( - model, system=system, user=f"EDIT RULES:\n{edit_rules}\n\nGRAPH:\n{_graph_context(graph)}" + model, + system=system, + user=f"EDIT RULES:\n{edit_rules}\n\nGRAPH:\n{_graph_context(graph)}", + on_reasoning=on_reasoning, ) except Exception: return ["Apply the requested edit"] @@ -79,17 +101,25 @@ def propose_edit_plan(model, edit_rules: dict[str, Any], graph: dict) -> list[st return [str(p) for p in plan] if isinstance(plan, list) and plan else ["Apply the requested edit"] -def build_edit_intents(model, edit_rules: dict[str, Any], graph: dict) -> list[MutationIntent]: +def build_edit_intents( + model, + edit_rules: dict[str, Any], + graph: dict, + on_reasoning: Callable[[str], None] | None = None, +) -> list[MutationIntent]: if model is None: return [] system = ( "You are a Dify workflow editor. Propose the minimal mutations that apply the edit rules to " "EXISTING nodes. Reference only node ids that exist and use only the listed node types. " - 'Reply with ONLY JSON: {"intents": [{"op": ..., "args": {...}}]}.\n' + _OP_SCHEMA - + "Allowed node types: " + ", ".join(sorted(_ALLOWED_NODE_TYPES)) + ".\n" + 'Reply with ONLY JSON: {"intents": [{"op": ..., "args": {...}}]}.\n' + + _OP_SCHEMA + + "Allowed node types: " + + ", ".join(sorted(_ALLOWED_NODE_TYPES)) + + ".\n" ) user = f"EDIT RULES:\n{edit_rules}\n\nGRAPH:\n{_graph_context(graph)}" - intents = _invoke_intents(model, system, user) + intents = _invoke_intents(model, system, user, on_reasoning) if intents is None: return [] applicable, rejected = graph_ops.filter_applicable(graph, intents, _ALLOWED_NODE_TYPES) @@ -98,16 +128,21 @@ def build_edit_intents(model, edit_rules: dict[str, Any], graph: dict) -> list[M if not applicable and rejected: reasons = "\n".join(f"- {i.op} {i.args}: {why}" for i, why in rejected) retry_user = f"{user}\n\nYour previous intents were invalid:\n{reasons}\nReturn corrected intents." - intents = _invoke_intents(model, system, retry_user) + intents = _invoke_intents(model, system, retry_user, on_reasoning) if intents is None: return [] applicable, _rejected = graph_ops.filter_applicable(graph, intents, _ALLOWED_NODE_TYPES) return applicable -def _invoke_intents(model, system: str, user: str) -> list[MutationIntent] | None: +def _invoke_intents( + model, + system: str, + user: str, + on_reasoning: Callable[[str], None] | None, +) -> list[MutationIntent] | None: try: - data = llm.invoke_json(model, system=system, user=user) + data = llm.invoke_json(model, system=system, user=user, on_reasoning=on_reasoning) except Exception: return None raw = data.get("intents") diff --git a/api/services/dify_builder/agent/fix.py b/api/services/dify_builder/agent/fix.py index e3e69439c5a..d0682e96c49 100644 --- a/api/services/dify_builder/agent/fix.py +++ b/api/services/dify_builder/agent/fix.py @@ -6,6 +6,7 @@ valid repair) they DEGRADE to an honest, surface-to-human result rather than crashing the advance or applying a canned guess. """ +from collections.abc import Callable from typing import Any from core.dify_builder.models import ( @@ -105,7 +106,13 @@ def _degraded_checklist(errors: list[ChecklistError]) -> Diagnosis: return Diagnosis(culprit_node_id="", root_cause="Checklist error", severity="medium") -def diagnose(model: ModelInstance | None, failed_run: Run, graph: Graph, node_outputs: list[NodeOutput]) -> Diagnosis: +def diagnose( + model: ModelInstance | None, + failed_run: Run, + graph: Graph, + node_outputs: list[NodeOutput], + on_reasoning: Callable[[str], None] | None = None, +) -> Diagnosis: failed = _failed_nodes(node_outputs) if model is None: return _degraded_diagnosis(failed) @@ -125,14 +132,19 @@ def diagnose(model: ModelInstance | None, failed_run: Run, graph: Graph, node_ou ) user = f"FAILED NODES:\n{failed_desc}\n\nGRAPH:\n{_graph_context(graph)}" try: - data = llm.invoke_json(model, system=system, user=user) + data = llm.invoke_json(model, system=system, user=user, on_reasoning=on_reasoning) except Exception: # any LLM/provider failure degrades to a surfaced result, never crashes the advance return _degraded_diagnosis(failed) fallback = failed[0].node_id if failed else "" return _diagnosis_from_json(data, graph, fallback_node=fallback) -def diagnose_checklist(model: ModelInstance | None, errors: list[ChecklistError], graph: Graph) -> Diagnosis: +def diagnose_checklist( + model: ModelInstance | None, + errors: list[ChecklistError], + graph: Graph, + on_reasoning: Callable[[str], None] | None = None, +) -> Diagnosis: if model is None: return _degraded_checklist(errors) system = ( @@ -151,7 +163,7 @@ def diagnose_checklist(model: ModelInstance | None, errors: list[ChecklistError] ) user = f"CHECKLIST ERRORS:\n{errs}\n\nGRAPH:\n{_graph_context(graph)}" try: - data = llm.invoke_json(model, system=system, user=user) + data = llm.invoke_json(model, system=system, user=user, on_reasoning=on_reasoning) except Exception: # degrade to a surfaced result, never crash the advance return _degraded_checklist(errors) fallback = next((e.node_id for e in errors if e.node_id), "") @@ -222,16 +234,24 @@ def _shape_risk(intents: list[MutationIntent], graph: Graph, llm_risk: Risk) -> ) -def _invoke_repair(model: ModelInstance, system: str, user: str) -> tuple[list[MutationIntent] | None, Risk]: +def _invoke_repair( + model: ModelInstance, + system: str, + user: str, + on_reasoning: Callable[[str], None] | None, +) -> tuple[list[MutationIntent] | None, Risk]: try: - data = llm.invoke_json(model, system=system, user=user) + data = llm.invoke_json(model, system=system, user=user, on_reasoning=on_reasoning) except Exception: # degrade to a surfaced result, never crash the advance return None, _no_fix()[1] return _parse_repair(data) def propose_repair( - model: ModelInstance | None, diagnosis: Diagnosis, graph: Graph + model: ModelInstance | None, + diagnosis: Diagnosis, + graph: Graph, + on_reasoning: Callable[[str], None] | None = None, ) -> tuple[list[MutationIntent], Risk]: if model is None: return _no_fix() @@ -251,14 +271,14 @@ def propose_repair( f" severity={diagnosis.severity}\n\nCULPRIT NODE CONFIG:\n{_culprit_config(diagnosis.culprit_node_id, graph)}" f"\n\nGRAPH:\n{_graph_context(graph)}" ) - intents, risk = _invoke_repair(model, system, user) + intents, risk = _invoke_repair(model, system, user, on_reasoning) if intents is None: return _no_fix() applicable, rejected = graph_ops.filter_applicable(graph, intents, _ALLOWED_NODE_TYPES) if rejected: reasons = "\n".join(f"- {i.op} {i.args}: {why}" for i, why in rejected) retry_user = f"{user}\n\nYour previous intents were invalid:\n{reasons}\nReturn corrected intents." - intents, risk = _invoke_repair(model, system, retry_user) + intents, risk = _invoke_repair(model, system, retry_user, on_reasoning) if intents is None: return _no_fix() applicable, rejected = graph_ops.filter_applicable(graph, intents, _ALLOWED_NODE_TYPES) diff --git a/api/services/dify_builder/agent/llm.py b/api/services/dify_builder/agent/llm.py index 0b2c60adfa7..96e65ca4239 100644 --- a/api/services/dify_builder/agent/llm.py +++ b/api/services/dify_builder/agent/llm.py @@ -7,10 +7,11 @@ WorkflowGenerator._invoke_and_parse_json: parse with json_repair, and on failure retry ONCE with a corrective hint before raising. """ -from collections.abc import Generator, Iterator +from collections.abc import Callable, Generator, Iterator, Mapping from typing import Any, cast import json_repair +from pydantic import BaseModel from graphon.model_runtime.entities.llm_entities import LLMResult, LLMResultChunk from graphon.model_runtime.entities.message_entities import ( @@ -18,17 +19,51 @@ from graphon.model_runtime.entities.message_entities import ( TextPromptMessageContent, UserPromptMessage, ) +from graphon.nodes.llm.reasoning import ThinkStreamFilter, split_reasoning class LlmError(Exception): """The model did not return usable output (e.g. non-JSON after one retry).""" -def _complete(model_instance, messages, model_parameters, stop) -> str: +ReasoningCallback = Callable[[str], None] + + +def _direct_reasoning(value: object) -> str: + """Read provider-native reasoning without recursively scanning answer text.""" + sources: tuple[Mapping[str, object], ...] + if isinstance(value, BaseModel): + sources = (value.model_dump(), value.model_extra or {}) + elif isinstance(value, Mapping): + sources = (cast(Mapping[str, object], value),) + else: + return "" + for source in sources: + for key in ("reasoning_content", "reasoning", "reasoningContent"): + reasoning = source.get(key) + if isinstance(reasoning, str) and reasoning: + return reasoning + return "" + + +def _emit_reasoning(callback: ReasoningCallback | None, reasoning: str) -> None: + if callback is not None and reasoning: + callback(reasoning) + + +def _complete(model_instance, messages, model_parameters, stop, on_reasoning: ReasoningCallback | None) -> str: result = model_instance.invoke_llm( prompt_messages=messages, model_parameters=model_parameters or {}, stop=stop, stream=False ) - return result.message.get_text_content() + native_reasoning = _direct_reasoning(result) + _emit_reasoning(on_reasoning, native_reasoning) + message = getattr(result, "message", None) + if message is None or not hasattr(message, "get_text_content"): + raise LlmError("model returned an invalid blocking response") + clean_text, tagged_reasoning = split_reasoning(message.get_text_content(), "separated") + if tagged_reasoning and tagged_reasoning != native_reasoning: + _emit_reasoning(on_reasoning, tagged_reasoning) + return clean_text def _as_dict(text: str) -> dict[str, Any] | None: @@ -46,9 +81,10 @@ def invoke_text( user: str, model_parameters: dict[str, Any] | None = None, stop: list[str] | None = None, + on_reasoning: ReasoningCallback | None = None, ) -> str: messages = [SystemPromptMessage(content=system), UserPromptMessage(content=user)] - return _complete(model_instance, messages, model_parameters, stop) + return _complete(model_instance, messages, model_parameters, stop, on_reasoning) def invoke_text_stream( @@ -58,8 +94,9 @@ def invoke_text_stream( user: str, model_parameters: dict[str, Any] | None = None, stop: list[str] | None = None, + on_reasoning: ReasoningCallback | None = None, ) -> Iterator[str]: - """Yield assistant text deltas from the model's native streaming API.""" + """Yield answer deltas while routing model reasoning to its own callback.""" messages = [SystemPromptMessage(content=system), UserPromptMessage(content=user)] result = model_instance.invoke_llm( prompt_messages=messages, @@ -68,28 +105,47 @@ def invoke_text_stream( stream=True, ) if isinstance(result, LLMResult): - text = result.message.get_text_content() + native_reasoning = _direct_reasoning(result) + _emit_reasoning(on_reasoning, native_reasoning) + text, tagged_reasoning = split_reasoning(result.message.get_text_content(), "separated") + if tagged_reasoning and tagged_reasoning != native_reasoning: + _emit_reasoning(on_reasoning, tagged_reasoning) if text: yield text return + think_filter = ThinkStreamFilter() for chunk in cast(Generator[LLMResultChunk, None, None], result): + native_reasoning = next( + ( + reasoning + for value in (chunk, chunk.delta, chunk.delta.message) + if (reasoning := _direct_reasoning(value)) + ), + "", + ) + _emit_reasoning(on_reasoning, native_reasoning) content = chunk.delta.message.content if isinstance(content, str): - if content: - yield content - continue - if not isinstance(content, list): - continue - - delta = "" - for part in cast(list[object], content): - if isinstance(part, TextPromptMessageContent): - delta += part.data - elif isinstance(part, str): - delta += part - if delta: - yield delta + delta = content + elif isinstance(content, list): + delta = "".join( + part.data if isinstance(part, TextPromptMessageContent) else part + for part in cast(list[object], content) + if isinstance(part, (TextPromptMessageContent, str)) + ) + else: + delta = "" + for piece in think_filter.feed(delta): + if piece.kind == "reasoning": + _emit_reasoning(on_reasoning, piece.chunk) + elif piece.chunk: + yield piece.chunk + for piece in think_filter.finalize(): + if piece.kind == "reasoning": + _emit_reasoning(on_reasoning, piece.chunk) + elif piece.chunk: + yield piece.chunk def invoke_json( @@ -99,9 +155,10 @@ def invoke_json( user: str, model_parameters: dict[str, Any] | None = None, stop: list[str] | None = None, + on_reasoning: ReasoningCallback | None = None, ) -> dict[str, Any]: messages = [SystemPromptMessage(content=system), UserPromptMessage(content=user)] - text = _complete(model_instance, messages, model_parameters, stop) + text = _complete(model_instance, messages, model_parameters, stop, on_reasoning) parsed = _as_dict(text) if parsed is not None: return parsed @@ -112,7 +169,7 @@ def invoke_json( ) ) ) - retry_text = _complete(model_instance, messages, model_parameters, stop) + retry_text = _complete(model_instance, messages, model_parameters, stop, on_reasoning) parsed = _as_dict(retry_text) if parsed is not None: return parsed diff --git a/api/services/dify_builder/agent/llm_agent.py b/api/services/dify_builder/agent/llm_agent.py index a84d0036e04..5d1d4485241 100644 --- a/api/services/dify_builder/agent/llm_agent.py +++ b/api/services/dify_builder/agent/llm_agent.py @@ -15,6 +15,7 @@ streaming; the other cognition methods resolve the model through """ import logging +from collections.abc import Callable from typing import Any from core.model_manager import ModelInstance @@ -30,6 +31,16 @@ class LlmBuilderAgent: self._model_config = model_config or {} self._model_instance: ModelInstance | None = None self._resolution_failed = False + self._reasoning_callback: Callable[[str, str], None] | None = None + + def set_reasoning_callback(self, callback: Callable[[str, str], None] | None) -> None: + self._reasoning_callback = callback + + def _reasoning_for(self, span_id: str) -> Callable[[str], None] | None: + callback = self._reasoning_callback + if callback is None: + return None + return lambda delta: callback(span_id, delta) def _model(self) -> ModelInstance: """Lazily resolve + memoize the chosen model. Used by cognition (next slice).""" @@ -55,26 +66,56 @@ class LlmBuilderAgent: # -- Fix cognition (delegated) -- def diagnose(self, failed_run, graph, node_outputs): - return fix.diagnose(self._model_or_none(), failed_run, graph, node_outputs) + return fix.diagnose( + self._model_or_none(), + failed_run, + graph, + node_outputs, + self._reasoning_for("diagnose"), + ) def diagnose_checklist(self, errors, graph): - return fix.diagnose_checklist(self._model_or_none(), errors, graph) + return fix.diagnose_checklist( + self._model_or_none(), + errors, + graph, + self._reasoning_for("diagnose-checklist"), + ) def propose_repair(self, diagnosis, graph): - return fix.propose_repair(self._model_or_none(), diagnosis, graph) + return fix.propose_repair( + self._model_or_none(), + diagnosis, + graph, + self._reasoning_for("propose-repair"), + ) def generate_mock_inputs(self, schema, prior_failed): - return mock_inputs.generate(self._model_or_none(), schema, prior_failed) + return mock_inputs.generate( + self._model_or_none(), + schema, + prior_failed, + self._reasoning_for("generate-mock-inputs"), + ) # -- Build cognition (real) -- def analyze_goal(self, goal_text): - return build.analyze_goal(self._model_or_none(), goal_text) + return build.analyze_goal(self._model_or_none(), goal_text, self._reasoning_for("analyze-goal")) def propose_plan_v1(self, requirements): - return build.propose_plan_v1(self._model_or_none(), requirements) + return build.propose_plan_v1( + self._model_or_none(), + requirements, + self._reasoning_for("propose-build-plan"), + ) def discover_resources(self, plan_items): - return build.discover_resources(self._model_or_none(), self._tenant_id, plan_items) + return build.discover_resources( + self._model_or_none(), + self._tenant_id, + plan_items, + self._reasoning_for("discover-resources"), + ) def bind_resources(self, plan_items, resource_ids, conflict_policy): return build.bind_resources(self._model_or_none(), self._tenant_id, plan_items, resource_ids, conflict_policy) @@ -83,17 +124,39 @@ class LlmBuilderAgent: return build.build_nodes(self._tenant_id, self._model_config, plan_items) def learn_from_build(self, goal_text, requirements, plan_items, built_node_ids): - return build.learn_from_build(self._model_or_none(), goal_text, requirements, plan_items, built_node_ids) + return build.learn_from_build( + self._model_or_none(), + goal_text, + requirements, + plan_items, + built_node_ids, + self._reasoning_for("learn-from-build"), + ) # -- Edit cognition (real) -- def analyze_impact(self, goal_text, graph): - return edit.analyze_impact(self._model_or_none(), goal_text, graph) + return edit.analyze_impact( + self._model_or_none(), + goal_text, + graph, + self._reasoning_for("analyze-impact"), + ) def propose_edit_plan(self, edit_rules, graph): - return edit.propose_edit_plan(self._model_or_none(), edit_rules, graph) + return edit.propose_edit_plan( + self._model_or_none(), + edit_rules, + graph, + self._reasoning_for("propose-edit-plan"), + ) def build_edit_intents(self, edit_rules, graph): - return edit.build_edit_intents(self._model_or_none(), edit_rules, graph) + return edit.build_edit_intents( + self._model_or_none(), + edit_rules, + graph, + self._reasoning_for("build-edit-intents"), + ) def respond_to_message(self, state, context, history, graph, text, on_delta=None): return chat.respond( @@ -105,4 +168,5 @@ class LlmBuilderAgent: graph, text, on_delta, + self._reasoning_for("respond-to-message"), ) diff --git a/api/services/dify_builder/agent/mock_inputs.py b/api/services/dify_builder/agent/mock_inputs.py index 4f1caa14847..a70f7e44dd6 100644 --- a/api/services/dify_builder/agent/mock_inputs.py +++ b/api/services/dify_builder/agent/mock_inputs.py @@ -4,6 +4,7 @@ Given the start node's StartSchema, produce plausible run inputs keyed by each declared variable. LLM-assisted, degrading to deterministic type-based defaults on model-None / provider-error / parse-fail (never raises).""" +from collections.abc import Callable from typing import Any from core.dify_builder.models import Inputs, StartSchema @@ -34,7 +35,12 @@ def _deterministic(vars_: list[dict[str, Any]]) -> Inputs: return {str(v["variable"]): _default_value(v) for v in vars_} -def generate(model, schema: StartSchema, prior_failed: Inputs) -> Inputs: +def generate( + model, + schema: StartSchema, + prior_failed: Inputs, + on_reasoning: Callable[[str], None] | None = None, +) -> Inputs: vars_ = _variables(schema) if not vars_: return {} @@ -48,7 +54,7 @@ def generate(model, schema: StartSchema, prior_failed: Inputs) -> Inputs: listing = "\n".join(f"- {v['variable']} (type={v.get('type', '?')}) options={v.get('options')}" for v in vars_) user = f"VARIABLES:\n{listing}\n\nPREVIOUSLY-FAILING (avoid): {prior_failed}" try: - data = llm.invoke_json(model, system=system, user=user) + data = llm.invoke_json(model, system=system, user=user, on_reasoning=on_reasoning) produced = data.get("inputs") if not isinstance(produced, dict): return _deterministic(vars_) diff --git a/api/services/dify_builder/service.py b/api/services/dify_builder/service.py index 87a6aa88387..9f439f7b9ee 100644 --- a/api/services/dify_builder/service.py +++ b/api/services/dify_builder/service.py @@ -366,7 +366,7 @@ def _actions_for( if interrupted or state == PcState.FAILED: return [UiAction(id="restart", label="Restart from current draft", kind=ActionKind.PRIMARY)] if fc is not None and fc.paused: - return [UiAction(id="resume", label="Resume", kind=ActionKind.PRIMARY)] + return [] recovery_ref = recovery.recovery_ref_for(fc.recovery_class) if fc is not None else None if recovery_ref is not None: actions: list[UiAction] = [] @@ -378,10 +378,7 @@ def _actions_for( if app_revision_conflicted: return [UiAction(id="check_recovery", label="Review draft changes", kind=ActionKind.PRIMARY)] - actions = list(_ACTIONS_FOR.get(state, [])) - if fc is not None and is_waiting(state): - actions.append(UiAction(id="pause", label="Pause", kind=ActionKind.SECONDARY)) - return actions + return list(_ACTIONS_FOR.get(state, [])) _ACTION_ID_TO_KIND: dict[str, str] = { @@ -532,7 +529,7 @@ def _run_status(state: PcState, paused: bool = False) -> RunStatus: and ``PcState.EDIT_COMPLETE`` are terminal (spec §7.1/§7.2, ``run_status: complete``) but are not in ``_WORKING``/``_WAITING`` and are not ``SUCCESS``/``FAILED`` -- without this ordering they'd wrongly fall - through to EXECUTING. + through to PROCESSING. ``paused`` (Task 7, ``fc.paused``) only applies at a waiting state -- the canvas stays editable while paused -- and never overrides a terminal @@ -549,8 +546,8 @@ def _run_status(state: PcState, paused: bool = False) -> RunStatus: if is_waiting(state): return RunStatus.WAITING_CONFIRMATION if is_working(state): - return RunStatus.EXECUTING - return RunStatus.EXECUTING # defensive; unreachable for classified states + return RunStatus.PROCESSING + return RunStatus.PROCESSING # defensive; unreachable for classified states class DifyBuilderService: @@ -985,13 +982,7 @@ class DifyBuilderService: version=s.version, state=str(st), canvas_read_only=lock_held or canvas_read_only(st), - run_status=( - RunStatus.THINKING - if lock_held and is_waiting(st) - else RunStatus.EXECUTING - if lock_held - else _run_status(st, paused=fc.paused) - ), + run_status=RunStatus.PROCESSING if lock_held else _run_status(st, paused=fc.paused), interrupted=interrupted, conversation_last_seq=fc.next_seq - 1, entry_mode=s.entry_mode, diff --git a/api/services/dify_builder/wiring.py b/api/services/dify_builder/wiring.py index 89892061d1e..41f6e27ef6c 100644 --- a/api/services/dify_builder/wiring.py +++ b/api/services/dify_builder/wiring.py @@ -36,7 +36,7 @@ __all__ = [ _HEARTBEAT_SECONDS = 15 _MAX_STREAM_SECONDS = dify_config.DIFY_BUILDER_MAX_ADVANCE_SECONDS + _HEARTBEAT_SECONDS _TERMINAL_KINDS = ("state", "error") -_PROGRESS_KINDS = frozenset({"node", "canvas", "agent_message", "progress", "commit", *_TERMINAL_KINDS}) +_PROGRESS_KINDS = frozenset({"node", "canvas", "agent_message", "reasoning", "progress", "commit", *_TERMINAL_KINDS}) def _enqueue(session_id: str, action: Action, actor: Actor, token: str) -> None: diff --git a/api/tasks/dify_builder_advance_task.py b/api/tasks/dify_builder_advance_task.py index b8d208622bc..34b91d911d9 100644 --- a/api/tasks/dify_builder_advance_task.py +++ b/api/tasks/dify_builder_advance_task.py @@ -26,7 +26,13 @@ from celery import shared_task from sqlalchemy.orm import sessionmaker from configs import dify_config -from core.dify_builder.contract import AgentMessageEventData, ErrorCard, ProgressEventData, Trace +from core.dify_builder.contract import ( + AgentMessageEventData, + ErrorCard, + ExecutionProgress, + ProgressEventData, + ReasoningEventData, +) from core.dify_builder.errors import ConflictError from core.dify_builder.handlers_build import build_registry from core.dify_builder.handlers_edit import edit_registry @@ -178,6 +184,16 @@ def advance_session(session_id: str, action_dict: dict, actor_dict: dict, token: message.id, ) + def emit_reasoning(reasoning: ReasoningEventData) -> None: + try: + progress_bus.publish(session_id, asdict(reasoning)) + except Exception: + logger.exception( + "dify_builder reasoning event publish failed for session %s operation %s", + session_id, + reasoning.operation_id, + ) + def emit_progress(progress: ProgressEventData) -> None: nonlocal last_progress last_progress = progress @@ -202,6 +218,7 @@ def advance_session(session_id: str, action_dict: dict, actor_dict: dict, token: emit_commit=emit_commit, emit_message=emit_message, emit_progress=emit_progress, + emit_reasoning=emit_reasoning, ) runner = Runner(env, fix_registry() | build_registry() | edit_registry()) env.begin_operation(loaded_session) @@ -218,18 +235,24 @@ def advance_session(session_id: str, action_dict: dict, actor_dict: dict, token: # Generic message only -- never leak exception detail into the # progress event (it is relayed to the end user via SSE in P3c). logger.exception("dify_builder advance failed for session %s", session_id) - if last_progress is not None and last_progress.trace.status == "running": - failed_trace = Trace( + if last_progress is not None and last_progress.execution.status == "running": + failed_execution = ExecutionProgress( status="error", - steps=[ - replace(step, state="stopped", tone="error") if step.state == "active" else replace(step) - for step in last_progress.trace.steps + activities=[ + replace(activity, state="failed") if activity.state == "active" else replace(activity) + for activity in last_progress.execution.activities ], ) try: progress_bus.publish( session_id, - asdict(replace(last_progress, revision=last_progress.revision + 1, trace=failed_trace)), + asdict( + replace( + last_progress, + revision=last_progress.revision + 1, + execution=failed_execution, + ) + ), ) except Exception: logger.exception("dify_builder failed progress publish failed for session %s", session_id) diff --git a/api/tests/unit_tests/controllers/console/test_dify_builder_routes.py b/api/tests/unit_tests/controllers/console/test_dify_builder_routes.py index e0d6d40f8f1..60de39b9494 100644 --- a/api/tests/unit_tests/controllers/console/test_dify_builder_routes.py +++ b/api/tests/unit_tests/controllers/console/test_dify_builder_routes.py @@ -20,7 +20,7 @@ from core.dify_builder.models import Actor, ConversationItem from services.dify_builder.service import SessionView -def _session_view(session_id: str = "s1", *, run_status: RunStatus = RunStatus.EXECUTING) -> SessionView: +def _session_view(session_id: str = "s1", *, run_status: RunStatus = RunStatus.PROCESSING) -> SessionView: return SessionView( session_id=session_id, app_id="a1", diff --git a/api/tests/unit_tests/core/dify_builder/test_contract.py b/api/tests/unit_tests/core/dify_builder/test_contract.py index 80ff866d9f1..93c57518fcc 100644 --- a/api/tests/unit_tests/core/dify_builder/test_contract.py +++ b/api/tests/unit_tests/core/dify_builder/test_contract.py @@ -13,6 +13,8 @@ from core.dify_builder.contract import ( CanvasEvent, CardKind, ChangeSetCard, + ExecutionActivity, + ExecutionProgress, FormCard, FormField, Phase, @@ -25,8 +27,6 @@ from core.dify_builder.contract import ( SummaryCard, TestResultCard, TestStat, - Trace, - TraceStep, ) from core.dify_builder.models import EntryMode from core.dify_builder.state import PcState @@ -72,8 +72,7 @@ def test_enums_match_spec(): "complete", ] assert [r.value for r in RunStatus] == [ - "thinking", - "executing", + "processing", "waiting_input", "waiting_confirmation", "paused", @@ -96,7 +95,7 @@ def test_sessionview_has_new_fields(): version=1, state="fix.diagnose", canvas_read_only=True, - run_status=RunStatus.EXECUTING, + run_status=RunStatus.PROCESSING, interrupted=False, conversation_last_seq=-1, ) @@ -112,13 +111,13 @@ def test_run_status_terminal_states(): PcState.EDIT_COMPLETE are terminal (spec §7.1/§7.2, run_status: complete) but are absent from both _WORKING and _WAITING and aren't SUCCESS/FAILED -- _run_status must special-case is_terminal() before the waiting/working - checks, or these fall through to EXECUTING.""" + checks, or these fall through to PROCESSING.""" assert _run_status(PcState.SUCCESS) == RunStatus.COMPLETE assert _run_status(PcState.FAILED) == RunStatus.FAILED assert _run_status(PcState.BUILD_COMPLETE) == RunStatus.COMPLETE - assert _run_status(PcState.EDIT_PUBLISH) == RunStatus.EXECUTING + assert _run_status(PcState.EDIT_PUBLISH) == RunStatus.PROCESSING assert _run_status(PcState.EDIT_COMPLETE) == RunStatus.COMPLETE - assert _run_status(PcState.FIX_DIAGNOSE) == RunStatus.EXECUTING # working + assert _run_status(PcState.FIX_DIAGNOSE) == RunStatus.PROCESSING # working assert _run_status(PcState.FIX_AWAIT_DECISION) == RunStatus.WAITING_CONFIRMATION @@ -226,14 +225,15 @@ def test_card_shapes_round_trip(): pub_item = pub.to_item(seq=7, at_version=4) assert pub_item.payload == {"version": "v1.0", "badge": "live"} - # assistant_turn -- trace.steps[*] carries state/tone/canvas_event. + # assistant_turn keeps model reasoning separate from observable execution. turn = AssistantTurnItem( turn_id="t1", stage_id="build.goal_analysis", - trace=Trace( + execution=ExecutionProgress( status="running", - steps=[TraceStep(id="s1", label="Reading goal", state="active", tone="neutral", canvas_event=None)], + activities=[ExecutionActivity(id="s1", label="Reading goal", state="active")], ), + reasoning_text="I should clarify the requested output.", reply_text="Looking at your goal...", cards=["plan"], ) @@ -241,10 +241,15 @@ def test_card_shapes_round_trip(): assert "kind" not in asdict(turn) turn_item = turn.to_item(seq=8, at_version=4) assert turn_item.kind == "assistant_turn" - step_payload = turn_item.payload["trace"]["steps"][0] - assert step_payload["state"] == "active" - assert step_payload["tone"] == "neutral" - assert step_payload["canvas_event"] is None + activity_payload = turn_item.payload["execution"]["activities"][0] + assert activity_payload == { + "id": "s1", + "label": "Reading goal", + "state": "active", + "kind": "stage", + "parent_id": None, + } + assert turn_item.payload["reasoning_text"] == "I should clarify the requested output." # typed submit payload -- not a card: no kind, no to_item. req = RequirementsPayload( @@ -367,7 +372,7 @@ def test_transport_json_schema_contains_request_and_sse_unions(): assert len(create_schema["anyOf"]) == 4 assert "response_mode" not in str(create_schema) assert stream_schema["discriminator"]["propertyName"] == "event" - assert len(stream_schema["oneOf"]) == 8 + assert len(stream_schema["oneOf"]) == 9 def test_sse_union_accepts_current_payloads(): @@ -427,6 +432,19 @@ def test_sse_union_accepts_current_payloads(): "stage_id": "edit.impact_analysis", }, }, + { + "event": "reasoning", + "data": { + "kind": "reasoning", + "session_id": "s1", + "operation_id": "operation-1", + "stage_id": "edit.impact_analysis", + "at_version": 3, + "revision": 4, + "span_id": "analyze-impact", + "delta": "The requested change touches the LLM node.", + }, + }, { "event": "progress", "data": { @@ -436,14 +454,13 @@ def test_sse_union_accepts_current_payloads(): "stage_id": "edit.impact_analysis", "at_version": 3, "revision": 1, - "trace": { + "execution": { "status": "running", - "steps": [ + "activities": [ { "id": "edit-analyze-impact", "label": "Analyze the requested change", "state": "active", - "tone": "neutral", } ], }, diff --git a/api/tests/unit_tests/core/dify_builder/test_handlers_build.py b/api/tests/unit_tests/core/dify_builder/test_handlers_build.py index 36148f24ced..fb4d30a0c57 100644 --- a/api/tests/unit_tests/core/dify_builder/test_handlers_build.py +++ b/api/tests/unit_tests/core/dify_builder/test_handlers_build.py @@ -237,7 +237,7 @@ def test_plan_approval_approve_builds_graph_and_reveals_nodes(): change_set = next(i for i in res.items if i.kind == "change_set") assert change_set.payload["scope"] == "structure" assistant = next(i for i in res.items if i.kind == "assistant_turn") - assert len(assistant.payload["trace"]["steps"]) == 3 + assert len(assistant.payload["execution"]["activities"]) == 3 def test_plan_approval_ignores_non_approve_action(): diff --git a/api/tests/unit_tests/core/dify_builder/test_progress.py b/api/tests/unit_tests/core/dify_builder/test_progress.py index fc490378903..a2a7fcd8158 100644 --- a/api/tests/unit_tests/core/dify_builder/test_progress.py +++ b/api/tests/unit_tests/core/dify_builder/test_progress.py @@ -1,10 +1,15 @@ import pytest -from core.dify_builder.contract import ProgressEventData, TraceStep +from core.dify_builder.contract import ExecutionActivity, ProgressEventData +from core.dify_builder.models import NodeEvent from core.dify_builder.progress import ProgressReporter -def test_progress_reporter_emits_immutable_full_snapshots_with_monotonic_revisions() -> None: +def _activity(activity_id: str, label: str) -> ExecutionActivity: + return ExecutionActivity(id=activity_id, label=label, state="active") + + +def test_progress_reporter_reveals_only_started_activities_with_monotonic_revisions() -> None: events: list[ProgressEventData] = [] reporter = ProgressReporter( emit=events.append, @@ -12,50 +17,73 @@ def test_progress_reporter_emits_immutable_full_snapshots_with_monotonic_revisio operation_id="operation-1", stage_id="build.initial_plan", at_version=4, - steps=[ - TraceStep(id="review", label="Review requirements", state="pending"), - TraceStep(id="draft", label="Draft the workflow plan", state="pending"), + activities=[ + _activity("review", "Review requirements"), + _activity("draft", "Draft the workflow plan"), ], ) reporter.activate("review") reporter.activate("draft") - final_trace = reporter.finish() + final_execution = reporter.finish() assert [event.revision for event in events] == [1, 2, 3] - assert [step.state for step in events[0].trace.steps] == ["active", "pending"] - assert [step.state for step in events[1].trace.steps] == ["done", "active"] - assert [step.state for step in events[2].trace.steps] == ["done", "done"] - assert final_trace == events[2].trace - assert events[0].trace.steps[0].state == "active" + assert [(item.id, item.state) for item in events[0].execution.activities] == [("review", "active")] + assert [(item.id, item.state) for item in events[1].execution.activities] == [ + ("review", "done"), + ("draft", "active"), + ] + assert [item.state for item in events[2].execution.activities] == ["done", "done"] + assert final_execution == events[2].execution + assert events[0].execution.activities[0].state == "active" assert events[2].operation_id == "operation-1" assert events[2].at_version == 4 -def test_progress_reporter_can_append_recovery_steps_after_a_failed_activity() -> None: +def test_progress_reporter_keeps_unselected_branch_steps_private() -> None: events: list[ProgressEventData] = [] reporter = ProgressReporter( emit=events.append, session_id="session-1", stage_id="build.test_and_repair", at_version=2, - steps=[TraceStep(id="run", label="Run the workflow", state="pending")], + activities=[_activity("run", "Run the workflow")], ) reporter.activate("run") reporter.fail_step("run") reporter.add_steps([("diagnose", "Diagnose the failed workflow")]) + assert [item.id for item in events[-1].execution.activities] == ["run"] + reporter.activate("diagnose") - final_trace = reporter.finish() + final_execution = reporter.finish() - assert [(step.id, step.state, step.tone) for step in final_trace.steps] == [ - ("run", "stopped", "error"), - ("diagnose", "done", "neutral"), + assert [(item.id, item.state) for item in final_execution.activities] == [ + ("run", "failed"), + ("diagnose", "done"), ] - assert events[-1].trace.status == "completed" + assert events[-1].execution.status == "completed" -def test_progress_reporter_context_closes_active_step_on_exception() -> None: +def test_progress_reporter_nests_workflow_nodes_under_the_running_stage() -> None: + events: list[ProgressEventData] = [] + reporter = ProgressReporter( + emit=events.append, + session_id="session-1", + stage_id="build.test_and_repair", + at_version=2, + activities=[_activity("run", "Run the workflow")], + ) + + reporter.activate("run") + reporter.observe_node("run", NodeEvent(node_id="llm", title="Generate answer", status="running")) + reporter.observe_node("run", NodeEvent(node_id="llm", title="Generate answer", status="succeeded")) + + node = events[-1].execution.activities[-1] + assert (node.id, node.kind, node.parent_id, node.state) == ("node:llm", "node", "run", "done") + + +def test_progress_reporter_context_marks_active_activity_failed_on_exception() -> None: events: list[ProgressEventData] = [] def run_failing_reporter() -> None: @@ -65,7 +93,7 @@ def test_progress_reporter_context_closes_active_step_on_exception() -> None: operation_id="operation-1", stage_id="build.execution", at_version=2, - steps=[TraceStep(id="apply", label="Apply changes", state="pending")], + activities=[_activity("apply", "Apply changes")], ) as reporter: reporter.activate("apply") raise RuntimeError("boom") @@ -73,6 +101,5 @@ def test_progress_reporter_context_closes_active_step_on_exception() -> None: with pytest.raises(RuntimeError, match="boom"): run_failing_reporter() - assert events[-1].trace.status == "error" - assert events[-1].trace.steps[0].state == "stopped" - assert events[-1].trace.steps[0].tone == "error" + assert events[-1].execution.status == "error" + assert events[-1].execution.activities[0].state == "failed" diff --git a/api/tests/unit_tests/core/dify_builder/test_runner.py b/api/tests/unit_tests/core/dify_builder/test_runner.py index 6a6ede40c4c..bf15f4a6bff 100644 --- a/api/tests/unit_tests/core/dify_builder/test_runner.py +++ b/api/tests/unit_tests/core/dify_builder/test_runner.py @@ -5,6 +5,7 @@ Uses a toy two-state registry rather than the real fix-flow handlers (handlers land in Task 6/7). """ +from collections.abc import Callable from datetime import datetime import pytest @@ -194,7 +195,8 @@ def test_message_appends_user_and_assistant_turns_without_advancing_waiting_stat { "turn_id": "turn-1", "stage_id": "fix.await_approval", - "trace": {"status": "completed", "steps": []}, + "execution": {"status": "completed", "activities": []}, + "reasoning_text": None, "reply_text": "reply 1: Make the change smaller", "cards": [], "card_state": None, @@ -220,6 +222,47 @@ def test_message_appends_user_and_assistant_turns_without_advancing_waiting_stat assert messages[0].stage_id == "fix.await_approval" +def test_message_streams_and_persists_reasoning_independently_from_answer(): + class ReasoningAgent(StubAgent): + def __init__(self) -> None: + super().__init__() + self.reasoning_callback: Callable[[str, str], None] | None = None + + def set_reasoning_callback(self, callback: Callable[[str, str], None] | None) -> None: + self.reasoning_callback = callback + + def respond_to_message(self, *args, **kwargs): + assert self.reasoning_callback is not None + self.reasoning_callback("respond-to-message", "Check the current approval gate.") + return super().respond_to_message(*args, **kwargs) + + env, repo = _new_env() + env.agent = ReasoningAgent() + reasoning_events = [] + env.emit_reasoning = reasoning_events.append + session = _session(current_state=PcState.FIX_AWAIT_APPROVAL) + repo.create_session(session, DifyBuilderContext(), []) + + Runner(env, {}).advance( + session.id, + Turn( + action=Action( + kind="message", + payload={"text": "Explain", "client_turn_id": "turn-reasoning"}, + base_version=1, + ), + actor=_actor(), + ), + ) + + assistant = repo.list_conversation(session.id)[-1] + assert assistant.payload["reasoning_text"] == "Check the current approval gate." + assert assistant.payload["reply_text"] == "reply 1: Explain" + assert reasoning_events[0].delta == "Check the current approval gate." + assert reasoning_events[0].span_id == "respond-to-message" + assert reasoning_events[0].session_id == session.id + + def test_message_cognition_receives_prior_turns_and_completed_retry_is_idempotent(): env, repo = _new_env() commits: list[CommittedTransition] = [] diff --git a/api/tests/unit_tests/services/dify_builder/test_advance_task.py b/api/tests/unit_tests/services/dify_builder/test_advance_task.py index 61d1edebfd0..cde0731ab5d 100644 --- a/api/tests/unit_tests/services/dify_builder/test_advance_task.py +++ b/api/tests/unit_tests/services/dify_builder/test_advance_task.py @@ -118,7 +118,7 @@ def test_advance_session_drives_state_forward_emits_events_and_releases_lock( assert last_state_event["conversation_last_seq"] >= 0 # Task 5a: actions are now data-driven per PcState; fix.await_verify's # table entries are run_validation (primary) + revert (destructive). - assert [a["id"] for a in last_state_event["actions"]] == ["run_validation", "revert", "pause"] + assert [a["id"] for a in last_state_event["actions"]] == ["run_validation", "revert"] commit_events = [ev for _sid, ev in events if ev["kind"] == "commit"] assert commit_events, "each successful CAS must be observable before the terminal state" @@ -137,12 +137,12 @@ def test_advance_session_drives_state_forward_emits_events_and_releases_lock( progress_events = [ev for _sid, ev in events if ev["kind"] == "progress"] assert progress_events, "structured cognition and workflow work must publish visible phase progress" - assert progress_events[0]["trace"]["steps"][0] == { + assert progress_events[0]["execution"]["activities"][0] == { "id": "fix-load-failure", "label": "Load the failed run", "state": "active", - "tone": "neutral", - "canvas_event": None, + "kind": "stage", + "parent_id": None, } assert all("prompt" not in str(event).lower() for event in progress_events) @@ -341,7 +341,7 @@ def test_advance_session_generic_exception_publishes_generic_error_and_releases_ assert all("boom" not in str(ev) for _sid, ev in events) stored, _context = repo.get_session(s.id) assert stored.current_state == PcState.FAILED - assert any(ev["kind"] == "progress" and ev["trace"]["status"] == "error" for _sid, ev in events) + assert any(ev["kind"] == "progress" and ev["execution"]["status"] == "error" for _sid, ev in events) assert any(ev["kind"] == "commit" and ev["state"] == "failed" for _sid, ev in events) assert (s.id, "tok-y") in released diff --git a/api/tests/unit_tests/services/dify_builder/test_agent_llm.py b/api/tests/unit_tests/services/dify_builder/test_agent_llm.py index de91d31cccf..c1e1bac9f86 100644 --- a/api/tests/unit_tests/services/dify_builder/test_agent_llm.py +++ b/api/tests/unit_tests/services/dify_builder/test_agent_llm.py @@ -1,6 +1,6 @@ import pytest -from graphon.model_runtime.entities.llm_entities import LLMResultChunk, LLMResultChunkDelta +from graphon.model_runtime.entities.llm_entities import LLMResult, LLMResultChunk, LLMResultChunkDelta, LLMUsage from graphon.model_runtime.entities.message_entities import AssistantPromptMessage from services.dify_builder.agent import llm @@ -57,6 +57,61 @@ def test_invoke_text_stream_yields_model_deltas(): ] +def test_invoke_text_stream_routes_split_think_tags_to_reasoning_callback(): + chunks = [ + LLMResultChunk( + model="model", + prompt_messages=[], + delta=LLMResultChunkDelta( + index=0, + message=AssistantPromptMessage(content=delta), + ), + ) + for delta in ("plan ", "carefullyanswer") + ] + + class _StreamingInstance: + def invoke_llm(self, **_kwargs): + return iter(chunks) + + reasoning: list[str] = [] + answer = "".join( + llm.invoke_text_stream( + _StreamingInstance(), + system="s", + user="u", + on_reasoning=reasoning.append, + ) + ) + + assert answer == "answer" + assert "".join(reasoning) == "plan carefully" + + +def test_invoke_text_routes_native_reasoning_separately_from_answer(): + result = LLMResult( + model="model", + message=AssistantPromptMessage(content="answer"), + usage=LLMUsage.empty_usage(), + reasoning_content="consider the workflow", + ) + + class _NativeReasoningInstance: + def invoke_llm(self, **_kwargs): + return result + + reasoning: list[str] = [] + answer = llm.invoke_text( + _NativeReasoningInstance(), + system="s", + user="u", + on_reasoning=reasoning.append, + ) + + assert answer == "answer" + assert reasoning == ["consider the workflow"] + + def test_invoke_json_clean(): inst = _FakeInstance(['{"a": 1}']) assert llm.invoke_json(inst, system="s", user="u") == {"a": 1} diff --git a/api/tests/unit_tests/services/dify_builder/test_contract_stream.py b/api/tests/unit_tests/services/dify_builder/test_contract_stream.py index 6fc091108a3..c35fdc9dce9 100644 --- a/api/tests/unit_tests/services/dify_builder/test_contract_stream.py +++ b/api/tests/unit_tests/services/dify_builder/test_contract_stream.py @@ -28,7 +28,7 @@ def _event(frame: str) -> dict: return json.loads(lines[1].removeprefix("data: ")) -def test_stream_relays_canvas_progress_and_agent_message_as_typed_events(): +def test_stream_relays_canvas_execution_reasoning_and_agent_message_as_typed_events(): view = {"session_id": "s1", "state": "build.execution", "phase": "modify", "actions": []} canvas = { "kind": "canvas", @@ -50,6 +50,16 @@ def test_stream_relays_canvas_progress_and_agent_message_as_typed_events(): "revision": 1, "stage_id": "build.execution", } + reasoning = { + "kind": "reasoning", + "session_id": "s1", + "operation_id": "operation-1", + "stage_id": "build.execution", + "at_version": 2, + "revision": 2, + "span_id": "build-nodes", + "delta": "I need a start, LLM, and end node.", + } progress = { "kind": "progress", "session_id": "s1", @@ -57,9 +67,9 @@ def test_stream_relays_canvas_progress_and_agent_message_as_typed_events(): "stage_id": "build.execution", "at_version": 2, "revision": 1, - "trace": { + "execution": { "status": "running", - "steps": [ + "activities": [ { "id": "build-generate-graph", "label": "Generate the workflow graph", @@ -73,6 +83,7 @@ def test_stream_relays_canvas_progress_and_agent_message_as_typed_events(): [ json.dumps(canvas).encode(), json.dumps(progress).encode(), + json.dumps(reasoning).encode(), json.dumps(message).encode(), json.dumps(state).encode(), ] @@ -84,6 +95,7 @@ def test_stream_relays_canvas_progress_and_agent_message_as_typed_events(): {"event": "command_started", "data": {"kind": "command_started", **view}}, {"event": "canvas", "data": canvas}, {"event": "progress", "data": progress}, + {"event": "reasoning", "data": reasoning}, {"event": "agent_message", "data": message}, {"event": "state", "data": state}, ] diff --git a/api/tests/unit_tests/services/dify_builder/test_llm_agent.py b/api/tests/unit_tests/services/dify_builder/test_llm_agent.py index 0ac7a3167de..3abaa2c274a 100644 --- a/api/tests/unit_tests/services/dify_builder/test_llm_agent.py +++ b/api/tests/unit_tests/services/dify_builder/test_llm_agent.py @@ -1,7 +1,7 @@ import logging from core.dify_builder.models import Diagnosis -from core.dify_builder.ports import DifyBuilderAgent +from core.dify_builder.ports import DifyBuilderAgent, ReasoningStreamingAgent from services.dify_builder.agent import build as build_mod from services.dify_builder.agent import chat as chat_mod from services.dify_builder.agent import edit as edit_mod @@ -11,7 +11,9 @@ from services.dify_builder.agent.llm_agent import LlmBuilderAgent def test_is_dify_builder_agent(): - assert isinstance(LlmBuilderAgent("t1", None), DifyBuilderAgent) + agent = LlmBuilderAgent("t1", None) + assert isinstance(agent, DifyBuilderAgent) + assert isinstance(agent, ReasoningStreamingAgent) def test_llm_agent_build_methods_delegate_to_build(monkeypatch): @@ -20,7 +22,10 @@ def test_llm_agent_build_methods_delegate_to_build(monkeypatch): monkeypatch.setattr( build, "analyze_goal", - lambda _m, _g: {"fields": [{"key": "k", "label": "K", "type": "text"}], "values": {}}, + lambda _m, _g, _reasoning=None: { + "fields": [{"key": "k", "label": "K", "type": "text"}], + "values": {}, + }, ) agent = LlmBuilderAgent("t1", {}) assert agent.analyze_goal("goal")["fields"][0]["key"] == "k" @@ -29,7 +34,7 @@ def test_llm_agent_build_methods_delegate_to_build(monkeypatch): def test_build_methods_call_build_module_with_resolved_model(monkeypatch): seen = {} - def mock_analyze_goal(model, _goal_text): + def mock_analyze_goal(model, _goal_text, _reasoning=None): seen["m"] = model return {"fields": [], "values": {}} @@ -48,11 +53,11 @@ def test_remaining_build_methods_thread_model_and_tenant_args(monkeypatch): self._tenant_id/self._model_config directly instead).""" seen = {} - def mock_propose_plan_v1(model, requirements): + def mock_propose_plan_v1(model, requirements, _reasoning=None): seen["propose_plan_v1"] = (model, requirements) return [] - def mock_discover_resources(model, tenant_id, plan_items): + def mock_discover_resources(model, tenant_id, plan_items, _reasoning=None): seen["discover_resources"] = (model, tenant_id, plan_items) return [] @@ -64,7 +69,7 @@ def test_remaining_build_methods_thread_model_and_tenant_args(monkeypatch): seen["build_nodes"] = (tenant_id, model_config, plan_items) return [] - def mock_learn_from_build(model, goal_text, requirements, plan_items, built_node_ids): + def mock_learn_from_build(model, goal_text, requirements, plan_items, built_node_ids, _reasoning=None): seen["learn_from_build"] = (model, goal_text, requirements, plan_items, built_node_ids) return "skill" @@ -121,7 +126,7 @@ def test_model_is_resolved_lazily_and_memoized(monkeypatch): def test_fix_methods_call_fix_module_with_resolved_model(monkeypatch): seen = {} - def mock_diagnose(model, fr, g, no): # noqa: ARG001 + def mock_diagnose(model, fr, g, no, _reasoning=None): # noqa: ARG001 seen["m"] = model return "DIAG" @@ -134,7 +139,11 @@ def test_fix_methods_call_fix_module_with_resolved_model(monkeypatch): def test_model_or_none_swallows_resolution_error(monkeypatch): seen = {} - monkeypatch.setattr(fix_mod, "propose_repair", lambda model, d, g: seen.setdefault("m", model) or ([], None)) # noqa: ARG005 + monkeypatch.setattr( + fix_mod, + "propose_repair", + lambda model, d, g, _reasoning=None: seen.setdefault("m", model) or ([], None), # noqa: ARG005 + ) agent = LlmBuilderAgent("t1", None) def boom(): @@ -149,7 +158,7 @@ def test_llm_agent_generate_mock_inputs_delegates(monkeypatch): from services.dify_builder.agent import mock_inputs from services.dify_builder.agent.llm_agent import LlmBuilderAgent - monkeypatch.setattr(mock_inputs, "generate", lambda _m, _s, _p: {"q": "x"}) + monkeypatch.setattr(mock_inputs, "generate", lambda _m, _s, _p, _reasoning=None: {"q": "x"}) assert LlmBuilderAgent("t1", {}).generate_mock_inputs({"variables": []}, {}) == {"q": "x"} @@ -157,9 +166,13 @@ def test_llm_agent_edit_methods_delegate_to_edit(monkeypatch): monkeypatch.setattr( edit_mod, "analyze_impact", - lambda _m, _g, _graph: {"fields": [], "values": {}, "target_node_ids": []}, + lambda _m, _g, _graph, _reasoning=None: { + "fields": [], + "values": {}, + "target_node_ids": [], + }, ) - monkeypatch.setattr(edit_mod, "build_edit_intents", lambda _m, _r, _graph: []) + monkeypatch.setattr(edit_mod, "build_edit_intents", lambda _m, _r, _graph, _reasoning=None: []) agent = LlmBuilderAgent("t1", {}) assert agent.analyze_impact("g", {"nodes": [], "edges": []}) == { "fields": [], @@ -175,15 +188,15 @@ def test_edit_methods_call_edit_module_with_resolved_model(monkeypatch): corresponding edit.* with the resolved model threaded through.""" seen = {} - def mock_analyze_impact(model, goal_text, graph): + def mock_analyze_impact(model, goal_text, graph, _reasoning=None): seen["analyze_impact"] = (model, goal_text, graph) return {"fields": [], "values": {}, "target_node_ids": []} - def mock_propose_edit_plan(model, edit_rules, graph): + def mock_propose_edit_plan(model, edit_rules, graph, _reasoning=None): seen["propose_edit_plan"] = (model, edit_rules, graph) return ["step"] - def mock_build_edit_intents(model, edit_rules, graph): + def mock_build_edit_intents(model, edit_rules, graph, _reasoning=None): seen["build_edit_intents"] = (model, edit_rules, graph) return [] @@ -227,8 +240,9 @@ def test_model_or_none_logs_resolution_failure_once(caplog): def test_message_reply_delegates_stream_callback(monkeypatch): seen = {} - def mock_respond(model, model_config, state, context, history, graph, text, on_delta): + def mock_respond(model, model_config, state, context, history, graph, text, on_delta, on_reasoning): seen["args"] = (model, model_config, state, context, history, graph, text) + assert on_reasoning is None on_delta("first ") on_delta("second") return "first second" diff --git a/api/tests/unit_tests/services/dify_builder/test_service.py b/api/tests/unit_tests/services/dify_builder/test_service.py index cc9d7145bc8..c0ed52bcdc1 100644 --- a/api/tests/unit_tests/services/dify_builder/test_service.py +++ b/api/tests/unit_tests/services/dify_builder/test_service.py @@ -141,7 +141,7 @@ def test_create_fix_session_dispatches_request_fix_and_holds_lock( assert view.state == "fix.diagnose" assert view.canvas_read_only is True - assert view.run_status == "executing" + assert view.run_status == "processing" assert view.app_id == APP_ID assert view.version == 1 @@ -440,7 +440,7 @@ def test_waiting_session_projects_executing_while_worker_lock_is_held( assert token is not None locked_view = service.get_session_view(session.id, _actor()) - assert locked_view.run_status == "thinking" + assert locked_view.run_status == "processing" assert locked_view.canvas_read_only is True lock.release(session.id, token) @@ -543,7 +543,7 @@ def test_paused_session_can_resume_before_classifying_external_draft_changes( assert view.run_status == "paused" assert view.app_revision is not None assert view.app_revision.conflicted is True - assert [action.id for action in view.actions] == ["resume"] + assert view.actions == [] _view, expect_advance = service._prepare_action( session.id, @@ -759,7 +759,6 @@ def test_get_session_view_actions_for_fix_await_decision( ("continue_adjusting", ActionKind.SECONDARY), ("view_changes", ActionKind.SECONDARY), ("revert", ActionKind.DESTRUCTIVE), - ("pause", ActionKind.SECONDARY), ] @@ -774,7 +773,6 @@ def test_get_session_view_actions_for_fix_await_verify( assert [(a.id, a.kind) for a in view.actions] == [ ("run_validation", ActionKind.PRIMARY), ("revert", ActionKind.DESTRUCTIVE), - ("pause", ActionKind.SECONDARY), ] @@ -1265,7 +1263,6 @@ def test_build_execution_actions_and_run_status(service: DifyBuilderService, rep assert [(a.id, a.kind) for a in view.actions] == [ ("run_test", ActionKind.PRIMARY), ("revert", ActionKind.DESTRUCTIVE), - ("pause", ActionKind.SECONDARY), ] @@ -1288,7 +1285,11 @@ def test_session_view_exposes_only_the_current_interactive_card( ConversationItem( seq=1, kind="assistant_turn", - payload={"cards": ["form"], "turn_id": "turn-1", "trace": {"status": "completed"}}, + payload={ + "cards": ["form"], + "turn_id": "turn-1", + "execution": {"status": "completed"}, + }, ), ConversationItem(seq=2, kind="notice", payload={"text": "A later chat turn"}), ] @@ -1319,7 +1320,6 @@ def test_build_review_actions(service: DifyBuilderService, repo: SqlDifyBuilderR "continue_adjusting", "view_changes", "revert", - "pause", ] @@ -1427,7 +1427,6 @@ def test_edit_apply_changes_actions_and_run_status(service: DifyBuilderService, assert [(a.id, a.kind) for a in view.actions] == [ ("run_affected_tests", ActionKind.PRIMARY), ("revert", ActionKind.DESTRUCTIVE), - ("pause", ActionKind.SECONDARY), ] @@ -1440,7 +1439,6 @@ def test_edit_review_actions(service: DifyBuilderService, repo: SqlDifyBuilderRe "continue_adjusting", "view_changes", "revert", - "pause", ] @@ -1513,7 +1511,7 @@ def test_get_session_view_run_status_paused(service: DifyBuilderService, repo: S view = service.get_session_view(s.id, _actor()) assert view.run_status == "paused" assert view.canvas_read_only is False # editable while paused - assert [action.id for action in view.actions] == ["resume"] + assert view.actions == [] def test_recovery_ref_for(): diff --git a/packages/contracts/generated/api/console/dify-builder/types.gen.ts b/packages/contracts/generated/api/console/dify-builder/types.gen.ts index 906664b59c0..f7a0ba5a040 100644 --- a/packages/contracts/generated/api/console/dify-builder/types.gen.ts +++ b/packages/contracts/generated/api/console/dify-builder/types.gen.ts @@ -23,6 +23,9 @@ export type DifyBuilderStreamEventResponse = | ({ event: 'agent_message' } & DifyBuilderAgentMessageEventResponse) + | ({ + event: 'reasoning' + } & DifyBuilderReasoningEventResponse) | ({ event: 'progress' } & DifyBuilderProgressEventResponse) @@ -184,6 +187,11 @@ export type DifyBuilderAgentMessageEventResponse = { event: 'agent_message' } +export type DifyBuilderReasoningEventResponse = { + data: ReasoningEventData + event: 'reasoning' +} + export type DifyBuilderProgressEventResponse = { data: ProgressEventData event: 'progress' @@ -319,10 +327,9 @@ export type RecoveryRef = { export type RunStatus = | 'complete' - | 'executing' | 'failed' | 'paused' - | 'thinking' + | 'processing' | 'waiting_confirmation' | 'waiting_input' @@ -511,14 +518,25 @@ export type AgentMessageEventData = { stage_id: string } +export type ReasoningEventData = { + at_version: number + delta: string + kind?: 'reasoning' + operation_id: string + revision: number + session_id: string + span_id: string + stage_id: string +} + export type ProgressEventData = { at_version: number + execution: ExecutionProgress kind?: 'progress' operation_id: string revision: number session_id: string stage_id: string - trace: Trace } export type DifyBuilderCommitEventData = { @@ -646,9 +664,10 @@ export type PreflightContextCard = { export type AssistantTurnItem = { card_state?: string | null cards?: Array + execution: ExecutionProgress + reasoning_text?: string | null reply_text?: string | null stage_id: string - trace: Trace turn_id: string } @@ -753,9 +772,9 @@ export type CanvasEvent = | 'start_retest' | 'start_test_run' -export type Trace = { - status: string - steps?: Array +export type ExecutionProgress = { + activities?: Array + status: 'completed' | 'error' | 'running' | 'stopped' } export type PreflightIssue = { @@ -811,12 +830,12 @@ export type SummaryRow = { value: string } -export type TraceStep = { - canvas_event?: string | null +export type ExecutionActivity = { id: string + kind?: 'node' | 'stage' label: string - state: string - tone?: string + parent_id?: string | null + state: 'active' | 'done' | 'failed' | 'stopped' } export type PostDifyBuilderAgentPingData = { diff --git a/packages/contracts/generated/api/console/dify-builder/zod.gen.ts b/packages/contracts/generated/api/console/dify-builder/zod.gen.ts index 74b2f87c658..4647240b373 100644 --- a/packages/contracts/generated/api/console/dify-builder/zod.gen.ts +++ b/packages/contracts/generated/api/console/dify-builder/zod.gen.ts @@ -138,16 +138,15 @@ export const zRecoveryRef = z.object({ * * Widened run-status vocabulary (spec §2). * - * ``thinking``/``executing`` => ``canvas_read_only = true`` (WORKING). + * ``processing`` => ``canvas_read_only = true`` (WORKING). * ``waiting_*``/``paused`` => editable (WAITING). * ``complete``/``failed`` => terminal. */ export const zRunStatus = z.enum([ 'complete', - 'executing', 'failed', 'paused', - 'thinking', + 'processing', 'waiting_confirmation', 'waiting_input', ]) @@ -234,6 +233,28 @@ export const zDifyBuilderAgentMessageEventResponse = z.object({ event: z.literal('agent_message'), }) +/** + * ReasoningEventData + */ +export const zReasoningEventData = z.object({ + at_version: z.int(), + delta: z.string(), + kind: z.literal('reasoning').optional().default('reasoning'), + operation_id: z.string(), + revision: z.int(), + session_id: z.string(), + span_id: z.string(), + stage_id: z.string(), +}) + +/** + * DifyBuilderReasoningEventResponse + */ +export const zDifyBuilderReasoningEventResponse = z.object({ + data: zReasoningEventData, + event: z.literal('reasoning'), +}) + /** * ErrorEventData */ @@ -713,22 +734,22 @@ export const zDifyBuilderSummaryConversationItemResponse = z.object({ }) /** - * TraceStep + * ExecutionActivity */ -export const zTraceStep = z.object({ - canvas_event: z.string().nullish(), +export const zExecutionActivity = z.object({ id: z.string(), + kind: z.enum(['node', 'stage']).optional().default('stage'), label: z.string(), - state: z.string(), - tone: z.string().optional().default('neutral'), + parent_id: z.string().nullish(), + state: z.enum(['active', 'done', 'failed', 'stopped']), }) /** - * Trace + * ExecutionProgress */ -export const zTrace = z.object({ - status: z.string(), - steps: z.array(zTraceStep).optional(), +export const zExecutionProgress = z.object({ + activities: z.array(zExecutionActivity).optional(), + status: z.enum(['completed', 'error', 'running', 'stopped']), }) /** @@ -736,12 +757,12 @@ export const zTrace = z.object({ */ export const zProgressEventData = z.object({ at_version: z.int(), + execution: zExecutionProgress, kind: z.literal('progress').optional().default('progress'), operation_id: z.string(), revision: z.int(), session_id: z.string(), stage_id: z.string(), - trace: zTrace, }) /** @@ -758,9 +779,10 @@ export const zDifyBuilderProgressEventResponse = z.object({ export const zAssistantTurnItem = z.object({ card_state: z.string().nullish(), cards: z.array(z.string()).optional(), + execution: zExecutionProgress, + reasoning_text: z.string().nullish(), reply_text: z.string().nullish(), stage_id: z.string(), - trace: zTrace, turn_id: z.string(), }) @@ -1108,6 +1130,7 @@ export const zDifyBuilderStreamEventResponse = z.discriminatedUnion('event', [ zDifyBuilderNodeEventResponse.extend({ event: z.literal('node') }), zDifyBuilderCanvasEventResponse.extend({ event: z.literal('canvas') }), zDifyBuilderAgentMessageEventResponse.extend({ event: z.literal('agent_message') }), + zDifyBuilderReasoningEventResponse.extend({ event: z.literal('reasoning') }), zDifyBuilderProgressEventResponse.extend({ event: z.literal('progress') }), zDifyBuilderCommitEventResponse.extend({ event: z.literal('commit') }), zDifyBuilderStateEventResponse.extend({ event: z.literal('state') }), diff --git a/web/app/components/workflow-app/components/__tests__/workflow-panel.spec.tsx b/web/app/components/workflow-app/components/__tests__/workflow-panel.spec.tsx index ff0ff1da55d..9de25843037 100644 --- a/web/app/components/workflow-app/components/__tests__/workflow-panel.spec.tsx +++ b/web/app/components/workflow-app/components/__tests__/workflow-panel.spec.tsx @@ -167,6 +167,7 @@ const createDifyBuilderRuntime = (enabled: boolean, canEdit: boolean): DifyBuild getCanvasSnapshot: () => ({ nodes: [], edgeCount: 0 }), onSyncDraft: mockSyncDraft, session: { + loadOlderConversation: vi.fn(async () => true), refresh: vi.fn(async () => true), restore: vi.fn(async () => true), reset: vi.fn(), diff --git a/web/app/components/workflow-app/components/dify-builder/__tests__/conversation.spec.tsx b/web/app/components/workflow-app/components/dify-builder/__tests__/conversation.spec.tsx index a0f8a81c237..a4203daff53 100644 --- a/web/app/components/workflow-app/components/dify-builder/__tests__/conversation.spec.tsx +++ b/web/app/components/workflow-app/components/dify-builder/__tests__/conversation.spec.tsx @@ -5,7 +5,11 @@ import { act, render, screen, waitFor, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { createStore, Provider } from 'jotai' import { DifyBuilderConversation } from '../conversation' -import { difyBuilderLiveProgressAtom, difyBuilderStreamingTurnAtom } from '../session/state' +import { + difyBuilderExecutionProgressAtom, + difyBuilderReasoningAtom, + difyBuilderStreamingTurnAtom, +} from '../session/state' const mocks = vi.hoisted(() => ({ fileUploader: vi.fn(), @@ -20,6 +24,10 @@ vi.mock('@/app/components/workflow/store', () => ({ }), })) +vi.mock('@/app/components/base/markdown', () => ({ + Markdown: ({ content }: { content: string }) =>

{content}

, +})) + vi.mock('@/app/components/base/file-uploader', () => ({ FileUploaderInAttachmentWrapper: ({ fileConfig, @@ -295,24 +303,31 @@ describe('DifyBuilderConversation test data form', () => { await user.type(input, 'AI agents') act(() => { - store.set(difyBuilderLiveProgressAtom, { + store.set(difyBuilderExecutionProgressAtom, { sessionId: 'session-1', operationId: 'operation-1', stageId: 'build.test', atVersion: 2, revision: 1, - trace: { + execution: { status: 'running', - steps: [ + activities: [ { id: 'build-check-inputs', label: 'Check test inputs', state: 'active', - tone: 'neutral', }, ], }, }) + store.set(difyBuilderReasoningAtom, { + sessionId: 'session-1', + operationId: 'operation-1', + stageId: 'build.test', + atVersion: 2, + revision: 1, + text: 'Inspecting the supplied test data.', + }) store.set(difyBuilderStreamingTurnAtom, { sessionId: 'session-1', operationId: 'operation-1', @@ -327,6 +342,7 @@ describe('DifyBuilderConversation test data form', () => { expect(input).toHaveValue('AI agents') expect(screen.getByRole('status')).toHaveTextContent('Check test inputs') + expect(screen.getByText('Inspecting the supplied test data.')).toBeInTheDocument() expect(screen.getByText('Checking the workflow')).toBeInTheDocument() }) @@ -385,7 +401,7 @@ describe('DifyBuilderConversation test data form', () => { ) }) - it('keeps a completed trace available with the committed assistant reply', () => { + it('keeps completed execution and reasoning available with the committed reply', () => { render( { payload: { turn_id: 'turn-1', stage_id: 'build.initial_plan', - trace: { + execution: { status: 'completed', - steps: [ + activities: [ { id: 'build-draft-plan', label: 'Draft the workflow plan', state: 'done', - tone: 'success', }, ], }, + reasoning_text: 'The workflow needs one input and one answer node.', reply_text: 'The plan is ready.', }, }, @@ -419,10 +435,122 @@ describe('DifyBuilderConversation test data form', () => { />, ) - expect(screen.getByText('Draft the workflow plan')).toBeInTheDocument() + expect(screen.getAllByText('Draft the workflow plan')).toHaveLength(2) + expect( + screen.getByText('The workflow needs one input and one answer node.'), + ).toBeInTheDocument() expect(screen.getByText('The plan is ready.')).toBeInTheDocument() }) + it('does not render a Thinking section for execution progress alone', () => { + const store = createStore() + store.set(difyBuilderExecutionProgressAtom, { + sessionId: 'session-1', + operationId: 'operation-1', + stageId: 'build.test', + atVersion: 2, + revision: 1, + execution: { + status: 'running', + activities: [ + { + id: 'build-run-test', + label: 'Run the workflow', + state: 'active', + }, + ], + }, + }) + + render( + + + , + ) + + expect(screen.getByRole('status')).toHaveTextContent('Run the workflow') + expect(screen.queryByText(/Thinking|Thought/)).not.toBeInTheDocument() + }) + + it('preserves the execution disclosure state while snapshots update', async () => { + const user = userEvent.setup() + const store = createStore() + store.set(difyBuilderExecutionProgressAtom, { + sessionId: 'session-1', + operationId: 'operation-1', + stageId: 'build.test', + atVersion: 2, + revision: 1, + execution: { + status: 'running', + activities: [ + { + id: 'build-run-test', + label: 'Run the workflow', + state: 'active', + }, + ], + }, + }) + + render( + + + , + ) + + const progressDetails = screen.getByRole('group', { name: 'Run the workflow' }) + const progressToggle = progressDetails.querySelector('summary') + expect(progressToggle).not.toBeNull() + expect(progressDetails).toHaveAttribute('open') + await user.click(progressToggle!) + expect(progressDetails).not.toHaveAttribute('open') + + act(() => { + store.set(difyBuilderExecutionProgressAtom, { + sessionId: 'session-1', + operationId: 'operation-1', + stageId: 'build.test', + atVersion: 2, + revision: 2, + execution: { + status: 'running', + activities: [ + { + id: 'build-run-test', + label: 'Run the workflow', + state: 'done', + }, + { + id: 'node:answer', + label: 'Generate answer', + state: 'active', + kind: 'node', + parent_id: 'build-run-test', + }, + ], + }, + }) + }) + + expect(screen.getByRole('group', { name: 'Generate answer' })).toBe(progressDetails) + expect(progressDetails).not.toHaveAttribute('open') + }) + it('renders failed test results with the destructive card tone', () => { render( { it('streams a Build command and next action without a session-state GET', async () => { const createStarted = createSessionView({ canvas_read_only: true, - run_status: 'executing', + run_status: 'processing', state: 'build.goal', }) const planItem: ConversationItem = { @@ -189,7 +189,7 @@ describe('Dify Builder Build, Edit, and Fix flows', () => { payload: { reply_text: 'Plan reconciled from the server.', stage_id: 'build.plan', - trace: { status: 'completed' }, + execution: { status: 'completed' }, turn_id: 'turn-1', }, seq: 1, @@ -280,7 +280,7 @@ describe('Dify Builder Build, Edit, and Fix flows', () => { it('selects Edit for a connected canvas and sends the opening goal', async () => { const editStarted = createSessionView({ entry_mode: 'edit', - run_status: 'executing', + run_status: 'processing', state: 'edit.capability_check', }) const editReply: ConversationItem = { @@ -289,7 +289,7 @@ describe('Dify Builder Build, Edit, and Fix flows', () => { payload: { reply_text: 'Edit impact analysis is ready.', stage_id: 'edit.impact_analysis', - trace: { status: 'completed' }, + execution: { status: 'completed' }, turn_id: 'turn-edit', }, seq: 1, @@ -328,7 +328,7 @@ describe('Dify Builder Build, Edit, and Fix flows', () => { const fixStarted = createSessionView({ canvas_read_only: true, entry_mode: 'fix', - run_status: 'executing', + run_status: 'processing', state: 'fix.diagnose', }) const recoveredNotice: ConversationItem = { diff --git a/web/app/components/workflow-app/components/dify-builder/__tests__/panel.spec.tsx b/web/app/components/workflow-app/components/dify-builder/__tests__/panel.spec.tsx index 2261b756d07..4c63ab42006 100644 --- a/web/app/components/workflow-app/components/dify-builder/__tests__/panel.spec.tsx +++ b/web/app/components/workflow-app/components/dify-builder/__tests__/panel.spec.tsx @@ -48,7 +48,7 @@ const sessionConversation: ConversationItem[] = [ payload: { turn_id: 'turn-1', stage_id: 'fix.plan', - trace: { status: 'completed' }, + execution: { status: 'completed' }, reply_text: 'I found the failing configuration.', }, }, @@ -389,7 +389,7 @@ describe('DifyBuilderPanel', () => { actions: [], canvas_read_only: true, interrupted: true, - run_status: 'executing', + run_status: 'processing', state: 'build.publish', }) @@ -407,7 +407,7 @@ describe('DifyBuilderPanel', () => { it('shows durable recovery guidance and keeps a paused composer disabled', () => { renderPanel({ ...sessionView, - actions: [{ id: 'resume', label: 'Resume', kind: 'primary' }], + actions: [], recovery: { can_continue: false, can_restart: true, @@ -420,7 +420,7 @@ describe('DifyBuilderPanel', () => { expect(screen.getByRole('alert')).toHaveTextContent( 'The draft changed while Builder was paused.', ) - expect(screen.getByRole('button', { name: 'Resume' })).toBeEnabled() + expect(screen.queryByRole('button', { name: 'Resume' })).not.toBeInTheDocument() expect( screen.getByRole('textbox', { name: 'workflow.difyBuilder.messagePlaceholder' }), ).toBeDisabled() diff --git a/web/app/components/workflow-app/components/dify-builder/__tests__/provider.spec.tsx b/web/app/components/workflow-app/components/dify-builder/__tests__/provider.spec.tsx index 1674c6ca664..50f855c1bcb 100644 --- a/web/app/components/workflow-app/components/dify-builder/__tests__/provider.spec.tsx +++ b/web/app/components/workflow-app/components/dify-builder/__tests__/provider.spec.tsx @@ -105,7 +105,7 @@ const Probe = () => { canvas_read_only: true, conversation_last_seq: -1, interrupted: false, - run_status: 'executing', + run_status: 'processing', session_id: 'session-1', state: 'fix.diagnose', version: 1, diff --git a/web/app/components/workflow-app/components/dify-builder/__tests__/store.spec.ts b/web/app/components/workflow-app/components/dify-builder/__tests__/store.spec.ts index 3b0b8f6b08a..0ff2e0adf01 100644 --- a/web/app/components/workflow-app/components/dify-builder/__tests__/store.spec.ts +++ b/web/app/components/workflow-app/components/dify-builder/__tests__/store.spec.ts @@ -121,14 +121,14 @@ describe('Dify Builder store', () => { expect(store.get(difyBuilderCanStartFixAtom)).toBe(false) store.set(difyBuilderCanvasRefreshingAtom, false) - store.set(difyBuilderSessionViewAtom, createSessionView({ run_status: 'executing' })) + store.set(difyBuilderSessionViewAtom, createSessionView({ run_status: 'processing' })) expect(store.get(difyBuilderCanStartFixAtom)).toBe(false) }) it('makes an interrupted execution resettable without releasing its canvas lock', () => { const store = createStore() - store.set(difyBuilderSessionViewAtom, createSessionView({ run_status: 'executing' })) + store.set(difyBuilderSessionViewAtom, createSessionView({ run_status: 'processing' })) expect(store.get(difyBuilderInteractionBusyAtom)).toBe(true) store.set( @@ -136,7 +136,7 @@ describe('Dify Builder store', () => { createSessionView({ canvas_read_only: true, interrupted: true, - run_status: 'executing', + run_status: 'processing', }), ) @@ -147,7 +147,7 @@ describe('Dify Builder store', () => { it('treats thinking as active and only enables chat at conversational gates', () => { const store = createStore() - store.set(difyBuilderSessionViewAtom, createSessionView({ run_status: 'thinking' })) + store.set(difyBuilderSessionViewAtom, createSessionView({ run_status: 'processing' })) expect(store.get(difyBuilderRunActiveAtom)).toBe(true) expect(store.get(difyBuilderInteractionBusyAtom)).toBe(true) expect(store.get(difyBuilderCanComposeAtom)).toBe(false) diff --git a/web/app/components/workflow-app/components/dify-builder/conversation/conversation-card.tsx b/web/app/components/workflow-app/components/dify-builder/conversation/conversation-card.tsx index fe803361090..79c5ae7cab3 100644 --- a/web/app/components/workflow-app/components/dify-builder/conversation/conversation-card.tsx +++ b/web/app/components/workflow-app/components/dify-builder/conversation/conversation-card.tsx @@ -6,6 +6,7 @@ import type { import { memo } from 'react' import { useTranslation } from 'react-i18next' import { DifyBuilderCardShell } from '../cards/card-shell' +import { ExecutionProgress } from './execution-progress' import { FormCard } from './form-card' import { ResourceCard } from './resource-card' import { Thinking } from './thinking' @@ -47,13 +48,15 @@ export const ConversationCard = memo( } if (item.kind === 'assistant_turn') { - if (!item.payload.reply_text) - return invalidated ? null : - const hasTrace = (item.payload.trace.steps?.length ?? 0) > 0 + const hasExecution = (item.payload.execution.activities?.length ?? 0) > 0 + const hasReasoning = Boolean(item.payload.reasoning_text?.trim()) + const hasReply = Boolean(item.payload.reply_text) + if (!hasExecution && !hasReasoning && !hasReply) return null return (
- {hasTrace ? : null} - + {hasExecution ? : null} + + {item.payload.reply_text ? : null}
) } diff --git a/web/app/components/workflow-app/components/dify-builder/conversation/execution-progress.tsx b/web/app/components/workflow-app/components/dify-builder/conversation/execution-progress.tsx new file mode 100644 index 00000000000..c7c095752b3 --- /dev/null +++ b/web/app/components/workflow-app/components/dify-builder/conversation/execution-progress.tsx @@ -0,0 +1,103 @@ +import type { + ExecutionActivity, + ExecutionProgress as ExecutionProgressData, +} from '@dify/contracts/api/console/dify-builder/types.gen' +import { cn } from '@langgenius/dify-ui/cn' +import { useState } from 'react' + +const ActivityIcon = ({ state }: { state: ExecutionActivity['state'] }) => ( + +) + +const ActivityRow = ({ activity }: { activity: ExecutionActivity }) => ( +
+ + {activity.label} +
+) + +export const ExecutionProgress = ({ execution }: { execution?: ExecutionProgressData | null }) => { + const [open, setOpen] = useState(execution?.status === 'running' || execution?.status === 'error') + const activities = execution?.activities ?? [] + if (!execution || activities.length === 0) return null + + const activityIds = new Set(activities.map((activity) => activity.id)) + const childrenByParent = new Map() + for (const activity of activities) { + if (!activity.parent_id || !activityIds.has(activity.parent_id)) continue + const siblings = childrenByParent.get(activity.parent_id) ?? [] + siblings.push(activity) + childrenByParent.set(activity.parent_id, siblings) + } + const roots = activities.filter( + (activity) => !activity.parent_id || !activityIds.has(activity.parent_id), + ) + + let summaryActivity = activities.at(-1) + for (const activity of activities) { + if (activity.state === 'active') summaryActivity = activity + } + if (!activities.some((activity) => activity.state === 'active')) { + summaryActivity = activities.find((activity) => activity.state === 'failed') ?? summaryActivity + } + + return ( +
setOpen(event.currentTarget.open)} + > + + + + {summaryActivity?.label} + + + + +
    + {roots.map((activity) => { + const children = childrenByParent.get(activity.id) ?? [] + return ( +
  1. + + {children.length > 0 && ( +
      + {children.map((child) => ( +
    1. + +
    2. + ))} +
    + )} +
  2. + ) + })} +
+
+ ) +} diff --git a/web/app/components/workflow-app/components/dify-builder/conversation/index.tsx b/web/app/components/workflow-app/components/dify-builder/conversation/index.tsx index a9bce9e4854..c8769177917 100644 --- a/web/app/components/workflow-app/components/dify-builder/conversation/index.tsx +++ b/web/app/components/workflow-app/components/dify-builder/conversation/index.tsx @@ -34,10 +34,6 @@ export const DifyBuilderConversation = memo( const { t } = useTranslation() const groups = useMemo(() => groupConversationItems(items), [items]) const activeCard = activeInteraction?.card - const hasThinkingTurn = useMemo( - () => items.some((item) => item.kind === 'assistant_turn' && !item.payload.reply_text), - [items], - ) return (
@@ -115,11 +111,7 @@ export const DifyBuilderConversation = memo( onActionValidityChange={onActionValidityChange} /> )} - +
) }, diff --git a/web/app/components/workflow-app/components/dify-builder/conversation/streaming-assistant-turn.tsx b/web/app/components/workflow-app/components/dify-builder/conversation/streaming-assistant-turn.tsx index d2446f53d7f..e3aabd75a31 100644 --- a/web/app/components/workflow-app/components/dify-builder/conversation/streaming-assistant-turn.tsx +++ b/web/app/components/workflow-app/components/dify-builder/conversation/streaming-assistant-turn.tsx @@ -1,34 +1,54 @@ import { useAtomValue } from 'jotai' import { useEffect } from 'react' -import { difyBuilderLiveProgressAtom, difyBuilderStreamingTurnAtom } from '../session/state' +import { useTranslation } from 'react-i18next' +import { + difyBuilderExecutionProgressAtom, + difyBuilderReasoningAtom, + difyBuilderStreamingTurnAtom, +} from '../session/state' import { AssistantReply } from './conversation-card' +import { ExecutionProgress } from './execution-progress' import { Thinking } from './thinking' export const StreamingAssistantTurn = ({ busy, - hasThinkingTurn, onContentChange, }: { busy: boolean - hasThinkingTurn: boolean onContentChange?: () => void }) => { - const liveProgress = useAtomValue(difyBuilderLiveProgressAtom) + const { t } = useTranslation() + const liveExecution = useAtomValue(difyBuilderExecutionProgressAtom) + const reasoning = useAtomValue(difyBuilderReasoningAtom) const streamingTurn = useAtomValue(difyBuilderStreamingTurnAtom) - const trace = liveProgress?.trace - const hasProgress = (trace?.steps?.length ?? 0) > 0 + const activityCount = liveExecution?.execution.activities?.length ?? 0 + const reasoningText = reasoning?.text ?? '' + const replyText = streamingTurn?.replyText ?? '' useEffect(() => { - if (hasProgress || streamingTurn?.replyText) onContentChange?.() - }, [hasProgress, liveProgress?.trace, onContentChange, streamingTurn?.replyText]) + if (activityCount > 0 || reasoningText || replyText) onContentChange?.() + }, [activityCount, onContentChange, reasoningText, replyText]) - if (!hasProgress && !streamingTurn?.replyText) - return busy && !hasThinkingTurn ? : null + if (activityCount === 0 && !reasoningText && !replyText) { + if (!busy) return null + return ( +
+ + + {t(($) => $['common.running'], { ns: 'workflow' })} + +
+ ) + } return (
- {hasProgress ? : null} - {streamingTurn?.replyText ? : null} + {activityCount > 0 ? : null} + + {replyText ? : null}
) } diff --git a/web/app/components/workflow-app/components/dify-builder/conversation/thinking.tsx b/web/app/components/workflow-app/components/dify-builder/conversation/thinking.tsx index 7abeae6829e..c3288b10adb 100644 --- a/web/app/components/workflow-app/components/dify-builder/conversation/thinking.tsx +++ b/web/app/components/workflow-app/components/dify-builder/conversation/thinking.tsx @@ -1,49 +1,38 @@ -import type { Trace } from '@dify/contracts/api/console/dify-builder/types.gen' -import { cn } from '@langgenius/dify-ui/cn' import { useTranslation } from 'react-i18next' +import { Markdown } from '@/app/components/base/markdown' -export const Thinking = ({ trace }: { trace?: Trace }) => { +export const Thinking = ({ + text, + isStreaming = false, +}: { + text?: string | null + isStreaming?: boolean +}) => { const { t } = useTranslation() - const steps = trace?.steps ?? [] - const activeStep = steps.find((step) => step.state === 'active') + if (!text?.trim()) return null + + const label = isStreaming + ? t(($) => $['chat.thinking'], { ns: 'common' }) + : t(($) => $['chat.thought'], { ns: 'common' }) return ( -
step.state === 'active')}> +
- {t(($) => $['difyBuilder.thinking'], { ns: 'workflow' })} + {label} - {steps.length > 0 && ( - - )} + - {activeStep ? ( - - {activeStep.label} - - ) : null} - {steps.length > 0 && ( -
    - {steps.map((step) => ( -
  1. - - {step.label} -
  2. - ))} -
- )} +
+ +
) } diff --git a/web/app/components/workflow-app/components/dify-builder/session/__tests__/fixtures.tsx b/web/app/components/workflow-app/components/dify-builder/session/__tests__/fixtures.tsx index 9eeaf57b1d5..1a0c0ee5e1e 100644 --- a/web/app/components/workflow-app/components/dify-builder/session/__tests__/fixtures.tsx +++ b/web/app/components/workflow-app/components/dify-builder/session/__tests__/fixtures.tsx @@ -2,6 +2,7 @@ import type { DifyBuilderConversationPageResponse, DifyBuilderStreamEventResponse, ProgressEventData, + ReasoningEventData, } from '@dify/contracts/api/console/dify-builder/types.gen' import type { ReactNode } from 'react' import type { ConversationItem, SessionView } from '../../types' @@ -15,7 +16,7 @@ export const createSessionView = (overrides: Partial = {}): Session version: 1, state: 'fix.diagnose', canvas_read_only: true, - run_status: 'executing', + run_status: 'processing', interrupted: false, conversation_last_seq: -1, active_interaction: null, @@ -72,14 +73,13 @@ export const progressEvent = ( stage_id: 'fix.verify', at_version: 2, revision: 1, - trace: { + execution: { status: 'running', - steps: [ + activities: [ { id: 'fix-run-validation', label: 'Run the repaired workflow', state: 'active', - tone: 'neutral', }, ], }, @@ -87,6 +87,24 @@ export const progressEvent = ( }, }) +export const reasoningEvent = ( + delta: string, + overrides: Partial = {}, +): DifyBuilderStreamEventResponse => ({ + event: 'reasoning', + data: { + kind: 'reasoning', + session_id: 'session-1', + operation_id: 'operation-1', + stage_id: 'fix.verify', + at_version: 2, + revision: 1, + span_id: 'diagnose', + delta, + ...overrides, + }, +}) + export async function* streamOf( ...events: DifyBuilderStreamEventResponse[] ): AsyncGenerator { diff --git a/web/app/components/workflow-app/components/dify-builder/session/__tests__/use-session-controller.spec.tsx b/web/app/components/workflow-app/components/dify-builder/session/__tests__/use-session-controller.spec.tsx index cb1c9f02edb..b12a879b67c 100644 --- a/web/app/components/workflow-app/components/dify-builder/session/__tests__/use-session-controller.spec.tsx +++ b/web/app/components/workflow-app/components/dify-builder/session/__tests__/use-session-controller.spec.tsx @@ -1,3 +1,4 @@ +import type { ConversationItem } from '../../types' import { act, waitFor } from '@testing-library/react' import { difyBuilderActiveSessionIdAtom, @@ -387,7 +388,7 @@ describe('useDifyBuilderSessionController lifecycle', () => { it('keeps a thinking restore stream open until the authoritative state arrives', async () => { const thinking = createSessionView({ session_id: 'session-restored', - run_status: 'thinking', + run_status: 'processing', version: 3, }) const terminal = createSessionView({ @@ -413,7 +414,7 @@ describe('useDifyBuilderSessionController lifecycle', () => { it('retries a restore when an active GET stream ends before a state frame', async () => { const executing = createSessionView({ session_id: 'session-restored', - run_status: 'executing', + run_status: 'processing', version: 3, }) const waiting = createSessionView({ @@ -461,7 +462,7 @@ describe('useDifyBuilderSessionController lifecycle', () => { interrupted: true, session_id: 'session-restored', state: 'fix.verify', - run_status: 'executing', + run_status: 'processing', version: 3, }) clientMocks.get.mockResolvedValue(interrupted) @@ -479,7 +480,7 @@ describe('useDifyBuilderSessionController lifecycle', () => { }) it('restores the latest history page and prepends an older complete group', async () => { - const olderGroup = [ + const olderGroup: ConversationItem[] = [ { seq: 0, at_version: 1, @@ -493,7 +494,7 @@ describe('useDifyBuilderSessionController lifecycle', () => { payload: { turn_id: 'turn-1', stage_id: 'build.await_testdata', - trace: { status: 'completed' }, + execution: { status: 'completed' }, reply_text: 'Provide test data.', cards: ['form'], }, diff --git a/web/app/components/workflow-app/components/dify-builder/session/__tests__/use-session-controller.streaming.spec.tsx b/web/app/components/workflow-app/components/dify-builder/session/__tests__/use-session-controller.streaming.spec.tsx index d458ea8848a..96d7d1766b9 100644 --- a/web/app/components/workflow-app/components/dify-builder/session/__tests__/use-session-controller.streaming.spec.tsx +++ b/web/app/components/workflow-app/components/dify-builder/session/__tests__/use-session-controller.streaming.spec.tsx @@ -1,8 +1,10 @@ +import type { ConversationItem } from '../../types' import { act, waitFor } from '@testing-library/react' import { difyBuilderActiveSessionIdAtom, difyBuilderConversationAtom, - difyBuilderLiveProgressAtom, + difyBuilderExecutionProgressAtom, + difyBuilderReasoningAtom, difyBuilderSessionBusyAtom, difyBuilderSessionLastCanvasEventAtom, difyBuilderSessionLastErrorAtom, @@ -17,6 +19,7 @@ import { createSessionView, installAnimationFrameMock, progressEvent, + reasoningEvent, renderSessionHook, stateEvent, streamOf, @@ -74,14 +77,14 @@ describe('useDifyBuilderSessionController streaming', () => { state: 'fix.await_approval', run_status: 'waiting_input', }) - const turn = { + const turn: ConversationItem = { seq: 1, at_version: 4, kind: 'assistant_turn' as const, payload: { turn_id: 'turn-1', stage_id: 'fix.await_approval', - trace: { status: 'completed' }, + execution: { status: 'completed' }, reply_text: 'A smaller repair', }, } @@ -264,13 +267,21 @@ describe('useDifyBuilderSessionController streaming', () => { clientMocks.get.mockResolvedValue(restored) const { result, store } = renderSessionHook() act(() => { - store.set(difyBuilderLiveProgressAtom, { + store.set(difyBuilderExecutionProgressAtom, { sessionId: 'session-old', operationId: 'operation-old', stageId: 'fix.diagnose', atVersion: 2, revision: 1, - trace: { status: 'running', steps: [] }, + execution: { status: 'running', activities: [] }, + }) + store.set(difyBuilderReasoningAtom, { + sessionId: 'session-old', + operationId: 'operation-old', + stageId: 'fix.diagnose', + atVersion: 2, + revision: 1, + text: 'stale reasoning', }) store.set(difyBuilderStreamingTurnAtom, { sessionId: 'session-old', @@ -288,7 +299,8 @@ describe('useDifyBuilderSessionController streaming', () => { expect(await result.current.restore('session-restored')).toBe(true) }) - expect(store.get(difyBuilderLiveProgressAtom)).toBeNull() + expect(store.get(difyBuilderExecutionProgressAtom)).toBeNull() + expect(store.get(difyBuilderReasoningAtom)).toBeNull() expect(store.get(difyBuilderStreamingTurnAtom)).toBeNull() expect(store.get(difyBuilderSessionViewAtom)).toEqual(restored) }) @@ -307,13 +319,15 @@ describe('useDifyBuilderSessionController streaming', () => { act(() => { stream.push(commandStartedEvent(started)) stream.push(progressEvent()) + stream.push(reasoningEvent('Partial reasoning')) stream.push(agentMessageEvent('Partial response')) }) - await waitFor(() => expect(globalThis.requestAnimationFrame).toHaveBeenCalledOnce()) + await waitFor(() => expect(globalThis.requestAnimationFrame).toHaveBeenCalledTimes(2)) act(() => result.current.reset()) - expect(globalThis.cancelAnimationFrame).toHaveBeenCalledOnce() - expect(store.get(difyBuilderLiveProgressAtom)).toBeNull() + expect(globalThis.cancelAnimationFrame).toHaveBeenCalledTimes(2) + expect(store.get(difyBuilderExecutionProgressAtom)).toBeNull() + expect(store.get(difyBuilderReasoningAtom)).toBeNull() expect(store.get(difyBuilderStreamingTurnAtom)).toBeNull() expect(store.get(difyBuilderSessionViewAtom)).toBeNull() @@ -326,7 +340,7 @@ describe('useDifyBuilderSessionController streaming', () => { expect(store.get(difyBuilderSessionLastCanvasEventAtom)).toBeNull() }) - it('projects progress and node status until the matching commit becomes authoritative', async () => { + it('projects authoritative progress snapshots without merging raw node events', async () => { const started = createSessionView() const terminal = createSessionView({ version: 2, @@ -348,7 +362,9 @@ describe('useDifyBuilderSessionController streaming', () => { stream.push(progressEvent()) }) await waitFor(() => - expect(store.get(difyBuilderLiveProgressAtom)?.trace.steps?.[0]?.state).toBe('active'), + expect(store.get(difyBuilderExecutionProgressAtom)?.execution.activities?.[0]?.state).toBe( + 'active', + ), ) act(() => { @@ -370,9 +386,9 @@ describe('useDifyBuilderSessionController streaming', () => { stream.push( progressEvent({ revision: 2, - trace: { + execution: { status: 'running', - steps: [ + activities: [ { id: 'fix-evaluate-validation', label: 'Evaluate validation results', @@ -383,8 +399,8 @@ describe('useDifyBuilderSessionController streaming', () => { }), ) }) - await waitFor(() => expect(store.get(difyBuilderLiveProgressAtom)?.revision).toBe(2)) - expect(store.get(difyBuilderLiveProgressAtom)?.trace.steps).not.toEqual( + await waitFor(() => expect(store.get(difyBuilderExecutionProgressAtom)?.revision).toBe(2)) + expect(store.get(difyBuilderExecutionProgressAtom)?.execution.activities).not.toEqual( expect.arrayContaining([expect.objectContaining({ id: 'node:stale-node' })]), ) @@ -405,59 +421,44 @@ describe('useDifyBuilderSessionController streaming', () => { }, }) }) - await waitFor(() => - expect(store.get(difyBuilderLiveProgressAtom)?.trace.steps).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - id: 'node:llm-node', - label: 'Generate answer', - state: 'active', - }), - ]), - ), + await waitFor(() => expect(store.get(difyBuilderExecutionProgressAtom)?.revision).toBe(2)) + expect(store.get(difyBuilderExecutionProgressAtom)?.execution.activities).not.toEqual( + expect.arrayContaining([expect.objectContaining({ id: 'node:llm-node' })]), ) act(() => { - stream.push({ - event: 'node', - data: { - kind: 'node', - session_id: 'session-1', - operation_id: 'operation-1', - stage_id: 'fix.verify', - at_version: 2, - revision: 4, - node_id: 'llm-node', - title: 'Generate answer', - status: 'success', - error: '', - }, - }) - stream.push({ - event: 'node', - data: { - kind: 'node', - session_id: 'session-1', - operation_id: 'operation-1', - stage_id: 'fix.verify', - at_version: 2, + stream.push( + progressEvent({ revision: 3, - node_id: 'llm-node', - title: 'Generate answer', - status: 'running', - error: '', - }, - }) + execution: { + status: 'running', + activities: [ + { + id: 'fix-evaluate-validation', + label: 'Evaluate validation results', + state: 'active', + }, + { + id: 'node:llm-node', + label: 'Generate answer', + state: 'active', + kind: 'node', + parent_id: 'fix-evaluate-validation', + }, + ], + }, + }), + ) }) await waitFor(() => expect( store - .get(difyBuilderLiveProgressAtom) - ?.trace.steps?.find((step) => step.id === 'node:llm-node')?.state, - ).toBe('done'), + .get(difyBuilderExecutionProgressAtom) + ?.execution.activities?.find((activity) => activity.id === 'node:llm-node')?.state, + ).toBe('active'), ) - expect(store.get(difyBuilderLiveProgressAtom)?.trace.steps).toEqual( + expect(store.get(difyBuilderExecutionProgressAtom)?.execution.activities).toEqual( expect.arrayContaining([ expect.objectContaining({ id: 'fix-evaluate-validation' }), expect.objectContaining({ id: 'node:llm-node' }), @@ -467,7 +468,7 @@ describe('useDifyBuilderSessionController streaming', () => { act(() => { stream.push(progressEvent()) }) - expect(store.get(difyBuilderLiveProgressAtom)?.revision).toBe(2) + expect(store.get(difyBuilderExecutionProgressAtom)?.revision).toBe(3) act(() => { stream.push({ @@ -485,12 +486,12 @@ describe('useDifyBuilderSessionController streaming', () => { }, }) }) - await waitFor(() => expect(store.get(difyBuilderLiveProgressAtom)).toBeNull()) + await waitFor(() => expect(store.get(difyBuilderExecutionProgressAtom)).toBeNull()) act(() => { stream.push(progressEvent()) }) - await waitFor(() => expect(store.get(difyBuilderLiveProgressAtom)).toBeNull()) + await waitFor(() => expect(store.get(difyBuilderExecutionProgressAtom)).toBeNull()) await act(async () => { stream.push(stateEvent(terminal)) @@ -498,7 +499,67 @@ describe('useDifyBuilderSessionController streaming', () => { }) }) - it('clears live progress when the server terminates the command with an error', async () => { + it('buffers reasoning separately and clears transient streams on commit', async () => { + const started = createSessionView() + const terminal = createSessionView({ + version: 2, + state: 'fix.await_decision', + run_status: 'waiting_input', + }) + const stream = createControlledEventStream() + clientMocks.create.mockResolvedValue(stream.iterable) + const { result, store } = renderSessionHook() + + let startPromise!: Promise + act(() => { + startPromise = result.current.startFix('app-1', 'run-1') + }) + await waitFor(() => expect(store.get(difyBuilderSessionBusyAtom)).toBe(true)) + + act(() => { + stream.push(commandStartedEvent(started)) + stream.push(progressEvent()) + stream.push(reasoningEvent('Inspecting ', { revision: 1 })) + stream.push(reasoningEvent('the failure.', { revision: 2 })) + }) + await waitFor(() => expect(globalThis.requestAnimationFrame).toHaveBeenCalledOnce()) + act(flushAnimationFrames) + + expect(store.get(difyBuilderReasoningAtom)?.text).toBe('Inspecting the failure.') + expect(store.get(difyBuilderExecutionProgressAtom)?.execution.activities).toHaveLength(1) + + act(() => { + stream.push(reasoningEvent('stale', { revision: 1 })) + }) + act(flushAnimationFrames) + expect(store.get(difyBuilderReasoningAtom)?.text).toBe('Inspecting the failure.') + + act(() => { + stream.push({ + event: 'commit', + data: { + kind: 'commit', + session_id: 'session-1', + operation_id: 'operation-1', + stage_id: 'fix.verify', + at_version: 2, + version: 2, + state: 'fix.await_decision', + settled: true, + items: [], + }, + }) + }) + await waitFor(() => expect(store.get(difyBuilderReasoningAtom)).toBeNull()) + expect(store.get(difyBuilderExecutionProgressAtom)).toBeNull() + + await act(async () => { + stream.push(stateEvent(terminal)) + expect(await startPromise).toBe(true) + }) + }) + + it('clears execution progress and reasoning when the server terminates with an error', async () => { const stream = createControlledEventStream() clientMocks.create.mockResolvedValue(stream.iterable) const { result, store } = renderSessionHook() @@ -512,15 +573,20 @@ describe('useDifyBuilderSessionController streaming', () => { act(() => { stream.push(commandStartedEvent(createSessionView())) stream.push(progressEvent()) + stream.push(reasoningEvent('Inspecting the failure')) }) - await waitFor(() => expect(store.get(difyBuilderLiveProgressAtom)).not.toBeNull()) + await waitFor(() => expect(globalThis.requestAnimationFrame).toHaveBeenCalledOnce()) + act(flushAnimationFrames) + expect(store.get(difyBuilderExecutionProgressAtom)).not.toBeNull() + expect(store.get(difyBuilderReasoningAtom)).not.toBeNull() await act(async () => { stream.push({ event: 'error', data: { kind: 'error', error: 'step failed' } }) expect(await startPromise).toBe(false) }) - expect(store.get(difyBuilderLiveProgressAtom)).toBeNull() + expect(store.get(difyBuilderExecutionProgressAtom)).toBeNull() + expect(store.get(difyBuilderReasoningAtom)).toBeNull() expect(store.get(difyBuilderSessionLastErrorAtom)).toBe('step failed') }) diff --git a/web/app/components/workflow-app/components/dify-builder/session/projection.ts b/web/app/components/workflow-app/components/dify-builder/session/projection.ts index 883121afad4..2f4f87ba34a 100644 --- a/web/app/components/workflow-app/components/dify-builder/session/projection.ts +++ b/web/app/components/workflow-app/components/dify-builder/session/projection.ts @@ -37,6 +37,6 @@ export const projectCommit = ( version: commit.version, state: commit.state, canvas_read_only: true, - run_status: 'executing', + run_status: 'processing', } } diff --git a/web/app/components/workflow-app/components/dify-builder/session/state.ts b/web/app/components/workflow-app/components/dify-builder/session/state.ts index 74efc2c5321..12e240f74ed 100644 --- a/web/app/components/workflow-app/components/dify-builder/session/state.ts +++ b/web/app/components/workflow-app/components/dify-builder/session/state.ts @@ -1,7 +1,8 @@ import type { CanvasEventData, ConversationItem, - DifyBuilderLiveProgress, + DifyBuilderExecutionProgress, + DifyBuilderReasoning, DifyBuilderStreamingTurn, SessionView, } from '../types' @@ -17,7 +18,8 @@ export const difyBuilderConversationHasMoreAtom = atom(false) export const difyBuilderConversationLoadingAtom = atom(false) export const difyBuilderSessionBusyAtom = atom(false) export const difyBuilderSessionLastErrorAtom = atom('') -export const difyBuilderLiveProgressAtom = atom(null) +export const difyBuilderExecutionProgressAtom = atom(null) +export const difyBuilderReasoningAtom = atom(null) export const difyBuilderStreamingTurnAtom = atom(null) export const difyBuilderSessionLastCanvasEventAtom = atom<{ id: number @@ -32,7 +34,8 @@ export const difyBuilderSessionScopedAtoms = [ difyBuilderConversationLoadingAtom, difyBuilderSessionBusyAtom, difyBuilderSessionLastErrorAtom, - difyBuilderLiveProgressAtom, + difyBuilderExecutionProgressAtom, + difyBuilderReasoningAtom, difyBuilderStreamingTurnAtom, difyBuilderSessionLastCanvasEventAtom, ] as const diff --git a/web/app/components/workflow-app/components/dify-builder/session/use-execution-progress.ts b/web/app/components/workflow-app/components/dify-builder/session/use-execution-progress.ts new file mode 100644 index 00000000000..39658064d53 --- /dev/null +++ b/web/app/components/workflow-app/components/dify-builder/session/use-execution-progress.ts @@ -0,0 +1,70 @@ +'use client' + +import type { ProgressEventData } from '@dify/contracts/api/console/dify-builder/types.gen' +import type { DifyBuilderExecutionProgress } from '../types' +import { useSetAtom, useStore } from 'jotai' +import { useCallback, useEffect, useMemo } from 'react' +import { + difyBuilderActiveSessionIdAtom, + difyBuilderExecutionProgressAtom, + difyBuilderSessionViewAtom, +} from './state' + +const toExecutionProgress = (progress: ProgressEventData): DifyBuilderExecutionProgress => ({ + sessionId: progress.session_id, + operationId: progress.operation_id, + stageId: progress.stage_id, + atVersion: progress.at_version, + revision: progress.revision, + execution: { + ...progress.execution, + activities: [...(progress.execution.activities ?? [])], + }, +}) + +/** Owns low-frequency, replaceable execution snapshots separately from SessionView. */ +export const useDifyBuilderExecutionProgress = () => { + const store = useStore() + const setExecutionProgress = useSetAtom(difyBuilderExecutionProgressAtom) + + const clear = useCallback(() => { + setExecutionProgress(null) + }, [setExecutionProgress]) + + const clearThroughVersion = useCallback( + (sessionId: string, version: number) => { + setExecutionProgress((current) => + current?.sessionId === sessionId && current.atVersion <= version ? null : current, + ) + }, + [setExecutionProgress], + ) + + const enqueue = useCallback( + (progress: ProgressEventData) => { + const view = store.get(difyBuilderSessionViewAtom) + if ( + store.get(difyBuilderActiveSessionIdAtom) !== progress.session_id || + view?.session_id !== progress.session_id || + view.version >= progress.at_version + ) + return + + setExecutionProgress((current) => { + if (current?.sessionId === progress.session_id && current.atVersion > progress.at_version) + return current + if (current?.operationId === progress.operation_id && current.revision >= progress.revision) + return current + return toExecutionProgress(progress) + }) + }, + [setExecutionProgress, store], + ) + + useEffect(() => clear, [clear]) + + return useMemo( + () => ({ clear, clearThroughVersion, enqueue }), + [clear, clearThroughVersion, enqueue], + ) +} diff --git a/web/app/components/workflow-app/components/dify-builder/session/use-live-progress.ts b/web/app/components/workflow-app/components/dify-builder/session/use-live-progress.ts deleted file mode 100644 index 1cecce7242b..00000000000 --- a/web/app/components/workflow-app/components/dify-builder/session/use-live-progress.ts +++ /dev/null @@ -1,149 +0,0 @@ -'use client' - -import type { - NodeEventData, - ProgressEventData, - TraceStep, -} from '@dify/contracts/api/console/dify-builder/types.gen' -import type { DifyBuilderLiveProgress } from '../types' -import { useSetAtom, useStore } from 'jotai' -import { useCallback, useEffect, useMemo, useRef } from 'react' -import { - difyBuilderActiveSessionIdAtom, - difyBuilderLiveProgressAtom, - difyBuilderSessionViewAtom, -} from './state' - -const toLiveProgress = (progress: ProgressEventData): DifyBuilderLiveProgress => ({ - sessionId: progress.session_id, - operationId: progress.operation_id, - stageId: progress.stage_id, - atVersion: progress.at_version, - revision: progress.revision, - trace: { ...progress.trace, steps: [...(progress.trace.steps ?? [])] }, -}) - -const nodeStep = (node: NodeEventData): TraceStep => { - const succeeded = node.status === 'success' || node.status === 'succeeded' - const failed = node.status === 'failed' - return { - id: `node:${node.node_id}`, - label: node.title || node.node_id, - state: succeeded - ? 'done' - : failed - ? 'stopped' - : node.status === 'running' - ? 'active' - : 'pending', - tone: failed ? 'error' : succeeded ? 'success' : 'neutral', - } -} - -/** Owns low-frequency, replaceable progress snapshots separately from SessionView. */ -export const useDifyBuilderLiveProgress = () => { - const store = useStore() - const setLiveProgress = useSetAtom(difyBuilderLiveProgressAtom) - const nodeRevisionsRef = useRef(new Map()) - - const clear = useCallback(() => { - nodeRevisionsRef.current.clear() - setLiveProgress(null) - }, [setLiveProgress]) - - const clearThroughVersion = useCallback( - (sessionId: string, version: number) => { - setLiveProgress((current) => { - if (current?.sessionId === sessionId && current.atVersion <= version) { - nodeRevisionsRef.current.clear() - return null - } - return current - }) - }, - [setLiveProgress], - ) - - const enqueue = useCallback( - (progress: ProgressEventData) => { - const view = store.get(difyBuilderSessionViewAtom) - if ( - store.get(difyBuilderActiveSessionIdAtom) !== progress.session_id || - view?.session_id !== progress.session_id || - view.version >= progress.at_version - ) - return - - let operationChanged = false - setLiveProgress((current) => { - if (current?.sessionId === progress.session_id && current.atVersion > progress.at_version) - return current - if (current?.operationId === progress.operation_id && current.revision >= progress.revision) - return current - const next = toLiveProgress(progress) - if ( - current?.operationId !== progress.operation_id || - current.atVersion !== progress.at_version - ) { - operationChanged = true - return next - } - - const backendStepIds = new Set(next.trace.steps?.map((step) => step.id)) - const observedNodeSteps = current.trace.steps?.filter( - (step) => step.id.startsWith('node:') && !backendStepIds.has(step.id), - ) - if (!observedNodeSteps?.length) return next - return { - ...next, - trace: { ...next.trace, steps: [...(next.trace.steps ?? []), ...observedNodeSteps] }, - } - }) - if (operationChanged) nodeRevisionsRef.current.clear() - }, - [setLiveProgress, store], - ) - - const enqueueNode = useCallback( - (node: NodeEventData) => { - const view = store.get(difyBuilderSessionViewAtom) - if ( - store.get(difyBuilderActiveSessionIdAtom) !== node.session_id || - view?.session_id !== node.session_id || - view.version >= node.at_version - ) - return - - const nextStep = nodeStep(node) - const revisionKey = `${node.session_id}:${node.operation_id}:${node.node_id}` - if ((nodeRevisionsRef.current.get(revisionKey) ?? 0) >= node.revision) return - - let accepted = false - setLiveProgress((current) => { - if ( - !current || - current.sessionId !== node.session_id || - current.operationId !== node.operation_id || - current.stageId !== node.stage_id || - current.atVersion !== node.at_version - ) - return current - accepted = true - const steps = [...(current.trace.steps ?? [])] - const stepIndex = steps.findIndex((step) => step.id === nextStep.id) - if (stepIndex === -1) steps.push(nextStep) - else steps[stepIndex] = nextStep - return { ...current, trace: { ...current.trace, steps } } - }) - if (accepted) nodeRevisionsRef.current.set(revisionKey, node.revision) - }, - [setLiveProgress, store], - ) - - useEffect(() => clear, [clear]) - - return useMemo( - () => ({ clear, clearThroughVersion, enqueue, enqueueNode }), - [clear, clearThroughVersion, enqueue, enqueueNode], - ) -} diff --git a/web/app/components/workflow-app/components/dify-builder/session/use-reasoning-buffer.ts b/web/app/components/workflow-app/components/dify-builder/session/use-reasoning-buffer.ts new file mode 100644 index 00000000000..624bcef00ab --- /dev/null +++ b/web/app/components/workflow-app/components/dify-builder/session/use-reasoning-buffer.ts @@ -0,0 +1,140 @@ +'use client' + +import type { ReasoningEventData } from '@dify/contracts/api/console/dify-builder/types.gen' +import type { DifyBuilderReasoning } from '../types' +import { useSetAtom, useStore } from 'jotai' +import { useCallback, useEffect, useMemo, useRef } from 'react' +import { + difyBuilderActiveSessionIdAtom, + difyBuilderReasoningAtom, + difyBuilderSessionViewAtom, +} from './state' + +type ScheduledFrame = + | { id: number; kind: 'animation-frame' } + | { id: ReturnType; kind: 'timeout' } + +const scheduleFrame = (callback: () => void): ScheduledFrame => { + if (typeof globalThis.requestAnimationFrame === 'function') { + return { + id: globalThis.requestAnimationFrame(callback), + kind: 'animation-frame', + } + } + return { + id: globalThis.setTimeout(callback, 16), + kind: 'timeout', + } +} + +const cancelFrame = (frame: ScheduledFrame) => { + if (frame.kind === 'animation-frame') { + globalThis.cancelAnimationFrame?.(frame.id) + return + } + globalThis.clearTimeout(frame.id) +} + +const toReasoning = (event: ReasoningEventData): DifyBuilderReasoning => ({ + sessionId: event.session_id, + operationId: event.operation_id, + stageId: event.stage_id, + atVersion: event.at_version, + revision: event.revision, + text: event.delta, +}) + +const isSameReasoning = (left: DifyBuilderReasoning, right: DifyBuilderReasoning) => + left.sessionId === right.sessionId && + left.operationId === right.operationId && + left.atVersion === right.atVersion + +/** Buffers token-frequency reasoning deltas into a small, isolated atom. */ +export const useDifyBuilderReasoningBuffer = () => { + const store = useStore() + const setReasoning = useSetAtom(difyBuilderReasoningAtom) + const pendingReasoningRef = useRef(null) + const scheduledFrameRef = useRef(null) + + const flush = useCallback(() => { + scheduledFrameRef.current = null + const pending = pendingReasoningRef.current + pendingReasoningRef.current = null + if (!pending) return + + const view = store.get(difyBuilderSessionViewAtom) + if ( + store.get(difyBuilderActiveSessionIdAtom) !== pending.sessionId || + view?.session_id !== pending.sessionId || + view.version >= pending.atVersion + ) + return + + setReasoning((current) => { + if (!current || !isSameReasoning(current, pending)) + return current && current.atVersion > pending.atVersion ? current : pending + if (current.revision >= pending.revision) return current + return { + ...pending, + text: `${current.text}${pending.text}`, + } + }) + }, [setReasoning, store]) + + const cancelPendingFrame = useCallback(() => { + if (!scheduledFrameRef.current) return + cancelFrame(scheduledFrameRef.current) + scheduledFrameRef.current = null + }, []) + + const clear = useCallback(() => { + cancelPendingFrame() + pendingReasoningRef.current = null + setReasoning(null) + }, [cancelPendingFrame, setReasoning]) + + const clearThroughVersion = useCallback( + (sessionId: string, version: number) => { + const pending = pendingReasoningRef.current + if (pending?.sessionId === sessionId && pending.atVersion <= version) { + cancelPendingFrame() + pendingReasoningRef.current = null + } + setReasoning((current) => + current?.sessionId === sessionId && current.atVersion <= version ? null : current, + ) + }, + [cancelPendingFrame, setReasoning], + ) + + const enqueue = useCallback( + (event: ReasoningEventData) => { + if (!event.delta) return + const view = store.get(difyBuilderSessionViewAtom) + if ( + store.get(difyBuilderActiveSessionIdAtom) !== event.session_id || + view?.session_id !== event.session_id || + view.version >= event.at_version + ) + return + + const next = toReasoning(event) + const pending = pendingReasoningRef.current + if (pending && isSameReasoning(pending, next) && pending.revision >= next.revision) return + if (pending && !isSameReasoning(pending, next) && pending.atVersion > next.atVersion) return + pendingReasoningRef.current = + pending && isSameReasoning(pending, next) + ? { ...next, text: `${pending.text}${next.text}` } + : next + if (!scheduledFrameRef.current) scheduledFrameRef.current = scheduleFrame(flush) + }, + [flush, store], + ) + + useEffect(() => clear, [clear]) + + return useMemo( + () => ({ clear, clearThroughVersion, enqueue }), + [clear, clearThroughVersion, enqueue], + ) +} diff --git a/web/app/components/workflow-app/components/dify-builder/session/use-session-controller.ts b/web/app/components/workflow-app/components/dify-builder/session/use-session-controller.ts index eac0571264e..956d6d36c8f 100644 --- a/web/app/components/workflow-app/components/dify-builder/session/use-session-controller.ts +++ b/web/app/components/workflow-app/components/dify-builder/session/use-session-controller.ts @@ -42,12 +42,12 @@ import { difyBuilderSessionLastErrorAtom, difyBuilderSessionViewAtom, } from './state' -import { useDifyBuilderLiveProgress } from './use-live-progress' +import { useDifyBuilderExecutionProgress } from './use-execution-progress' +import { useDifyBuilderReasoningBuffer } from './use-reasoning-buffer' import { useDifyBuilderStreamingTurnBuffer } from './use-streaming-turn-buffer' const MAX_RECONCILE_ATTEMPTS = 3 -const isActiveRunStatus = (status: SessionView['run_status']) => - status === 'thinking' || status === 'executing' +const isActiveRunStatus = (status: SessionView['run_status']) => status === 'processing' const isActiveView = (view: SessionView) => isActiveRunStatus(view.run_status) && !view.interrupted /** @@ -66,7 +66,8 @@ export function useDifyBuilderSessionController(): DifyBuilderSessionController const setLastError = useSetAtom(difyBuilderSessionLastErrorAtom) const setLastCanvasEvent = useSetAtom(difyBuilderSessionLastCanvasEventAtom) const setIsBusy = useSetAtom(difyBuilderSessionBusyAtom) - const liveProgress = useDifyBuilderLiveProgress() + const executionProgress = useDifyBuilderExecutionProgress() + const reasoningBuffer = useDifyBuilderReasoningBuffer() const streamingTurnBuffer = useDifyBuilderStreamingTurnBuffer() const abortRef = useRef(null) const canvasEventIdRef = useRef(0) @@ -184,11 +185,12 @@ export function useDifyBuilderSessionController(): DifyBuilderSessionController canvasCursorRef.current.at_version <= commit.version ) canvasCursorRef.current = undefined - liveProgress.clearThroughVersion(commit.session_id, commit.version) + executionProgress.clearThroughVersion(commit.session_id, commit.version) + reasoningBuffer.clearThroughVersion(commit.session_id, commit.version) streamingTurnBuffer.clearThroughVersion(commit.session_id, commit.version) return hasConversationGap ? newSequences.at(-1) : undefined }, - [liveProgress, setConversation, setView, store, streamingTurnBuffer], + [executionProgress, reasoningBuffer, setConversation, setView, store, streamingTurnBuffer], ) const clearSession = useCallback( @@ -197,11 +199,13 @@ export function useDifyBuilderSessionController(): DifyBuilderSessionController setView((current) => (current?.session_id === sessionId ? null : current)) setConversation([]) setConversationHasMore(false) - liveProgress.clear() + executionProgress.clear() + reasoningBuffer.clear() streamingTurnBuffer.clear() }, [ - liveProgress, + executionProgress, + reasoningBuffer, setActiveSessionId, setConversation, setConversationHasMore, @@ -231,7 +235,8 @@ export function useDifyBuilderSessionController(): DifyBuilderSessionController const { kind: _kind, ...stateView } = event.data const stateApplied = applySessionView(stateView) if (stateApplied) { - liveProgress.clear() + executionProgress.clear() + reasoningBuffer.clear() streamingTurnBuffer.clear() } const historyApplied = await syncConversation( @@ -271,13 +276,18 @@ export function useDifyBuilderSessionController(): DifyBuilderSessionController } if (event.event === 'node') { - liveProgress.enqueueNode(event.data) + return false + } + + if (event.event === 'reasoning') { + outcome.sessionId = event.data.session_id + reasoningBuffer.enqueue(event.data) return false } if (event.event === 'progress') { outcome.sessionId = event.data.session_id - liveProgress.enqueue(event.data) + executionProgress.enqueue(event.data) return false } @@ -310,7 +320,8 @@ export function useDifyBuilderSessionController(): DifyBuilderSessionController outcome.terminalRunStatus = stateView.run_status const stateApplied = applySessionView(stateView) if (stateApplied) { - liveProgress.clear() + executionProgress.clear() + reasoningBuffer.clear() streamingTurnBuffer.clear() } const historyApplied = await syncConversation( @@ -325,7 +336,8 @@ export function useDifyBuilderSessionController(): DifyBuilderSessionController if (event.event === 'error') { outcome.terminalEvent = 'error' outcome.terminalError = streamErrorMessage(event.data) - liveProgress.clear() + executionProgress.clear() + reasoningBuffer.clear() streamingTurnBuffer.clear() setLastError(outcome.terminalError) return true @@ -349,7 +361,8 @@ export function useDifyBuilderSessionController(): DifyBuilderSessionController [ applyCommit, applySessionView, - liveProgress, + executionProgress, + reasoningBuffer, setLastCanvasEvent, setLastError, store, @@ -418,7 +431,8 @@ export function useDifyBuilderSessionController(): DifyBuilderSessionController ? startingView.version : undefined abortRef.current?.abort() - liveProgress.clear() + executionProgress.clear() + reasoningBuffer.clear() streamingTurnBuffer.clear() const controller = new AbortController() abortRef.current = controller @@ -464,7 +478,8 @@ export function useDifyBuilderSessionController(): DifyBuilderSessionController } if (outcome.transportError) { - liveProgress.clear() + executionProgress.clear() + reasoningBuffer.clear() streamingTurnBuffer.clear() setLastError(outcome.transportError) const reconciled = sessionId ? await reconcileSession(sessionId, controller) : undefined @@ -489,7 +504,8 @@ export function useDifyBuilderSessionController(): DifyBuilderSessionController } if (expectTerminalEvent) { - liveProgress.clear() + executionProgress.clear() + reasoningBuffer.clear() streamingTurnBuffer.clear() setLastError(UNEXPECTED_EOF_ERROR) const reconciled = sessionId ? await reconcileSession(sessionId, controller) : undefined @@ -508,7 +524,8 @@ export function useDifyBuilderSessionController(): DifyBuilderSessionController return true } catch (error) { if (controller.signal.aborted) return false - liveProgress.clear() + executionProgress.clear() + reasoningBuffer.clear() streamingTurnBuffer.clear() const message = requestErrorMessage(error) setLastError(message) @@ -524,7 +541,8 @@ export function useDifyBuilderSessionController(): DifyBuilderSessionController }, [ consumeStream, - liveProgress, + executionProgress, + reasoningBuffer, reconcileSession, setActiveSessionId, setConversation, @@ -584,7 +602,8 @@ export function useDifyBuilderSessionController(): DifyBuilderSessionController if (!normalizedSessionId || store.get(difyBuilderSessionBusyAtom)) return false abortRef.current?.abort() - liveProgress.clear() + executionProgress.clear() + reasoningBuffer.clear() streamingTurnBuffer.clear() setConversationLoading(false) const controller = new AbortController() @@ -620,7 +639,8 @@ export function useDifyBuilderSessionController(): DifyBuilderSessionController }, [ clearSession, - liveProgress, + executionProgress, + reasoningBuffer, reconcileSession, setActiveSessionId, setIsBusy, @@ -734,7 +754,8 @@ export function useDifyBuilderSessionController(): DifyBuilderSessionController const reset = useCallback(() => { abortRef.current?.abort() abortRef.current = null - liveProgress.clear() + executionProgress.clear() + reasoningBuffer.clear() streamingTurnBuffer.clear() pendingMessageRef.current = null setActiveSessionId(null) @@ -747,7 +768,8 @@ export function useDifyBuilderSessionController(): DifyBuilderSessionController canvasCursorRef.current = undefined setIsBusy(false) }, [ - liveProgress, + executionProgress, + reasoningBuffer, setActiveSessionId, setConversation, setConversationHasMore, diff --git a/web/app/components/workflow-app/components/dify-builder/store.ts b/web/app/components/workflow-app/components/dify-builder/store.ts index b9f936e0995..b776bcc3224 100644 --- a/web/app/components/workflow-app/components/dify-builder/store.ts +++ b/web/app/components/workflow-app/components/dify-builder/store.ts @@ -26,7 +26,7 @@ export type DifyBuilderRuntime = { const EMPTY_ACTIONS: Action[] = [] const isTerminalStatus = (status?: string) => status === 'complete' || status === 'failed' -const isActiveStatus = (status?: string) => status === 'thinking' || status === 'executing' +const isActiveStatus = (status?: string) => status === 'processing' const canContinueConversation = (status?: string) => status === 'waiting_input' || status === 'waiting_confirmation' diff --git a/web/app/components/workflow-app/components/dify-builder/types.ts b/web/app/components/workflow-app/components/dify-builder/types.ts index 965b2fc436c..2f9bbea4347 100644 --- a/web/app/components/workflow-app/components/dify-builder/types.ts +++ b/web/app/components/workflow-app/components/dify-builder/types.ts @@ -7,9 +7,9 @@ import type { DifyBuilderSessionViewResponse, Action as GeneratedAction, CanvasEventData as GeneratedCanvasEventData, + ExecutionProgress as GeneratedExecutionProgress, FormField as GeneratedFormField, SessionModel as GeneratedSessionModel, - Trace as GeneratedTrace, } from '@dify/contracts/api/console/dify-builder/types.gen' export type Action = GeneratedAction @@ -32,13 +32,22 @@ export type DifyBuilderStreamingTurn = { replyText: string } -export type DifyBuilderLiveProgress = { +export type DifyBuilderExecutionProgress = { sessionId: string operationId: string stageId: string atVersion: number revision: number - trace: GeneratedTrace + execution: GeneratedExecutionProgress +} + +export type DifyBuilderReasoning = { + sessionId: string + operationId: string + stageId: string + atVersion: number + revision: number + text: string } export type DifyBuilderSessionController = { diff --git a/web/app/components/workflow-app/components/workflow-header/__tests__/index.spec.tsx b/web/app/components/workflow-app/components/workflow-header/__tests__/index.spec.tsx index bea7552a1a5..b36687bc44a 100644 --- a/web/app/components/workflow-app/components/workflow-header/__tests__/index.spec.tsx +++ b/web/app/components/workflow-app/components/workflow-header/__tests__/index.spec.tsx @@ -88,9 +88,9 @@ describe('WorkflowHeader', () => { renderHeader(true, { app_id: 'app-1', canvas_read_only: true, - conversation: [], + conversation_last_seq: -1, interrupted: true, - run_status: 'executing', + run_status: 'processing', session_id: 'session-1', state: 'build.publish', version: 1, diff --git a/web/app/components/workflow/header/checklist/__tests__/index.spec.tsx b/web/app/components/workflow/header/checklist/__tests__/index.spec.tsx index 30e15900b43..00dcebe86e6 100644 --- a/web/app/components/workflow/header/checklist/__tests__/index.spec.tsx +++ b/web/app/components/workflow/header/checklist/__tests__/index.spec.tsx @@ -112,6 +112,7 @@ const renderChecklist = ( getCanvasSnapshot: () => ({ nodes: [], edgeCount: 0 }), onSyncDraft: mockSyncDraft, session: { + loadOlderConversation: vi.fn(async () => true), refresh: vi.fn(async () => true), restore: vi.fn(async () => true), reset: vi.fn(), diff --git a/web/service/client.spec.ts b/web/service/client.spec.ts index 55ba8a2a1fb..445b7849079 100644 --- a/web/service/client.spec.ts +++ b/web/service/client.spec.ts @@ -488,18 +488,19 @@ describe('consoleQuery transport context', () => { }) it('should expose Dify Builder restore as a typed streamed query', async () => { - const snapshot = { + const started = { app_id: 'app-1', canvas_read_only: true, - conversation: [], + conversation_last_seq: -1, interrupted: false, - run_status: 'executing', + kind: 'command_started' as const, + run_status: 'processing', session_id: 'session-1', state: 'fix.diagnose', version: 1, } as const const terminal = { - ...snapshot, + ...started, canvas_read_only: false, kind: 'state' as const, run_status: 'waiting_input' as const, @@ -510,7 +511,7 @@ describe('consoleQuery transport context', () => { new Response( [ 'event: message', - `data: ${JSON.stringify({ event: 'snapshot', data: snapshot })}`, + `data: ${JSON.stringify({ event: 'command_started', data: started })}`, '', 'event: message', `data: ${JSON.stringify({ event: 'state', data: terminal })}`, @@ -525,7 +526,7 @@ describe('consoleQuery transport context', () => { ) const consoleQuery = await loadConsoleQueryWithRequest(request) const queryOptions = - consoleQuery.difyBuilder.sessions.bySessionId.get.experimental_streamedOptions({ + consoleQuery.difyBuilder.sessions.bySessionId.stream.get.experimental_streamedOptions({ input: { params: { session_id: 'session-1' } }, }) @@ -536,11 +537,11 @@ describe('consoleQuery transport context', () => { expectTypeOf(events[0]!).toMatchTypeOf() expect(events).toEqual([ - { event: 'snapshot', data: snapshot }, + { event: 'command_started', data: started }, { event: 'state', data: terminal }, ]) expect(request).toHaveBeenCalledWith( - expect.stringContaining('/dify-builder/sessions/session-1'), + expect.stringContaining('/dify-builder/sessions/session-1/stream'), expect.any(Object), expect.objectContaining({ fetchCompat: true }), )