mirror of
https://github.com/langgenius/dify.git
synced 2026-10-04 13:47:55 +08:00
copilot: Edit service wiring + create_edit_session + merged registry
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
1 parent
606cff1964
commit
ef255c4dde
6 files changed
+181
-1
No files matched your search
@@ -91,6 +91,13 @@ def _create(body, actor: Actor) -> tuple[dict, int]:
|
||||
goal_text=body.get("goal_text", ""),
|
||||
)
|
||||
)
|
||||
elif body.get("scenario") == "edit":
|
||||
result, status = _respond(
|
||||
lambda: build_service().create_edit_session(
|
||||
app_id=body.get("app_id", ""),
|
||||
actor=actor,
|
||||
)
|
||||
)
|
||||
else:
|
||||
result, status = _respond(
|
||||
lambda: build_service().create_fix_session(
|
||||
|
||||
@@ -172,6 +172,40 @@ _ACTIONS_FOR: dict[PcState, list[UiAction]] = {
|
||||
UiAction(id="retry_after_revert", label="Retry", kind=ActionKind.PRIMARY,
|
||||
next_state="build.initial_plan"),
|
||||
],
|
||||
# Edit (Slice 3). next_state/canvas_event carry the frozen state-map hints.
|
||||
PcState.EDIT_CAPABILITY_CHECK: [
|
||||
UiAction(id="send_edit_goal", label="Send", kind=ActionKind.PRIMARY,
|
||||
next_state="edit.impact_analysis"),
|
||||
],
|
||||
PcState.EDIT_IMPACT_ANALYSIS: [
|
||||
UiAction(id="submit_edit_rules", label="Submit rules", kind=ActionKind.PRIMARY,
|
||||
next_state="edit.plan_approval"),
|
||||
],
|
||||
PcState.EDIT_PLAN_APPROVAL: [
|
||||
UiAction(id="approve_plan", label="Approve changes", kind=ActionKind.PRIMARY,
|
||||
next_state="edit.apply_changes", canvas_event="create_checkpoint"),
|
||||
],
|
||||
PcState.EDIT_APPLY_CHANGES: [
|
||||
UiAction(id="run_affected_tests", label="Run affected tests", kind=ActionKind.PRIMARY,
|
||||
next_state="edit.test_affected_paths", canvas_event="start_test_run"),
|
||||
UiAction(id="revert", label="Revert", kind=ActionKind.DESTRUCTIVE,
|
||||
next_state="edit.reverted", canvas_event="revert_checkpoint"),
|
||||
],
|
||||
PcState.EDIT_REVIEW: [
|
||||
UiAction(id="publish_workflow", label="Publish", kind=ActionKind.PRIMARY,
|
||||
next_state="edit.publish", canvas_event="publish_workflow"),
|
||||
UiAction(id="keep_draft", label="Keep draft", kind=ActionKind.SECONDARY,
|
||||
next_state="edit.publish", canvas_event="cancel_publish"),
|
||||
UiAction(id="continue_adjusting", label="Continue adjusting", kind=ActionKind.SECONDARY,
|
||||
next_state="edit.impact_analysis", canvas_event="cancel_publish"),
|
||||
UiAction(id="view_changes", label="View changes", kind=ActionKind.SECONDARY),
|
||||
UiAction(id="revert", label="Revert", kind=ActionKind.DESTRUCTIVE,
|
||||
next_state="edit.reverted", canvas_event="revert_checkpoint"),
|
||||
],
|
||||
PcState.EDIT_REVERTED: [
|
||||
UiAction(id="retry_after_revert", label="Retry", kind=ActionKind.PRIMARY,
|
||||
next_state="edit.plan_approval"),
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@@ -315,6 +349,24 @@ class WorkflowCopilotService:
|
||||
self.dispatch(s.id, Action(kind="send_goal", payload={"text": goal_text}, base_version=1), actor)
|
||||
return self.get_session_view(s.id, actor)
|
||||
|
||||
def create_edit_session(self, app_id: str, actor: Actor) -> SessionView:
|
||||
"""Start an Edit session at edit.capability_check WITHOUT dispatching.
|
||||
Mock 02-edit.txt:3 -- on open, show history + composer only; do not read
|
||||
or lock the canvas. The graph read + impact analysis happen on the first
|
||||
send_edit_goal action, not here. Unlike create_build_session, this takes
|
||||
no goal and enqueues no advance -- the session simply rests, canvas
|
||||
editable, until the user sends their change request."""
|
||||
fc = CopilotContext()
|
||||
s = Session(
|
||||
app_id=app_id,
|
||||
tenant_id=actor.tenant_id,
|
||||
owner_account_id=actor.account_id,
|
||||
entry_mode=EntryMode.EDIT,
|
||||
current_state=PcState.EDIT_CAPABILITY_CHECK,
|
||||
)
|
||||
self._repo.create_session(s, fc, []) # empty conversation; assigns s.id, s.version = 1
|
||||
return self.get_session_view(s.id, actor)
|
||||
|
||||
def get_session_view(self, session_id: str, actor: Actor) -> SessionView:
|
||||
"""Port of Go ``GetSessionView``."""
|
||||
s, _fc = self._repo.get_session(session_id) # raises NotFoundError if absent
|
||||
|
||||
@@ -27,6 +27,7 @@ from sqlalchemy.orm import sessionmaker
|
||||
from configs import dify_config
|
||||
from core.workflow_copilot.errors import ConflictError
|
||||
from core.workflow_copilot.handlers_build import build_registry
|
||||
from core.workflow_copilot.handlers_edit import edit_registry
|
||||
from core.workflow_copilot.handlers_fix import fix_registry
|
||||
from core.workflow_copilot.models import Action, Actor, NodeEvent, Turn
|
||||
from core.workflow_copilot.runner import Env, Runner
|
||||
@@ -71,7 +72,7 @@ def advance_session(session_id: str, action_dict: dict, actor_dict: dict, token:
|
||||
|
||||
actor = Actor(**actor_dict)
|
||||
env = Env(dify=dify, agent=agent, repo=repo, now=naive_utc_now, emit=emit, emit_canvas=emit_canvas)
|
||||
runner = Runner(env, fix_registry() | build_registry())
|
||||
runner = Runner(env, fix_registry() | build_registry() | edit_registry())
|
||||
runner.advance(session_id, Turn(action=Action(**action_dict), actor=actor))
|
||||
# Project a SessionView (single source of truth for phase/run_status/
|
||||
# actions) rather than recomputing those fields inline -- once Task 5
|
||||
|
||||
@@ -78,6 +78,22 @@ def test_create_build_scenario_calls_create_build_session(monkeypatch):
|
||||
svc.create_fix_session.assert_not_called()
|
||||
|
||||
|
||||
def test_create_edit_scenario_calls_create_edit_session(monkeypatch):
|
||||
svc = MagicMock()
|
||||
svc.create_edit_session.return_value = _session_view()
|
||||
monkeypatch.setattr(mod, "build_service", lambda: svc)
|
||||
actor = _actor()
|
||||
|
||||
body, status = mod._create({"scenario": "edit", "app_id": "a1"}, actor)
|
||||
|
||||
assert status == 201
|
||||
svc.create_edit_session.assert_called_once()
|
||||
called = svc.create_edit_session.call_args
|
||||
assert called.kwargs["app_id"] == "a1"
|
||||
svc.create_fix_session.assert_not_called()
|
||||
svc.create_build_session.assert_not_called()
|
||||
|
||||
|
||||
def test_create_rejects_non_dict_body():
|
||||
actor = _actor()
|
||||
svc = MagicMock()
|
||||
|
||||
@@ -249,3 +249,28 @@ def test_advance_session_resolves_build_handler_via_merged_registry(
|
||||
stored, _fc = repo.get_session(s.id)
|
||||
assert stored.current_state == PcState.BUILD_GOAL_ANALYSIS # build handler resolved, not a 500
|
||||
assert (s.id, "tok-b1") in released
|
||||
|
||||
|
||||
def _seed_edit_session_at_capability(repo: SqlCopilotRepository) -> Session:
|
||||
s = Session(
|
||||
app_id=APP_ID,
|
||||
tenant_id=TENANT_ID,
|
||||
owner_account_id=ACCOUNT_ID,
|
||||
entry_mode=EntryMode.EDIT,
|
||||
current_state=PcState.EDIT_CAPABILITY_CHECK,
|
||||
)
|
||||
repo.create_session(s, CopilotContext(), [ConversationItem(kind="user", seq=0)])
|
||||
return s
|
||||
|
||||
|
||||
def test_advance_session_resolves_edit_handler_via_merged_registry(
|
||||
repo: SqlCopilotRepository, wired
|
||||
) -> None:
|
||||
_events, released = wired
|
||||
s = _seed_edit_session_at_capability(repo)
|
||||
|
||||
mod.advance_session(s.id, _act("send_edit_goal", 1, text="Tighten risk"), _ACTOR_DICT, "tok-e1")
|
||||
|
||||
stored, _fc = repo.get_session(s.id)
|
||||
assert stored.current_state == PcState.EDIT_IMPACT_ANALYSIS # edit handler resolved, not a 500
|
||||
assert (s.id, "tok-e1") in released
|
||||
@@ -515,3 +515,82 @@ def test_build_waiting_state_actions_resolve_to_handled_kinds() -> None:
|
||||
resolve_action_kind(a.id) for a in actions if a.id not in service_module._CLIENT_ONLY_ACTIONS
|
||||
}
|
||||
assert resolved <= kinds, f"{state}: resolved {resolved} not handled by its handler ({kinds})"
|
||||
|
||||
|
||||
def _seed_edit_at(repo: SqlCopilotRepository, state: PcState) -> Session:
|
||||
s = Session(
|
||||
app_id=APP_ID,
|
||||
tenant_id=TENANT_ID,
|
||||
owner_account_id=ACCOUNT_ID,
|
||||
entry_mode=EntryMode.EDIT,
|
||||
current_state=state,
|
||||
)
|
||||
repo.create_session(s, CopilotContext(goal_text="Tighten risk"), [ConversationItem(kind="user", seq=0)])
|
||||
return s
|
||||
|
||||
|
||||
def test_create_edit_session_rests_at_capability_check_without_dispatch(
|
||||
service: WorkflowCopilotService, enqueued: list[tuple]
|
||||
) -> None:
|
||||
view = service.create_edit_session(APP_ID, _actor())
|
||||
assert view.state == "edit.capability_check"
|
||||
assert view.entry_mode == EntryMode.EDIT
|
||||
assert view.version == 1
|
||||
assert view.run_status == "waiting_input"
|
||||
assert view.canvas_read_only is False
|
||||
assert view.conversation == [] # mock: show history + composer, nothing read yet
|
||||
assert enqueued == [] # NO advance dispatched (unlike Build)
|
||||
assert [a.id for a in view.actions] == ["send_edit_goal"]
|
||||
|
||||
|
||||
def test_edit_apply_changes_actions_and_run_status(service: WorkflowCopilotService, repo: SqlCopilotRepository) -> None:
|
||||
s = _seed_edit_at(repo, PcState.EDIT_APPLY_CHANGES)
|
||||
view = service.get_session_view(s.id, _actor())
|
||||
assert view.run_status == "waiting_input"
|
||||
assert view.phase == "modify"
|
||||
assert [(a.id, a.kind) for a in view.actions] == [
|
||||
("run_affected_tests", ActionKind.PRIMARY),
|
||||
("revert", ActionKind.DESTRUCTIVE),
|
||||
]
|
||||
|
||||
|
||||
def test_edit_review_actions(service: WorkflowCopilotService, repo: SqlCopilotRepository) -> None:
|
||||
s = _seed_edit_at(repo, PcState.EDIT_REVIEW)
|
||||
view = service.get_session_view(s.id, _actor())
|
||||
assert [a.id for a in view.actions] == [
|
||||
"publish_workflow",
|
||||
"keep_draft",
|
||||
"continue_adjusting",
|
||||
"view_changes",
|
||||
"revert",
|
||||
]
|
||||
|
||||
|
||||
def test_edit_publish_is_terminal_with_no_actions(service: WorkflowCopilotService, repo: SqlCopilotRepository) -> None:
|
||||
s = _seed_edit_at(repo, PcState.EDIT_PUBLISH)
|
||||
view = service.get_session_view(s.id, _actor())
|
||||
assert view.run_status == "complete"
|
||||
assert view.actions == []
|
||||
|
||||
|
||||
def test_edit_waiting_state_actions_resolve_to_handled_kinds() -> None:
|
||||
"""Every non-client-only Edit action id must resolve (via
|
||||
resolve_action_kind) to a kind its handler in handlers_edit.py branches on.
|
||||
approve_plan->approve_repair, revert->undo, continue_adjusting/
|
||||
retry_after_revert->re_fix reuse the existing global map; the rest pass
|
||||
through (send_edit_goal, submit_edit_rules, run_affected_tests, keep_draft,
|
||||
publish_workflow)."""
|
||||
handled: dict[PcState, set[str]] = {
|
||||
PcState.EDIT_CAPABILITY_CHECK: {"send_edit_goal"},
|
||||
PcState.EDIT_IMPACT_ANALYSIS: {"submit_edit_rules"},
|
||||
PcState.EDIT_PLAN_APPROVAL: {"approve_repair"},
|
||||
PcState.EDIT_APPLY_CHANGES: {"run_affected_tests", "undo"},
|
||||
PcState.EDIT_REVIEW: {"publish_workflow", "keep_draft", "re_fix", "undo"},
|
||||
PcState.EDIT_REVERTED: {"re_fix"},
|
||||
}
|
||||
for state, kinds in handled.items():
|
||||
actions = service_module._ACTIONS_FOR[state]
|
||||
resolved = {
|
||||
resolve_action_kind(a.id) for a in actions if a.id not in service_module._CLIENT_ONLY_ACTIONS
|
||||
}
|
||||
assert resolved <= kinds, f"{state}: resolved {resolved} not handled by its handler ({kinds})"
|
||||
Reference in new issue
Block a user