diff --git a/api/core/workflow_copilot/handlers_fix.py b/api/core/workflow_copilot/handlers_fix.py index 42563c30c04..1e844cbfb81 100644 --- a/api/core/workflow_copilot/handlers_fix.py +++ b/api/core/workflow_copilot/handlers_fix.py @@ -41,7 +41,7 @@ from core.workflow_copilot.models import ( ChangeSet, ChecklistError, ConversationItem, - FixContext, + CopilotContext, NodeOutput, Run, Session, @@ -76,7 +76,7 @@ __all__ = [ # ---- helpers --------------------------------------------------------------- -def append_item(fc: FixContext, kind: str, payload: dict[str, Any]) -> list[ConversationItem]: +def append_item(fc: CopilotContext, kind: str, payload: dict[str, Any]) -> list[ConversationItem]: """Append a conversation item stamped with the next sequence number. ``fc.next_seq`` is the seq authority (port of ``handlers_fix.go:9-13``): @@ -90,7 +90,7 @@ def append_item(fc: FixContext, kind: str, payload: dict[str, Any]) -> list[Conv return [item] -def append_card(fc: FixContext, card) -> list[ConversationItem]: +def append_card(fc: CopilotContext, card) -> list[ConversationItem]: """Stamp a typed card into the conversation at the next seq (mirrors append_item's seq authority; at_version preserved as the prior code's 0). """ @@ -123,7 +123,7 @@ def _mode_or_default(mode: str) -> str: # ---- handlers --------------------------------------------------------------- -def handle_diagnose(env: Env, turn: Turn, s: Session, fc: FixContext) -> StepResult: +def handle_diagnose(env: Env, turn: Turn, s: Session, fc: CopilotContext) -> StepResult: """(working) Read the failed run's node outputs, diagnose, capture the pre-repair checkpoint. Port of ``handlers_fix.go:38``.""" failed = env.repo.get_run(fc.failed_run_id) @@ -153,7 +153,7 @@ def handle_diagnose(env: Env, turn: Turn, s: Session, fc: FixContext) -> StepRes ) -def handle_propose(env: Env, turn: Turn, s: Session, fc: FixContext) -> StepResult: +def handle_propose(env: Env, turn: Turn, s: Session, fc: CopilotContext) -> StepResult: """(working) Ask the agent for repair intents + risk, stage them, branch on risk. Port of ``handlers_fix.go:97``. Also serves as ``checklist.propose`` (Task 7 reuses this handler, as Go does).""" @@ -179,7 +179,7 @@ def handle_propose(env: Env, turn: Turn, s: Session, fc: FixContext) -> StepResu return StepResult(next=next_state, context=fc, items=items) -def handle_await_approval(env: Env, turn: Turn, s: Session, fc: FixContext) -> StepResult: +def handle_await_approval(env: Env, turn: Turn, s: Session, fc: CopilotContext) -> StepResult: """(waiting) High-risk gate. Only an explicit ``approve_repair`` advances to ``fix.apply``; any other/absent action kind is a no-op that stays at the gate. Port of ``handlers_fix.go:123``.""" @@ -193,7 +193,7 @@ def handle_await_approval(env: Env, turn: Turn, s: Session, fc: FixContext) -> S return StepResult(next=PcState.FIX_AWAIT_APPROVAL, context=fc) -def handle_apply(env: Env, turn: Turn, s: Session, fc: FixContext) -> StepResult: +def handle_apply(env: Env, turn: Turn, s: Session, fc: CopilotContext) -> StepResult: """(working) Apply the staged repair to the real draft. The next state branches on ``fc.source``: the run-fix path always lands at ``fix.await_verify``; the checklist-fix path lands at @@ -222,7 +222,7 @@ def handle_apply(env: Env, turn: Turn, s: Session, fc: FixContext) -> StepResult return StepResult(next=next_state, context=fc, items=items) -def handle_await_verify(env: Env, turn: Turn, s: Session, fc: FixContext) -> StepResult: +def handle_await_verify(env: Env, turn: Turn, s: Session, fc: CopilotContext) -> StepResult: """(waiting) The user runs verification or undoes. Port of ``handlers_fix.go:163``.""" if turn.action is not None and turn.action.kind == "undo": @@ -234,7 +234,7 @@ def handle_await_verify(env: Env, turn: Turn, s: Session, fc: FixContext) -> Ste return StepResult(next=PcState.FIX_VERIFY, context=fc) -def handle_await_testdata(env: Env, turn: Turn, s: Session, fc: FixContext) -> StepResult: +def handle_await_testdata(env: Env, turn: Turn, s: Session, fc: CopilotContext) -> StepResult: """(waiting) Prepare inputs for the verify run. Port of ``handlers_fix.go:176``.""" mode, _ = action_string(turn, "mode") @@ -254,7 +254,7 @@ def handle_await_testdata(env: Env, turn: Turn, s: Session, fc: FixContext) -> S return StepResult(next=PcState.FIX_VERIFY, context=fc) -def handle_verify(env: Env, turn: Turn, s: Session, fc: FixContext) -> StepResult: +def handle_verify(env: Env, turn: Turn, s: Session, fc: CopilotContext) -> StepResult: """(working) Run the repaired draft, mint a NEW immutable Run. Advances to ``fix.await_decision`` on both pass and fail; never touches the original failed run. Port of ``handlers_fix.go:206``.""" @@ -304,7 +304,7 @@ def handle_verify(env: Env, turn: Turn, s: Session, fc: FixContext) -> StepResul ) -def handle_await_decision(env: Env, turn: Turn, s: Session, fc: FixContext) -> StepResult: +def handle_await_decision(env: Env, turn: Turn, s: Session, fc: CopilotContext) -> StepResult: """(waiting) Terminal choice. Port of ``handlers_fix.go:239``.""" kind = turn.action.kind if turn.action is not None else "" if kind == "publish": @@ -329,7 +329,7 @@ def handle_await_decision(env: Env, turn: Turn, s: Session, fc: FixContext) -> S return StepResult(next=PcState.SUCCESS, context=fc, items=items) -def handle_publish(env: Env, turn: Turn, s: Session, fc: FixContext) -> StepResult: +def handle_publish(env: Env, turn: Turn, s: Session, fc: CopilotContext) -> StepResult: """(working) Publish the repaired workflow. Port of ``handlers_fix.go:316``.""" env.dify.publish(s.app_id, turn.actor) @@ -340,7 +340,7 @@ def handle_publish(env: Env, turn: Turn, s: Session, fc: FixContext) -> StepResu # ---- checklist handlers ----------------------------------------------------- -def handle_checklist_diagnose(env: Env, turn: Turn, s: Session, fc: FixContext) -> StepResult: +def handle_checklist_diagnose(env: Env, turn: Turn, s: Session, fc: CopilotContext) -> StepResult: """(working) Mirrors ``handle_diagnose`` but diagnoses from the pre-publish checklist's config errors instead of a failed run's node outputs — there is no ``fc.failed_run_id`` on this path, so no run @@ -370,7 +370,7 @@ def handle_checklist_diagnose(env: Env, turn: Turn, s: Session, fc: FixContext) ) -def handle_await_recheck(env: Env, turn: Turn, s: Session, fc: FixContext) -> StepResult: +def handle_await_recheck(env: Env, turn: Turn, s: Session, fc: CopilotContext) -> StepResult: """(waiting) The checklist-fix counterpart of ``handle_await_verify``: instead of running the workflow, the caller re-runs the pre-publish checklist and reports the result via a ``"recheck"`` action. On ``undo`` diff --git a/api/core/workflow_copilot/models.py b/api/core/workflow_copilot/models.py index 81ada062cb1..b3f1cf10d58 100644 --- a/api/core/workflow_copilot/models.py +++ b/api/core/workflow_copilot/models.py @@ -14,13 +14,13 @@ Deltas from the Go source (per the P1 port plan's Global Constraints / ADR): Go test suite constructs these structs: almost every struct literal in ``*_test.go`` sets only a handful of fields and relies on Go's implicit zero-initialization for the rest (e.g. ``&Session{AppID: "app", ...}``, - ``&FixContext{}``, ``&FixContext{FailedRunID: "TR-1"}``). The excepted + ``&CopilotContext{}``, ``&CopilotContext{FailedRunID: "TR-1"}``). The excepted fields have no meaningful "empty" value (an empty string is not a valid ``PcState``/``EntryMode`` member) and are always set explicitly in the Go suite, so they stay required here. - ``Diagnosis`` / ``Risk`` / ``MutationIntent`` / ``ChangeSet`` / ``NodeEvent`` / ``ApplyResult`` (Go: ``ports.go``) live here rather than in ``ports.py``. - ``FixContext`` embeds ``Diagnosis``/``Risk``/``ChangeSet``/ + ``CopilotContext`` embeds ``Diagnosis``/``Risk``/``ChangeSet``/ ``MutationIntent`` directly, and defining them in ``ports.py`` (which imports the rest of these models) would create a models<->ports import cycle; ``ports.py`` imports them back out of this module instead. @@ -205,9 +205,16 @@ class ApplyResult: @dataclass(kw_only=True) -class FixContext: - """Per-session working state persisted as the commit context.""" +class CopilotContext: + """Per-session working state persisted as the commit context. + One context type for all entry modes (Fix, checklist, Build, Edit). + Fix-specific and Build-specific fields coexist; each scenario touches + only its own, all default-valued (serialized as a JSON blob -- no + migration on rename or on adding fields). + """ + + # -- Fix / checklist fields -- failed_run_id: str = "" diagnosis: Diagnosis | None = None staged_repair: list[MutationIntent] = field(default_factory=list) @@ -222,6 +229,14 @@ class FixContext: source: str = "" checklist_errors: list[ChecklistError] = field(default_factory=list) + # -- Build fields (Slice 2, additive) -- + goal_text: str = "" + requirements: dict[str, Any] = field(default_factory=dict) + plan_items: list[str] = field(default_factory=list) + plan_version_tag: str = "" + resource_selection: dict[str, Any] = field(default_factory=dict) + built_node_ids: list[str] = field(default_factory=list) + @dataclass(kw_only=True) class Action: diff --git a/api/core/workflow_copilot/ports.py b/api/core/workflow_copilot/ports.py index daec254be88..f864ce1451b 100644 --- a/api/core/workflow_copilot/ports.py +++ b/api/core/workflow_copilot/ports.py @@ -11,7 +11,7 @@ Deltas from the Go source (per the P1 port plan's Global Constraints / ADR): - The value types Go defines in this file (``Diagnosis``, ``Risk``, ``MutationIntent``, ``ChangeSet``, ``NodeEvent``, ``ApplyResult``) live in ``models.py`` instead, alongside the rest of the domain dataclasses — - ``FixContext`` embeds several of them directly, and defining them here + ``CopilotContext`` embeds several of them directly, and defining them here (where the models are imported) would create a models<->ports import cycle. They are re-exported from here for convenience. - ``interface`` becomes ``typing.Protocol``, marked ``@runtime_checkable`` so @@ -29,8 +29,8 @@ from core.workflow_copilot.models import ( ChecklistError, Checkpoint, ConversationItem, + CopilotContext, Diagnosis, - FixContext, Graph, Inputs, MutationIntent, @@ -109,16 +109,16 @@ class Repository(Protocol): ``base_version``, else raises ``ConflictError``. """ - def create_session(self, session: Session, initial_fc: FixContext, items: list[ConversationItem]) -> None: ... + def create_session(self, session: Session, initial_fc: CopilotContext, items: list[ConversationItem]) -> None: ... - def get_session(self, id: str) -> tuple[Session, FixContext]: ... + def get_session(self, id: str) -> tuple[Session, CopilotContext]: ... def compare_and_advance( self, session_id: str, base_version: int, next: PcState, - fc: FixContext, + fc: CopilotContext, items: list[ConversationItem], ) -> int: ... diff --git a/api/core/workflow_copilot/runner.py b/api/core/workflow_copilot/runner.py index d6d45bf4414..3b1bc12d2e0 100644 --- a/api/core/workflow_copilot/runner.py +++ b/api/core/workflow_copilot/runner.py @@ -27,7 +27,7 @@ from core.workflow_copilot.errors import ConflictError from core.workflow_copilot.models import ( Checkpoint, ConversationItem, - FixContext, + CopilotContext, NodeEvent, Run, Session, @@ -61,14 +61,14 @@ class Env: @dataclass class StepResult: next: PcState - context: FixContext | None = None + context: CopilotContext | None = None items: list[ConversationItem] = field(default_factory=list) checkpoint: Snapshot | None = None # if set, persist a checkpoint from this snapshot before advancing run: Run | None = None # if set, persist this run before advancing run_id_sink: list[str] | None = None # internal: runner writes the saved run id to run_id_sink[0] -Handler = Callable[[Env, Turn, Session, FixContext], StepResult] +Handler = Callable[[Env, Turn, Session, CopilotContext], StepResult] class Runner: diff --git a/api/models/workflow_copilot.py b/api/models/workflow_copilot.py index 81fc45ffe72..b2a4604edc3 100644 --- a/api/models/workflow_copilot.py +++ b/api/models/workflow_copilot.py @@ -46,7 +46,7 @@ class CopilotSessionCommit(Base): Copilot Session Commit Model One immutable row per session version, carrying the serialized - `FixContext` (`context`) as of that version. The latest commit by + `CopilotContext` (`context`) as of that version. The latest commit by `version` for a given `session_id` is the session's current context. """ diff --git a/api/services/workflow_copilot/repository.py b/api/services/workflow_copilot/repository.py index af29271a302..3419e42f280 100644 --- a/api/services/workflow_copilot/repository.py +++ b/api/services/workflow_copilot/repository.py @@ -30,8 +30,8 @@ from core.workflow_copilot.errors import ConflictError, NotFoundError from core.workflow_copilot.models import ( Checkpoint, ConversationItem, + CopilotContext, EntryMode, - FixContext, NodeOutput, Run, Snapshot, @@ -50,7 +50,7 @@ from models.workflow_copilot import ( CopilotSnapshot, CopilotTestInput, ) -from services.workflow_copilot.serde import fix_context_from_dict, fix_context_to_dict +from services.workflow_copilot.serde import context_from_dict, context_to_dict __all__ = ["SqlCopilotRepository"] @@ -63,7 +63,7 @@ class SqlCopilotRepository: # -- sessions -- - def create_session(self, session: DomainSession, initial_fc: FixContext, items: list[ConversationItem]) -> None: + def create_session(self, session: DomainSession, initial_fc: CopilotContext, items: list[ConversationItem]) -> None: with self._session_factory() as db_session, db_session.begin(): row = CopilotSession( app_id=session.app_id, @@ -93,12 +93,12 @@ class SqlCopilotRepository: session_id=row.id, version=1, state=str(session.current_state), - context=fix_context_to_dict(initial_fc), + context=context_to_dict(initial_fc), actor="", ) ) - def get_session(self, id: str) -> tuple[DomainSession, FixContext]: + def get_session(self, id: str) -> tuple[DomainSession, CopilotContext]: with self._session_factory() as db_session, db_session.begin(): row = db_session.get(CopilotSession, id) if row is None: @@ -114,7 +114,7 @@ class SqlCopilotRepository: if commit is None: raise NotFoundError(f"no commits found for session {id}") - fc = fix_context_from_dict(commit.context) + fc = context_from_dict(commit.context) return self._to_domain_session(row), fc def compare_and_advance( @@ -122,7 +122,7 @@ class SqlCopilotRepository: session_id: str, base_version: int, next: PcState, - fc: FixContext, + fc: CopilotContext, items: list[ConversationItem], ) -> int: new_version = base_version + 1 @@ -145,7 +145,7 @@ class SqlCopilotRepository: session_id=session_id, version=new_version, state=str(next), - context=fix_context_to_dict(fc), + context=context_to_dict(fc), actor="", ) ) diff --git a/api/services/workflow_copilot/run_mapping.py b/api/services/workflow_copilot/run_mapping.py index d530d964af3..e4a905f6db2 100644 --- a/api/services/workflow_copilot/run_mapping.py +++ b/api/services/workflow_copilot/run_mapping.py @@ -47,7 +47,7 @@ def map_run_result(data: Mapping[str, Any], node_execs: Sequence[Any]) -> Run: """Map a blocking ``AppGenerateService.generate`` result into a ``Run``. ``Run.id`` is deliberately left ``""`` — the caller (``handle_verify``) - assigns the id and sets ``FixContext.verify_run_id``, not this function. + assigns the id and sets ``CopilotContext.verify_run_id``, not this function. """ if _is_paused_shape(data): status = "failed" diff --git a/api/services/workflow_copilot/serde.py b/api/services/workflow_copilot/serde.py index 121cb7d5aa0..9e8d1090417 100644 --- a/api/services/workflow_copilot/serde.py +++ b/api/services/workflow_copilot/serde.py @@ -1,11 +1,13 @@ -"""Pure JSON (de)serialization for the domain ``FixContext``. +"""Pure JSON (de)serialization for the domain ``CopilotContext``. -``FixContext`` (see ``core.workflow_copilot.models``) is the per-session +``CopilotContext`` (see ``core.workflow_copilot.models``) is the per-session working state persisted, one row per version, as the ``context`` JSONB -column on ``workflow_copilot_session_commits`` (P3a Task 4). This module -has no I/O and no SQLAlchemy dependency -- it only converts between the -dataclass and a JSON-safe ``dict``, so the SQL repository can hand the dict -straight to an ``AdjustedJSON`` column and get it back unchanged. +column on ``workflow_copilot_session_commits``. This module has no I/O and +no SQLAlchemy dependency -- it only converts between the dataclass and a +JSON-safe ``dict``, so the SQL repository can hand the dict straight to an +``AdjustedJSON`` column and get it back unchanged. The on-disk key names are +unchanged from the pre-rename dataclass form so existing rows deserialize +as-is. """ from dataclasses import asdict @@ -14,34 +16,31 @@ from typing import Any from core.workflow_copilot.models import ( ChangeSet, ChecklistError, + CopilotContext, Diagnosis, - FixContext, MutationIntent, Risk, ) -def fix_context_to_dict(fc: FixContext) -> dict[str, Any]: - """Serialize a ``FixContext`` to a JSON-safe dict. +def context_to_dict(fc: CopilotContext) -> dict[str, Any]: + """Serialize a ``CopilotContext`` to a JSON-safe dict. ``dataclasses.asdict`` recurses into the nested dataclasses (``Diagnosis``, ``MutationIntent``, ``Risk``, ``ChangeSet``, - ``ChecklistError``), turning each into a plain dict, and every field on - ``FixContext`` and its nested dataclasses is already a + ``ChecklistError``), turning each into a plain dict; every field is a str/int/bool/list/dict/None -- so the result is JSON-safe as-is. """ return asdict(fc) -def fix_context_from_dict(d: dict[str, Any]) -> FixContext: - """Rebuild a ``FixContext`` from a dict produced by ``fix_context_to_dict``. +def context_from_dict(d: dict[str, Any]) -> CopilotContext: + """Rebuild a ``CopilotContext`` from a dict produced by ``context_to_dict``. - Reconstructs the nested dataclasses explicitly rather than splatting - ``d`` into ``FixContext(**d)`` -- the nested values are plain dicts (and - lists of dicts) after a JSON round-trip and must become the right - dataclass instances. Every lookup is defensive (``.get`` with a default - matching the ``FixContext``/nested-dataclass field default) so an - older or partial dict still yields a valid ``FixContext``. + Reconstructs the nested dataclasses explicitly (they are plain dicts + after a JSON round-trip). Every lookup is defensive (``.get`` with a + default matching the field default) so an older or partial dict still + yields a valid ``CopilotContext``. """ diagnosis = Diagnosis(**d["diagnosis"]) if d.get("diagnosis") else None staged_repair = [MutationIntent(**m) for m in d.get("staged_repair", [])] @@ -49,7 +48,7 @@ def fix_context_from_dict(d: dict[str, Any]) -> FixContext: change_set = ChangeSet(**d["change_set"]) if d.get("change_set") else None checklist_errors = [ChecklistError(**e) for e in d.get("checklist_errors", [])] - return FixContext( + return CopilotContext( failed_run_id=d.get("failed_run_id", ""), diagnosis=diagnosis, staged_repair=staged_repair, @@ -62,4 +61,10 @@ def fix_context_from_dict(d: dict[str, Any]) -> FixContext: next_seq=d.get("next_seq", 0), source=d.get("source", ""), checklist_errors=checklist_errors, + goal_text=d.get("goal_text", ""), + requirements=dict(d.get("requirements") or {}), + plan_items=list(d.get("plan_items") or []), + plan_version_tag=d.get("plan_version_tag", ""), + resource_selection=dict(d.get("resource_selection") or {}), + built_node_ids=list(d.get("built_node_ids") or []), ) diff --git a/api/services/workflow_copilot/service.py b/api/services/workflow_copilot/service.py index 75051862d50..d9d7c60561b 100644 --- a/api/services/workflow_copilot/service.py +++ b/api/services/workflow_copilot/service.py @@ -21,8 +21,8 @@ from core.workflow_copilot.models import ( Actor, ChecklistError, ConversationItem, + CopilotContext, EntryMode, - FixContext, Run, Session, ) @@ -223,7 +223,7 @@ class WorkflowCopilotService: failed_run: Run | None = None if checklist_errors: entry_mode, state = EntryMode.FIX_CHECKLIST, PcState.CHECKLIST_DIAGNOSE - fc = FixContext(source="checklist", checklist_errors=checklist_errors) + fc = CopilotContext(source="checklist", checklist_errors=checklist_errors) else: entry_mode, state = EntryMode.FIX, PcState.FIX_DIAGNOSE failed_run = Run( @@ -233,7 +233,7 @@ class WorkflowCopilotService: status="failed", immutable=True, ) - fc = FixContext(failed_run_id=failed_run.id, source="run") + fc = CopilotContext(failed_run_id=failed_run.id, source="run") s = Session( app_id=app_id, diff --git a/api/tests/unit_tests/core/workflow_copilot/fakes.py b/api/tests/unit_tests/core/workflow_copilot/fakes.py index 49b65c012b4..70d8d669cdc 100644 --- a/api/tests/unit_tests/core/workflow_copilot/fakes.py +++ b/api/tests/unit_tests/core/workflow_copilot/fakes.py @@ -42,8 +42,8 @@ from core.workflow_copilot.models import ( ChecklistError, Checkpoint, ConversationItem, + CopilotContext, Diagnosis, - FixContext, Graph, Inputs, MutationIntent, @@ -75,7 +75,7 @@ class InMemoryRepository: def __init__(self) -> None: self._lock = threading.Lock() self._sessions: dict[str, Session] = {} - self._contexts: dict[str, FixContext] = {} + self._contexts: dict[str, CopilotContext] = {} self._commits: dict[str, list[int]] = {} self._checkpoints: dict[str, Checkpoint] = {} self._snapshots: dict[str, Snapshot] = {} @@ -86,7 +86,7 @@ class InMemoryRepository: # -- sessions -- - def create_session(self, session: Session, initial_fc: FixContext, items: list[ConversationItem]) -> None: + def create_session(self, session: Session, initial_fc: CopilotContext, items: list[ConversationItem]) -> None: with self._lock: if not session.id: session.id = str(uuid.uuid4()) @@ -109,7 +109,7 @@ class InMemoryRepository: self._items[session.id] = copy.deepcopy(items) self._used_seqs[session.id] = seqs - def get_session(self, id: str) -> tuple[Session, FixContext]: + def get_session(self, id: str) -> tuple[Session, CopilotContext]: with self._lock: session = self._sessions.get(id) if session is None: @@ -121,7 +121,7 @@ class InMemoryRepository: session_id: str, base_version: int, next: PcState, - fc: FixContext, + fc: CopilotContext, items: list[ConversationItem], ) -> int: with self._lock: diff --git a/api/tests/unit_tests/core/workflow_copilot/test_fakes.py b/api/tests/unit_tests/core/workflow_copilot/test_fakes.py index cf98664c273..e6053100470 100644 --- a/api/tests/unit_tests/core/workflow_copilot/test_fakes.py +++ b/api/tests/unit_tests/core/workflow_copilot/test_fakes.py @@ -16,9 +16,9 @@ from core.workflow_copilot.models import ( Actor, Checkpoint, ConversationItem, + CopilotContext, Diagnosis, EntryMode, - FixContext, MutationIntent, Run, Session, @@ -62,7 +62,7 @@ def test_create_session_assigns_id_and_starts_at_version_1(): repo = InMemoryRepository() s = _session() - repo.create_session(s, FixContext(), []) + repo.create_session(s, CopilotContext(), []) assert s.id != "" assert s.version == 1 @@ -75,7 +75,7 @@ def test_create_session_assigns_id_and_starts_at_version_1(): def test_create_session_sets_next_seq_to_seed_item_count(): repo = InMemoryRepository() s = _session() - fc = FixContext() + fc = CopilotContext() items = [ ConversationItem(seq=0, kind="run-context", at_version=1), ConversationItem(seq=1, kind="user", at_version=1), @@ -93,7 +93,7 @@ def test_create_session_sets_next_seq_to_seed_item_count(): def test_create_session_does_not_downgrade_an_already_larger_next_seq(): repo = InMemoryRepository() s = _session() - fc = FixContext(next_seq=5) + fc = CopilotContext(next_seq=5) repo.create_session(s, fc, []) @@ -109,7 +109,7 @@ def test_create_session_rejects_duplicate_seq_within_seed_items(): ] with pytest.raises(ConflictError): - repo.create_session(s, FixContext(), items) + repo.create_session(s, CopilotContext(), items) # ---- compare_and_advance: version CAS ------------------------------------- @@ -118,10 +118,10 @@ def test_create_session_rejects_duplicate_seq_within_seed_items(): def test_compare_and_advance_rejects_stale_base_version(): repo = InMemoryRepository() s = _session() - repo.create_session(s, FixContext(), []) + repo.create_session(s, CopilotContext(), []) with pytest.raises(ConflictError): - repo.compare_and_advance(s.id, 0, PcState.FIX_PROPOSE, FixContext(), []) + repo.compare_and_advance(s.id, 0, PcState.FIX_PROPOSE, CopilotContext(), []) # Nothing applied. stored, _ = repo.get_session(s.id) @@ -132,13 +132,13 @@ def test_compare_and_advance_rejects_stale_base_version(): def test_compare_and_advance_accepts_current_version_and_bumps_it(): repo = InMemoryRepository() s = _session() - repo.create_session(s, FixContext(), []) + repo.create_session(s, CopilotContext(), []) new_version = repo.compare_and_advance( s.id, 1, PcState.FIX_PROPOSE, - FixContext(diagnosis=Diagnosis(culprit_node_id="n1")), + CopilotContext(diagnosis=Diagnosis(culprit_node_id="n1")), [], ) @@ -154,15 +154,15 @@ def test_compare_and_advance_on_unknown_session_raises_not_found(): repo = InMemoryRepository() with pytest.raises(NotFoundError): - repo.compare_and_advance("nope", 1, PcState.FIX_PROPOSE, FixContext(), []) + repo.compare_and_advance("nope", 1, PcState.FIX_PROPOSE, CopilotContext(), []) def test_compare_and_advance_deep_copies_the_stored_context(): repo = InMemoryRepository() s = _session() - repo.create_session(s, FixContext(), []) + repo.create_session(s, CopilotContext(), []) - fc = FixContext(staged_repair=[MutationIntent(op="set_node_config")]) + fc = CopilotContext(staged_repair=[MutationIntent(op="set_node_config")]) repo.compare_and_advance(s.id, 1, PcState.FIX_APPLY, fc, []) # Mutating the caller's fc after the commit must not affect stored state. @@ -179,7 +179,7 @@ def test_compare_and_advance_deep_copies_the_stored_context(): def test_compare_and_advance_rejects_duplicate_seq_within_one_batch(): repo = InMemoryRepository() s = _session() - repo.create_session(s, FixContext(), []) + repo.create_session(s, CopilotContext(), []) dup_items = [ ConversationItem(seq=0, kind="a", at_version=2), @@ -187,7 +187,7 @@ def test_compare_and_advance_rejects_duplicate_seq_within_one_batch(): ] with pytest.raises(ConflictError): - repo.compare_and_advance(s.id, 1, PcState.FIX_PROPOSE, FixContext(), dup_items) + repo.compare_and_advance(s.id, 1, PcState.FIX_PROPOSE, CopilotContext(), dup_items) # Nothing applied: neither version nor conversation items advanced. stored, _ = repo.get_session(s.id) @@ -198,14 +198,14 @@ def test_compare_and_advance_rejects_duplicate_seq_within_one_batch(): def test_compare_and_advance_rejects_seq_reused_from_a_previous_commit(): repo = InMemoryRepository() s = _session() - repo.create_session(s, FixContext(), [ConversationItem(seq=0, kind="run-context", at_version=1)]) + repo.create_session(s, CopilotContext(), [ConversationItem(seq=0, kind="run-context", at_version=1)]) with pytest.raises(ConflictError): repo.compare_and_advance( s.id, 1, PcState.FIX_PROPOSE, - FixContext(), + CopilotContext(), [ConversationItem(seq=0, kind="user", at_version=2)], ) @@ -217,13 +217,13 @@ def test_compare_and_advance_rejects_seq_reused_from_a_previous_commit(): def test_compare_and_advance_accepts_the_next_seq_in_order(): repo = InMemoryRepository() s = _session() - repo.create_session(s, FixContext(), [ConversationItem(seq=0, kind="run-context", at_version=1)]) + repo.create_session(s, CopilotContext(), [ConversationItem(seq=0, kind="run-context", at_version=1)]) repo.compare_and_advance( s.id, 1, PcState.FIX_PROPOSE, - FixContext(), + CopilotContext(), [ConversationItem(seq=1, kind="user", at_version=2)], ) diff --git a/api/tests/unit_tests/core/workflow_copilot/test_fix_flow.py b/api/tests/unit_tests/core/workflow_copilot/test_fix_flow.py index dbf2b8425ae..bec1d3fada3 100644 --- a/api/tests/unit_tests/core/workflow_copilot/test_fix_flow.py +++ b/api/tests/unit_tests/core/workflow_copilot/test_fix_flow.py @@ -24,8 +24,8 @@ from core.workflow_copilot.models import ( Actor, ChecklistError, ConversationItem, + CopilotContext, EntryMode, - FixContext, Run, Session, Turn, @@ -66,12 +66,12 @@ def _fix_session(**overrides) -> Session: def _seed_fix_session(repo: InMemoryRepository) -> Session: """Seed a session at fix.diagnose with a run-context item and a - FixContext pointing at a pre-existing failed run. The Fix flow diagnoses + CopilotContext pointing at a pre-existing failed run. The Fix flow diagnoses from this already-ingested "red" run; it never re-runs it.""" s = _fix_session() repo.create_session( s, - FixContext(failed_run_id="TR-1"), + CopilotContext(failed_run_id="TR-1"), [ConversationItem(kind="run-context", seq=0, payload={"failed_run_id": "TR-1"})], ) repo.save_run(s.id, Run(id="TR-1", kind="original-failed", status="failed", immutable=True)) @@ -219,7 +219,7 @@ def _seed_checklist_session(repo: InMemoryRepository, errors: list[ChecklistErro s = _fix_session(entry_mode=EntryMode.FIX_CHECKLIST, current_state=PcState.CHECKLIST_DIAGNOSE) repo.create_session( s, - FixContext(source="checklist", checklist_errors=errors), + CopilotContext(source="checklist", checklist_errors=errors), [ConversationItem(kind="run-context", seq=0)], ) return s diff --git a/api/tests/unit_tests/core/workflow_copilot/test_handlers_fix.py b/api/tests/unit_tests/core/workflow_copilot/test_handlers_fix.py index 5fd924ca9f5..7c568d3d4ae 100644 --- a/api/tests/unit_tests/core/workflow_copilot/test_handlers_fix.py +++ b/api/tests/unit_tests/core/workflow_copilot/test_handlers_fix.py @@ -24,8 +24,8 @@ from core.workflow_copilot.models import ( Actor, ChecklistError, ConversationItem, + CopilotContext, EntryMode, - FixContext, Run, Session, Turn, @@ -81,7 +81,7 @@ def _run_fix_registry() -> dict[PcState, object]: def _seed_diagnose_session(repo: InMemoryRepository) -> Session: s = _session() - repo.create_session(s, FixContext(failed_run_id="TR-1"), [ConversationItem(kind="run-context", seq=0)]) + repo.create_session(s, CopilotContext(failed_run_id="TR-1"), [ConversationItem(kind="run-context", seq=0)]) repo.save_run(s.id, Run(id="TR-1", kind="original-failed", status="failed", immutable=True)) return s @@ -374,7 +374,7 @@ def _seed_checklist_session(repo: InMemoryRepository, errors: list[ChecklistErro s = _session(entry_mode=EntryMode.FIX_CHECKLIST, current_state=PcState.CHECKLIST_DIAGNOSE) repo.create_session( s, - FixContext(source="checklist", checklist_errors=errors), + CopilotContext(source="checklist", checklist_errors=errors), [ConversationItem(kind="run-context", seq=0)], ) return s diff --git a/api/tests/unit_tests/core/workflow_copilot/test_models.py b/api/tests/unit_tests/core/workflow_copilot/test_models.py index 05698a1da03..17965c08e06 100644 --- a/api/tests/unit_tests/core/workflow_copilot/test_models.py +++ b/api/tests/unit_tests/core/workflow_copilot/test_models.py @@ -6,7 +6,7 @@ these structs (see e.g. handlers_fix_test.go, runner_test.go): almost every struct literal in that suite sets only a handful of fields and relies on Go's implicit zero-initialization for the rest (``&Session{AppID: "app", TenantID: "t", OwnerAccountID: "u", EntryMode: EntryModeFix, CurrentState: -FixDiagnose}``, ``&FixContext{}``, ``&FixContext{FailedRunID: "TR-1"}``). +FixDiagnose}``, ``&CopilotContext{}``, ``&CopilotContext{FailedRunID: "TR-1"}``). These tests port that same terse-construction ergonomics and assert the Python defaults match the Go zero values field-for-field. """ @@ -19,8 +19,8 @@ from core.workflow_copilot.models import ( ChecklistError, Checkpoint, ConversationItem, + CopilotContext, EntryMode, - FixContext, NodeOutput, Run, Session, @@ -162,8 +162,8 @@ def test_conversation_item_defaults(): def test_fix_context_round_trip_defaults_empty(): - # Mirrors runner_test.go: repo.CreateSession(..., &FixContext{}, nil) - fc = FixContext() + # Mirrors runner_test.go: repo.CreateSession(..., &CopilotContext{}, nil) + fc = CopilotContext() assert fc.failed_run_id == "" assert fc.diagnosis is None assert fc.staged_repair == [] @@ -179,14 +179,14 @@ def test_fix_context_round_trip_defaults_empty(): def test_fix_context_partial_construction(): - # Mirrors handlers_fix_test.go: &FixContext{FailedRunID: "TR-1"} - fc = FixContext(failed_run_id="TR-1") + # Mirrors handlers_fix_test.go: &CopilotContext{FailedRunID: "TR-1"} + fc = CopilotContext(failed_run_id="TR-1") assert fc.failed_run_id == "TR-1" assert fc.source == "" - # Mirrors handlers_fix_test.go: &FixContext{Source: "checklist", ChecklistErrors: errs} + # Mirrors handlers_fix_test.go: &CopilotContext{Source: "checklist", ChecklistErrors: errs} errs = [ChecklistError(node_id="n1", unconnected=True)] - fc2 = FixContext(source="checklist", checklist_errors=errs) + fc2 = CopilotContext(source="checklist", checklist_errors=errs) assert fc2.source == "checklist" assert fc2.checklist_errors == errs assert fc2.failed_run_id == "" diff --git a/api/tests/unit_tests/core/workflow_copilot/test_ports.py b/api/tests/unit_tests/core/workflow_copilot/test_ports.py index f27856d356d..8b252e7b4a5 100644 --- a/api/tests/unit_tests/core/workflow_copilot/test_ports.py +++ b/api/tests/unit_tests/core/workflow_copilot/test_ports.py @@ -14,8 +14,8 @@ from core.workflow_copilot.models import ( Actor, ApplyResult, ChangeSet, + CopilotContext, Diagnosis, - FixContext, MutationIntent, NodeEvent, Risk, @@ -86,7 +86,7 @@ def test_apply_result_defaults_and_changed_nodes_not_shared(): def test_fix_context_embeds_the_tightened_value_types(): - fc = FixContext( + fc = CopilotContext( diagnosis=Diagnosis(culprit_node_id="n1"), staged_repair=[MutationIntent(op="set_node_config")], risk=Risk(level="low"), diff --git a/api/tests/unit_tests/core/workflow_copilot/test_runner.py b/api/tests/unit_tests/core/workflow_copilot/test_runner.py index e80ac03e56a..b04f1058f64 100644 --- a/api/tests/unit_tests/core/workflow_copilot/test_runner.py +++ b/api/tests/unit_tests/core/workflow_copilot/test_runner.py @@ -10,7 +10,7 @@ from datetime import datetime import pytest from core.workflow_copilot.errors import ConflictError -from core.workflow_copilot.models import Action, Actor, EntryMode, FixContext, Session, Turn +from core.workflow_copilot.models import Action, Actor, CopilotContext, EntryMode, Session, Turn from core.workflow_copilot.runner import Env, Runner, StepResult from core.workflow_copilot.state import PcState from tests.unit_tests.core.workflow_copilot.fakes import FakeDifyPort, InMemoryRepository, StubAgent @@ -36,7 +36,7 @@ def _toy_registry() -> dict[PcState, object]: """A working -> waiting toy machine: FIX_DIAGNOSE auto-advances to FIX_AWAIT_APPROVAL (a waiting state), where the loop stops.""" - def diagnose(_env: Env, _turn: Turn, _s: Session, fc: FixContext) -> StepResult: + def diagnose(_env: Env, _turn: Turn, _s: Session, fc: CopilotContext) -> StepResult: return StepResult(next=PcState.FIX_AWAIT_APPROVAL, context=fc) return {PcState.FIX_DIAGNOSE: diagnose} @@ -56,7 +56,7 @@ def _new_env() -> tuple[Env, InMemoryRepository]: def test_advance_commits_each_transition_and_stops_at_waiting(): env, repo = _new_env() s = _session() - repo.create_session(s, FixContext(), []) + repo.create_session(s, CopilotContext(), []) runner = Runner(env, _toy_registry()) turn = Turn(action=Action(kind="request_fix", base_version=1), actor=_actor()) @@ -73,7 +73,7 @@ def test_advance_commits_each_transition_and_stops_at_waiting(): def test_advance_stale_base_version_raises_conflict_with_nothing_applied(): env, repo = _new_env() s = _session() - repo.create_session(s, FixContext(), []) + repo.create_session(s, CopilotContext(), []) runner = Runner(env, _toy_registry()) turn = Turn(action=Action(kind="request_fix", base_version=99), actor=_actor()) @@ -90,7 +90,7 @@ def test_advance_stale_base_version_raises_conflict_with_nothing_applied(): def test_advance_missing_handler_raises(): env, repo = _new_env() s = _session() - repo.create_session(s, FixContext(), []) + repo.create_session(s, CopilotContext(), []) runner = Runner(env, {}) # empty registry: no handler for FIX_DIAGNOSE turn = Turn(action=Action(kind="request_fix", base_version=1), actor=_actor()) @@ -102,7 +102,7 @@ def test_advance_missing_handler_raises(): def test_advance_missing_handler_error_message_names_the_state(): env, repo = _new_env() s = _session() - repo.create_session(s, FixContext(), []) + repo.create_session(s, CopilotContext(), []) runner = Runner(env, {}) turn = Turn(action=Action(kind="request_fix", base_version=1), actor=_actor()) @@ -114,7 +114,7 @@ def test_advance_missing_handler_error_message_names_the_state(): def test_advance_waiting_state_first_turn_no_action_returns_session_unchanged(): env, repo = _new_env() s = _session(current_state=PcState.FIX_AWAIT_APPROVAL) - repo.create_session(s, FixContext(), []) + repo.create_session(s, CopilotContext(), []) runner = Runner(env, {}) # never consulted: loop returns before handler lookup turn = Turn(actor=_actor()) # no action @@ -127,7 +127,7 @@ def test_advance_waiting_state_first_turn_no_action_returns_session_unchanged(): def test_advance_terminal_state_returns_session_unchanged(): env, repo = _new_env() s = _session(current_state=PcState.SUCCESS) - repo.create_session(s, FixContext(), []) + repo.create_session(s, CopilotContext(), []) runner = Runner(env, {}) # never consulted: terminal short-circuits first turn = Turn(actor=_actor()) @@ -143,15 +143,15 @@ def test_advance_passes_full_turn_to_first_step_and_actor_only_turn_to_subsequen handler must see a Turn with Action consumed (None) but the same Actor.""" env, repo = _new_env() s = _session() - repo.create_session(s, FixContext(), []) + repo.create_session(s, CopilotContext(), []) seen_turns: list[Turn] = [] - def diagnose(_env: Env, turn: Turn, _s: Session, fc: FixContext) -> StepResult: + def diagnose(_env: Env, turn: Turn, _s: Session, fc: CopilotContext) -> StepResult: seen_turns.append(turn) return StepResult(next=PcState.FIX_PROPOSE, context=fc) - def propose(_env: Env, turn: Turn, _s: Session, fc: FixContext) -> StepResult: + def propose(_env: Env, turn: Turn, _s: Session, fc: CopilotContext) -> StepResult: seen_turns.append(turn) return StepResult(next=PcState.FIX_AWAIT_APPROVAL, context=fc) diff --git a/api/tests/unit_tests/services/workflow_copilot/test_advance_task.py b/api/tests/unit_tests/services/workflow_copilot/test_advance_task.py index 87429eaf8bd..287ee1858a1 100644 --- a/api/tests/unit_tests/services/workflow_copilot/test_advance_task.py +++ b/api/tests/unit_tests/services/workflow_copilot/test_advance_task.py @@ -21,7 +21,7 @@ from sqlalchemy.engine import Engine from sqlalchemy.orm import sessionmaker import tasks.workflow_copilot_advance_task as mod -from core.workflow_copilot.models import ConversationItem, EntryMode, FixContext, Run, Session +from core.workflow_copilot.models import ConversationItem, CopilotContext, EntryMode, Run, Session from core.workflow_copilot.state import PcState from models.base import Base from services.workflow_copilot.repository import SqlCopilotRepository @@ -55,7 +55,7 @@ def _seed_fix_session(repo: SqlCopilotRepository) -> Session: ) repo.create_session( s, - FixContext(failed_run_id="TR-1"), + CopilotContext(failed_run_id="TR-1"), [ConversationItem(kind="run-context", seq=0)], ) repo.save_run(s.id, Run(id="TR-1", kind="original-failed", status="failed", immutable=True)) diff --git a/api/tests/unit_tests/services/workflow_copilot/test_async_flow.py b/api/tests/unit_tests/services/workflow_copilot/test_async_flow.py index a7fa58eeb4e..787112cca88 100644 --- a/api/tests/unit_tests/services/workflow_copilot/test_async_flow.py +++ b/api/tests/unit_tests/services/workflow_copilot/test_async_flow.py @@ -29,7 +29,7 @@ from sqlalchemy.orm import sessionmaker import tasks.workflow_copilot_advance_task as task_mod from core.workflow_copilot.errors import BusyError -from core.workflow_copilot.models import Action, Actor, ConversationItem, EntryMode, FixContext, Run, Session +from core.workflow_copilot.models import Action, Actor, ConversationItem, CopilotContext, EntryMode, Run, Session from core.workflow_copilot.state import PcState from models.base import Base from services.workflow_copilot import session_lock @@ -170,7 +170,7 @@ def test_submit_action_while_lock_held_raises_busy_eager_async( entry_mode=EntryMode.FIX, current_state=PcState.FIX_DIAGNOSE, ) - repo.create_session(s, FixContext(failed_run_id="TR-1"), [ConversationItem(kind="run-context", seq=0)]) + repo.create_session(s, CopilotContext(failed_run_id="TR-1"), [ConversationItem(kind="run-context", seq=0)]) # Manually hold the lock, simulating an in-flight advance on another # worker. diff --git a/api/tests/unit_tests/services/workflow_copilot/test_engine_on_sql_repo.py b/api/tests/unit_tests/services/workflow_copilot/test_engine_on_sql_repo.py index 1e68bc81b25..64b2252fdf2 100644 --- a/api/tests/unit_tests/services/workflow_copilot/test_engine_on_sql_repo.py +++ b/api/tests/unit_tests/services/workflow_copilot/test_engine_on_sql_repo.py @@ -28,8 +28,8 @@ from core.workflow_copilot.models import ( Actor, ChecklistError, ConversationItem, + CopilotContext, EntryMode, - FixContext, Run, Session, Turn, @@ -87,13 +87,13 @@ def _fix_session(**overrides) -> Session: def _seed_fix_session(repo: SqlCopilotRepository) -> Session: """Seed a session at fix.diagnose with a run-context item and a - FixContext pointing at a pre-existing failed run -- mirrors the P1 + CopilotContext pointing at a pre-existing failed run -- mirrors the P1 full-flow acceptance's seed, persisted through the SQL repo instead of the in-memory fake.""" s = _fix_session() repo.create_session( s, - FixContext(failed_run_id="TR-1"), + CopilotContext(failed_run_id="TR-1"), [ConversationItem(kind="run-context", seq=0, payload={"failed_run_id": "TR-1"})], ) repo.save_run(s.id, Run(id="TR-1", kind="original-failed", status="failed", immutable=True)) @@ -244,7 +244,7 @@ def _seed_checklist_session(repo: SqlCopilotRepository, errors: list[ChecklistEr s = _fix_session(entry_mode=EntryMode.FIX_CHECKLIST, current_state=PcState.CHECKLIST_DIAGNOSE) repo.create_session( s, - FixContext(source="checklist", checklist_errors=errors), + CopilotContext(source="checklist", checklist_errors=errors), [ConversationItem(kind="run-context", seq=0)], ) return s diff --git a/api/tests/unit_tests/services/workflow_copilot/test_reconcile.py b/api/tests/unit_tests/services/workflow_copilot/test_reconcile.py index e2ea51a9cae..d5a0f493108 100644 --- a/api/tests/unit_tests/services/workflow_copilot/test_reconcile.py +++ b/api/tests/unit_tests/services/workflow_copilot/test_reconcile.py @@ -24,7 +24,7 @@ from sqlalchemy.orm import sessionmaker import schedule.workflow_copilot_reconcile_task as mod from configs import dify_config -from core.workflow_copilot.models import ConversationItem, EntryMode, FixContext, Session +from core.workflow_copilot.models import ConversationItem, CopilotContext, EntryMode, Session from core.workflow_copilot.state import PcState from libs.datetime_utils import naive_utc_now from models.base import Base @@ -63,7 +63,7 @@ def _seed(repo: SqlCopilotRepository, state: PcState) -> Session: entry_mode=EntryMode.FIX, current_state=state, ) - repo.create_session(s, FixContext(), [ConversationItem(kind="run-context", seq=0)]) + repo.create_session(s, CopilotContext(), [ConversationItem(kind="run-context", seq=0)]) return s diff --git a/api/tests/unit_tests/services/workflow_copilot/test_repository.py b/api/tests/unit_tests/services/workflow_copilot/test_repository.py index cf8b01112c9..367470db2b4 100644 --- a/api/tests/unit_tests/services/workflow_copilot/test_repository.py +++ b/api/tests/unit_tests/services/workflow_copilot/test_repository.py @@ -19,8 +19,8 @@ from core.workflow_copilot.errors import ConflictError, NotFoundError from core.workflow_copilot.models import ( Checkpoint, ConversationItem, + CopilotContext, EntryMode, - FixContext, MutationIntent, NodeOutput, Run, @@ -67,7 +67,7 @@ def _make_domain_session(**overrides) -> DomainSession: def test_create_session_then_get_session_round_trips(repo: SqlCopilotRepository) -> None: domain_session = _make_domain_session() - fc = FixContext(failed_run_id="TR-1") + fc = CopilotContext(failed_run_id="TR-1") items = [ ConversationItem(seq=0, kind="run-context", payload={"a": 1}, at_version=1), ConversationItem(seq=1, kind="assistant-turn", payload={"b": 2}, at_version=1), @@ -90,7 +90,7 @@ def test_create_session_then_get_session_round_trips(repo: SqlCopilotRepository) assert loaded_session.entry_mode == EntryMode.FIX assert loaded_session.current_state == PcState.FIX_DIAGNOSE assert loaded_session.version == 1 - assert loaded_fc == FixContext(failed_run_id="TR-1", next_seq=2) + assert loaded_fc == CopilotContext(failed_run_id="TR-1", next_seq=2) def test_get_session_missing_id_raises_not_found(repo: SqlCopilotRepository) -> None: @@ -100,10 +100,10 @@ def test_get_session_missing_id_raises_not_found(repo: SqlCopilotRepository) -> def test_compare_and_advance_bumps_version_and_appends_commit_and_items(repo: SqlCopilotRepository) -> None: domain_session = _make_domain_session() - fc = FixContext(failed_run_id="TR-1") + fc = CopilotContext(failed_run_id="TR-1") repo.create_session(domain_session, fc, []) - next_fc = FixContext(failed_run_id="TR-1", staged_repair=[MutationIntent(op="set_node_config")]) + next_fc = CopilotContext(failed_run_id="TR-1", staged_repair=[MutationIntent(op="set_node_config")]) new_items = [ConversationItem(seq=0, kind="diagnosis", payload={"x": 1}, at_version=2)] new_version = repo.compare_and_advance( @@ -122,7 +122,7 @@ def test_compare_and_advance_stale_version_raises_conflict_and_leaves_row_unchan repo: SqlCopilotRepository, ) -> None: domain_session = _make_domain_session() - fc = FixContext(failed_run_id="TR-1") + fc = CopilotContext(failed_run_id="TR-1") repo.create_session(domain_session, fc, []) with pytest.raises(ConflictError): @@ -130,7 +130,7 @@ def test_compare_and_advance_stale_version_raises_conflict_and_leaves_row_unchan domain_session.id, base_version=999, next=PcState.FIX_PROPOSE, - fc=FixContext(), + fc=CopilotContext(), items=[], ) @@ -146,14 +146,14 @@ def test_compare_and_advance_missing_session_raises_not_found(repo: SqlCopilotRe "does-not-exist", base_version=1, next=PcState.FIX_PROPOSE, - fc=FixContext(), + fc=CopilotContext(), items=[], ) def test_compare_and_advance_duplicate_seq_raises_conflict(repo: SqlCopilotRepository) -> None: domain_session = _make_domain_session() - fc = FixContext(failed_run_id="TR-1") + fc = CopilotContext(failed_run_id="TR-1") seed_items = [ConversationItem(seq=0, kind="run-context", payload={}, at_version=1)] repo.create_session(domain_session, fc, seed_items) @@ -162,7 +162,7 @@ def test_compare_and_advance_duplicate_seq_raises_conflict(repo: SqlCopilotRepos domain_session.id, base_version=1, next=PcState.FIX_PROPOSE, - fc=FixContext(), + fc=CopilotContext(), items=[ConversationItem(seq=0, kind="diagnosis", payload={}, at_version=2)], ) @@ -177,7 +177,7 @@ def test_compare_and_advance_duplicate_seq_raises_conflict(repo: SqlCopilotRepos def test_create_checkpoint_then_get_checkpoint_round_trips(repo: SqlCopilotRepository) -> None: domain_session = _make_domain_session() - fc = FixContext(failed_run_id="TR-1") + fc = CopilotContext(failed_run_id="TR-1") repo.create_session(domain_session, fc, []) snap = Snapshot(session_id=domain_session.id, hash="h1", graph={"nodes": [1, 2, 3]}) @@ -290,7 +290,7 @@ def test_get_test_input_missing_id_raises_not_found(repo: SqlCopilotRepository) def test_list_conversation_returns_items_ordered_by_seq(repo: SqlCopilotRepository) -> None: domain_session = _make_domain_session() - fc = FixContext(failed_run_id="TR-1") + fc = CopilotContext(failed_run_id="TR-1") items = [ ConversationItem(seq=0, kind="run-context", payload={"a": 1}, at_version=1), ConversationItem(seq=1, kind="assistant-turn", payload={"b": 2}, at_version=1), @@ -299,7 +299,7 @@ def test_list_conversation_returns_items_ordered_by_seq(repo: SqlCopilotReposito more_items = [ConversationItem(seq=2, kind="diagnosis", payload={"c": 3}, at_version=2)] repo.compare_and_advance( - domain_session.id, base_version=1, next=PcState.FIX_PROPOSE, fc=FixContext(), items=more_items + domain_session.id, base_version=1, next=PcState.FIX_PROPOSE, fc=CopilotContext(), items=more_items ) conv = repo.list_conversation(domain_session.id) diff --git a/api/tests/unit_tests/services/workflow_copilot/test_serde.py b/api/tests/unit_tests/services/workflow_copilot/test_serde.py index 052207e2bab..1263e4450e8 100644 --- a/api/tests/unit_tests/services/workflow_copilot/test_serde.py +++ b/api/tests/unit_tests/services/workflow_copilot/test_serde.py @@ -1,6 +1,6 @@ -"""Round-trip tests for the ``FixContext`` JSON (de)serializer. +"""Round-trip tests for the ``CopilotContext`` JSON (de)serializer. -``FixContext`` is the per-session working state persisted as the +``CopilotContext`` is the per-session working state persisted as the ``context`` JSONB column on ``workflow_copilot_session_commits`` (P3a Task 4). This module verifies the pure (de)serializer is lossless and produces a JSON-safe dict, independent of any database concern. @@ -11,16 +11,16 @@ import json from core.workflow_copilot.models import ( ChangeSet, ChecklistError, + CopilotContext, Diagnosis, - FixContext, MutationIntent, Risk, ) -from services.workflow_copilot.serde import fix_context_from_dict, fix_context_to_dict +from services.workflow_copilot.serde import context_from_dict, context_to_dict -def _full_fix_context() -> FixContext: - return FixContext( +def _full_fix_context() -> CopilotContext: + return CopilotContext( failed_run_id="TR-1", diagnosis=Diagnosis( culprit_node_id="n1", @@ -57,15 +57,15 @@ def _full_fix_context() -> FixContext: def test_fully_populated_fix_context_round_trips(): fc = _full_fix_context() - result = fix_context_from_dict(fix_context_to_dict(fc)) + result = context_from_dict(context_to_dict(fc)) assert result == fc def test_empty_fix_context_round_trips(): - fc = FixContext() + fc = CopilotContext() - result = fix_context_from_dict(fix_context_to_dict(fc)) + result = context_from_dict(context_to_dict(fc)) assert result == fc @@ -73,7 +73,7 @@ def test_empty_fix_context_round_trips(): def test_to_dict_output_is_json_serializable(): fc = _full_fix_context() - d = fix_context_to_dict(fc) + d = context_to_dict(fc) # Must not raise -- this is what actually lands in the JSONB column. json.dumps(d) @@ -83,6 +83,33 @@ def test_from_dict_tolerates_missing_optional_keys(): # An older/partial persisted dict -- only a subset of keys present. d = {"failed_run_id": "TR-9"} - fc = fix_context_from_dict(d) + fc = context_from_dict(d) - assert fc == FixContext(failed_run_id="TR-9") + assert fc == CopilotContext(failed_run_id="TR-9") + + +def test_context_round_trips_build_fields(): + from core.workflow_copilot.models import CopilotContext + from services.workflow_copilot.serde import context_from_dict, context_to_dict + + fc = CopilotContext( + goal_text="Build a quarterly report workflow", + requirements={"currency": "USD", "prefer_audited": True}, + plan_items=["Retrieve", "Summarize", "Emit"], + plan_version_tag="v2", + resource_selection={"resource_ids": ["kb-company"], "conflict_policy": "audited"}, + built_node_ids=["start", "llm", "end"], + ) + out = context_from_dict(context_to_dict(fc)) + assert out == fc + + +def test_context_from_dict_defaults_build_fields_when_absent(): + from core.workflow_copilot.models import CopilotContext + from services.workflow_copilot.serde import context_from_dict + + fc = context_from_dict({"failed_run_id": "TR-9"}) + assert fc == CopilotContext(failed_run_id="TR-9") + assert fc.goal_text == "" + assert fc.requirements == {} + assert fc.plan_items == [] diff --git a/api/tests/unit_tests/services/workflow_copilot/test_service.py b/api/tests/unit_tests/services/workflow_copilot/test_service.py index a0be009eb94..429e973b79c 100644 --- a/api/tests/unit_tests/services/workflow_copilot/test_service.py +++ b/api/tests/unit_tests/services/workflow_copilot/test_service.py @@ -21,8 +21,8 @@ from core.workflow_copilot.models import ( Actor, ChecklistError, ConversationItem, + CopilotContext, EntryMode, - FixContext, Session, ) from core.workflow_copilot.state import PcState @@ -251,7 +251,7 @@ def _seed_free_session(repo: SqlCopilotRepository) -> Session: entry_mode=EntryMode.FIX, current_state=PcState.FIX_DIAGNOSE, ) - repo.create_session(s, FixContext(failed_run_id="TR-1"), [ConversationItem(kind="run-context", seq=0)]) + repo.create_session(s, CopilotContext(failed_run_id="TR-1"), [ConversationItem(kind="run-context", seq=0)]) return s @@ -266,7 +266,7 @@ def _seed_session_at(repo: SqlCopilotRepository, state: PcState) -> Session: entry_mode=EntryMode.FIX, current_state=state, ) - repo.create_session(s, FixContext(failed_run_id="TR-1"), [ConversationItem(kind="run-context", seq=0)]) + repo.create_session(s, CopilotContext(failed_run_id="TR-1"), [ConversationItem(kind="run-context", seq=0)]) return s