copilot: Edit service wiring + create_edit_session + merged registry

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Charles YaoandClaude Opus 4.8 committed 2026-08-23 02:27:55 -07:00
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(
+52
View File
@@ -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
+2 -1
View File
@@ -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})"