diff --git a/.planning/README.md b/.planning/README.md index becf84a..05908fb 100644 --- a/.planning/README.md +++ b/.planning/README.md @@ -129,6 +129,7 @@ The plan aligns to: 72. [Phase 78 - Conversation Runtime Prompt Recall And Bounded Capture Adoption](https://github.com/mikehostetler/jido_code/blob/main/.planning/phase-78-conversation-runtime-prompt-recall-and-bounded-capture-adoption.md): wire prompt-context retrieval and explicit bounded capture into the real conversation runtime so each turn can reuse the right short-term memory without replaying raw transcript history. 73. [Phase 79 - Prompt Memory Lifecycle Hardening And Contributor Convergence](https://github.com/mikehostetler/jido_code/blob/main/.planning/phase-79-prompt-memory-lifecycle-hardening-and-contributor-convergence.md): harden provider behavior, retention and cleanup policy, verification defaults, and contributor guidance so prompt memory remains bounded, explainable, and clearly separate from provenance and durable repository memory. 74. [Phase 80 - Source Code Graph Save-Triggered Refresh Adoption](https://github.com/mikehostetler/jido_code/blob/main/.planning/phase-80-source-code-graph-save-triggered-refresh-adoption.md): add repository-scoped source-change observation and debounced refresh scheduling so the `source_code` graph updates after code saves from either a human editor or product-managed LLM write path. +75. [Phase 81 - CodingPod Refactorer API Exposure](https://github.com/mikehostetler/jido_code/blob/main/.planning/phase-81-coding-pod-refactorer-api-exposure.md): expose the existing lazy `Refactorer` specialist through a first-class `AgentWorkspace.refactor_work/3,4` API while preserving CodingPod isolation, task-board visibility, workflow provenance, and deterministic product-owned specialist routing. Chronology note: Phase 55 now owns the previously landed `55.6.*` memory ontology and governed-reference verification so the planning sequence once @@ -164,6 +165,11 @@ top of the existing explicit analyze, load, refresh, status, query, and recovery lifecycle. Repo-scoped monitoring owns source-change observation while `AgentWorkspace` and `SourceCodeGraphPod` continue to own graph mutation. +CodingPod refactorer exposure note: Phase 81 closes the remaining gap between +the `CodingPod` topology and the public workspace API by specifying a +first-class `refactor_work/3,4` route to the existing lazy `Refactorer` +specialist. + ## Shared Conventions - Numbering: - Phases: `N` diff --git a/.planning/phase-81-coding-pod-refactorer-api-exposure.md b/.planning/phase-81-coding-pod-refactorer-api-exposure.md new file mode 100644 index 0000000..fd9513e --- /dev/null +++ b/.planning/phase-81-coding-pod-refactorer-api-exposure.md @@ -0,0 +1,78 @@ +# Phase 81 - CodingPod Refactorer API Exposure + + + + + +Back to index: [README](https://github.com/mikehostetler/jido_code/blob/main/.planning/README.md) + +## Relevant Shared APIs / Interfaces +- `lib/jido_code/agent_workspace.ex` +- `lib/jido_code/pods/coding_pod.ex` +- `lib/jido_code/agents/refactorer.ex` +- `lib/jido_code/agent_workspace/specialist_runner.ex` +- `lib/jido_code/agent_workspace/runtime_specialist_runner.ex` +- `lib/jido_code/agent_workspace/deterministic_specialist_runner.ex` +- `docs/developer/04-coding-pod-and-specialist-workflows.md` +- `docs/developer/05-specialist-prompts-context-and-tool-execution.md` + +## Relevant Assumptions / Defaults +- `Refactorer` is already part of the `CodingPod` topology as a lazy specialist. +- `AgentWorkspace` is the product-owned boundary for specialist routing; callers should not address pod internals directly. +- Refactoring work must preserve behavior and should follow the same bounded context, workflow provenance, task-board artifact, and pod metadata patterns as plan, execute, review, and explain. +- Refactorer exposure should not change `full_workflow/3,4` by default. Full workflow remains plan -> execute -> review unless a later phase explicitly adopts a refactor stage. + +[x] 81 Phase 81 - CodingPod Refactorer API Exposure + Expose the existing `CodingPod` refactorer specialist through a first-class product API so behavior-preserving refactoring can use the same runtime, provenance, and context boundaries as other specialist work. + + [x] 81.1 Section - Workspace Refactor Entry Point + Add the missing product-owned API surface for invoking the lazy refactorer specialist without leaking pod internals. + + [x] 81.1.1 Task - Add `AgentWorkspace.refactor_work/3,4` + Route refactoring requests through the existing per-work-item `CodingPod` lifecycle and specialist runner pattern. + + [x] 81.1.1.1 Subtask - Resolve workspace path, LLM selection, kernel, and coding pod exactly like `plan_work/4`, `execute_work/4`, `review_work/4`, and `explain_work/4`. + [x] 81.1.1.2 Subtask - Build refactor instructions through the shared `agent_instruction/4` path with semantic and memory context included when available. + [x] 81.1.1.3 Subtask - Ensure the `:refactorer` node lazily starts through `ensure_coding_specialist/3`. + [x] 81.1.1.4 Subtask - Return a bounded result map with refactoring output, original instruction, semantic context, memory context, workflow provenance summary, and LLM selection summary. + + [x] 81.1.2 Task - Persist refactor-stage pod metadata and task-board state + Keep refactorer runs visible and recoverable using the same product-owned runtime bookkeeping as other CodingPod specialists. + + [x] 81.1.2.1 Subtask - Persist a `:refactoring` stage result with `last_refactor` metadata on the coding pod. + [x] 81.1.2.2 Subtask - Ensure task-board stage events and artifacts are written by the shared specialist-run wrapper. + [x] 81.1.2.3 Subtask - Preserve workflow provenance capture for refactorer runs without exposing specialist-local internals to callers. + + [x] 81.2 Section - Product Routing And Documentation + Make refactorer exposure understandable to contributors and safe for future conversation or workflow adoption. + + [x] 81.2.1 Task - Update developer guidance for refactorer API exposure + Align the CodingPod and prompt-context guides with the new refactorer entrypoint. + + [x] 81.2.1.1 Subtask - Update the CodingPod guide so `refactor_work/3,4` is listed with plan, execute, review, and explain. + [x] 81.2.1.2 Subtask - Update the prompt-context guide so refactorer runs are included in the workspace-prepared specialist request list. + [x] 81.2.1.3 Subtask - Document that `full_workflow/3,4` remains plan -> execute -> review unless explicitly changed later. + + [x] 81.2.2 Task - Keep specialist selection deterministic + Preserve product-owned dispatch when refactorer work is later adopted by conversations or workflows. + + [x] 81.2.2.1 Subtask - Keep callers on `AgentWorkspace.refactor_work/3,4` rather than direct pod or agent calls. + [x] 81.2.2.2 Subtask - Ensure any future conversation routing maps explicit refactor intent to the refactorer deterministically. + [x] 81.2.2.3 Subtask - Keep refactorer unavailable or degraded states typed and product-facing. + + [x] 81.3 Section - Verification + Prove the refactorer is exposed through the same bounded runtime contract as the other CodingPod specialists. + + [x] 81.3.1 Task - Add focused workspace coverage + Verify `refactor_work/3,4` starts the refactorer lazily and records bounded runtime state. + + [x] 81.3.1.1 Subtask - Add coverage proving `AgentWorkspace.refactor_work/4` ensures kernel, coding pod, and `:refactorer` node before running. + [x] 81.3.1.2 Subtask - Add coverage proving returned refactor results include instruction, semantic context, memory context, workflow provenance, and LLM selection summary. + [x] 81.3.1.3 Subtask - Add coverage proving pod metadata records `last_refactor` without disturbing existing `last_plan`, `last_changes`, `last_review`, or `last_explanation` metadata. + + [x] 81.3.2 Task - Add integration coverage + Verify refactorer exposure fits the existing CodingPod isolation and lifecycle model. + + [x] 81.3.2.1 Subtask - Add an integration test proving different work items invoke isolated refactorer specialists in separate CodingPods. + [x] 81.3.2.2 Subtask - Add coverage proving completed work-item pod teardown ends refactorer context along with other specialist context. + [x] 81.3.2.3 Subtask - Run the relevant CodingPod, AgentWorkspace, and conversation-runtime suites after implementation. diff --git a/docs/developer/04-coding-pod-and-specialist-workflows.md b/docs/developer/04-coding-pod-and-specialist-workflows.md index a749cc1..b073ddc 100644 --- a/docs/developer/04-coding-pod-and-specialist-workflows.md +++ b/docs/developer/04-coding-pod-and-specialist-workflows.md @@ -101,7 +101,7 @@ It wraps each run with: - success or failure events - workflow provenance capture - pod metadata persistence such as `last_plan`, `last_changes`, - `last_review`, and `last_explanation` + `last_review`, `last_refactor`, and `last_explanation` ## Important Nuance: Specialist Context Is Per Specialist @@ -131,12 +131,26 @@ forward the planner's output as a new prompt into the coder and reviewer. Instead, each stage gets the requested instruction and the current repo state, plus any explicit semantic or memory context passed in through options. -## Refactorer Nuance +## Refactorer API -The pod topology includes a `refactorer`, but the main public workspace API -does not currently expose a dedicated `refactor_work/...` entrypoint. The node -exists in the pod contract even though it is not surfaced like plan, execute, -review, and explain. +The pod topology includes a lazy `refactorer`, exposed through +`AgentWorkspace.refactor_work/3,4`. + +Use the workspace entrypoint rather than direct pod or specialist calls. It: + +- routes through the existing per-work-item `CodingPod` +- lazily ensures the `refactorer` node +- preserves the shared specialist wrapper for task-board state, artifacts, + workflow provenance, semantic context, memory context, and pod metadata + +`full_workflow/3,4` still remains plan -> code -> review. Refactoring is an +explicit stage until a later phase changes default workflow orchestration. + +Conversation routing does not currently infer a dedicated refactor workflow. +If conversation or workflow surfaces adopt refactorer dispatch later, they +should map explicit refactor intent to `AgentWorkspace.refactor_work/3,4` and +return typed product-facing unavailable or degraded states when the refactorer +cannot run. ## Pod Teardown @@ -152,4 +166,3 @@ So the work-item boundary is also the practical lifetime boundary. Continue with [`05-specialist-prompts-context-and-tool-execution.md`](https://github.com/mikehostetler/jido_code/blob/main/docs/developer/05-specialist-prompts-context-and-tool-execution.md). - diff --git a/docs/developer/05-specialist-prompts-context-and-tool-execution.md b/docs/developer/05-specialist-prompts-context-and-tool-execution.md index f2a03d0..a52ff7e 100644 --- a/docs/developer/05-specialist-prompts-context-and-tool-execution.md +++ b/docs/developer/05-specialist-prompts-context-and-tool-execution.md @@ -26,7 +26,7 @@ This distinction is the most important thing to keep straight. ```mermaid flowchart TD - A["AgentWorkspace.plan/execute/review/explain"] + A["AgentWorkspace.plan/execute/review/refactor/explain"] B["Build semantic context and memory context"] C["Build user instruction text"] D["Build tool_context map"] @@ -50,8 +50,8 @@ flowchart TD ## What The Workspace Adds Before Calling The Specialist -For `plan_work`, `execute_work`, `review_work`, and `explain_work`, -`AgentWorkspace` prepares three important inputs: +For `plan_work`, `execute_work`, `review_work`, `refactor_work`, and +`explain_work`, `AgentWorkspace` prepares three important inputs: 1. a user instruction string 2. a `tool_context` map @@ -242,6 +242,10 @@ The workspace does not automatically feed the planner's result text into the coder and reviewer as a new explicit prompt. The stages are orchestrated sequentially, but prompt chaining is not the main contract. +`full_workflow/3,4` remains plan -> code -> review. `refactor_work/3,4` is an +explicit workspace entrypoint, not a default stage in full workflow +orchestration. + ### Relatedness Is Not Semantic Today If you send two unrelated prompts to the same specialist on the same work item, diff --git a/docs/developer/12-user-request-to-llm-message-path.md b/docs/developer/12-user-request-to-llm-message-path.md index aaea12b..f5ed3e8 100644 --- a/docs/developer/12-user-request-to-llm-message-path.md +++ b/docs/developer/12-user-request-to-llm-message-path.md @@ -132,6 +132,11 @@ That value is still visible as the request later in For words like `fix`, `change`, `edit`, or `patch`, it usually infers `:execute`. +The `refactorer` specialist is exposed through `AgentWorkspace.refactor_work/3,4`, +but conversation workflow inference does not currently choose a dedicated +`:refactor` workflow. Conversation adoption should be explicit if it is added +later. + This matters because it decides: - whether the turn needs governed work attachment diff --git a/lib/jido_code/agent_workspace.ex b/lib/jido_code/agent_workspace.ex index 72664fa..ff2fdec 100644 --- a/lib/jido_code/agent_workspace.ex +++ b/lib/jido_code/agent_workspace.ex @@ -48,11 +48,12 @@ defmodule JidoCode.AgentWorkspace do ## Work Execution - Functions for planning, executing, and reviewing work through agents. + Functions for planning, executing, refactoring, reviewing, and explaining + work through agents. """ alias JidoCode.AgentOS.Manager - alias JidoCode.Agents.{Coder, Explainer, Planner, RepoMonitor, Reviewer} + alias JidoCode.Agents.{Coder, Explainer, Planner, Refactorer, RepoMonitor, Reviewer} alias JidoCode.Control.Actor alias JidoCode.Conversations alias JidoCode.Conversations.Driver, as: ConversationDriver @@ -565,6 +566,70 @@ defmodule JidoCode.AgentWorkspace do end end + @doc """ + Refactors work by routing to the refactorer agent. + + Sends a behavior-preserving refactor request to the refactorer agent within + the WorkItem's CodingPod and returns the result. + + ## Examples + + iex> AgentWorkspace.refactor_work("repo-123", "work-item-1", "Extract shared login validation") + {:ok, %{refactoring: "..."}} + + """ + @spec refactor_work(managed_repo_id(), work_item_id(), String.t()) :: {:ok, map()} | {:error, term()} + def refactor_work(managed_repo_id, work_item_id, instruction) do + refactor_work(managed_repo_id, work_item_id, instruction, []) + end + + @spec refactor_work(managed_repo_id(), work_item_id(), String.t(), keyword()) :: + {:ok, map()} | {:error, term()} + def refactor_work(managed_repo_id, work_item_id, instruction, opts) when is_list(opts) do + with {:ok, workspace_path} <- + resolve_workspace_path(managed_repo_id, work_item_id, Keyword.get(opts, :workspace_path)), + {:ok, opts} <- put_llm_selection(managed_repo_id, opts), + {:ok, _kernel_name} <- ensure_kernel(managed_repo_id), + {:ok, _} <- ensure_coding_pod(managed_repo_id, work_item_id, workspace_path), + {:ok, provenance_context} <- + workflow_provenance_context( + managed_repo_id, + work_item_id, + workspace_path, + :refactor, + instruction, + opts + ), + {:ok, semantic_context} <- workflow_semantic_context(managed_repo_id, :refactor, opts), + {:ok, memory_context} <- workflow_memory_context(:refactor, opts), + {:ok, refactorer_pid} <- ensure_coding_specialist(managed_repo_id, work_item_id, :refactorer), + {:ok, response} <- + run_specialist( + Refactorer, + refactorer_pid, + agent_instruction(:refactor, instruction, semantic_context, memory_context), + managed_repo_id, + work_item_id, + workspace_path, + semantic_context, + memory_context, + provenance_context, + Keyword.put(opts, :work_item_id, work_item_id) + ) do + result = %{ + refactoring: normalize_specialist_result(response), + instruction: instruction, + semantic_context: semantic_context, + memory_context: memory_context, + workflow_provenance: provenance_summary(provenance_context), + llm_selection: llm_selection_summary(opts) + } + + persist_coding_pod_result(managed_repo_id, work_item_id, :refactoring, %{last_refactor: result}) + {:ok, result} + end + end + @doc """ Explains work with an optional explicit semantic source-graph context. """ @@ -1566,6 +1631,7 @@ defmodule JidoCode.AgentWorkspace do defp specialist_stage(Planner), do: :planning defp specialist_stage(Coder), do: :coding defp specialist_stage(Reviewer), do: :reviewing + defp specialist_stage(Refactorer), do: :refactoring defp specialist_stage(Explainer), do: :explaining defp specialist_stage(_other), do: :working @@ -1741,6 +1807,7 @@ defmodule JidoCode.AgentWorkspace do defp task_board_artifact_type(:planning), do: "plan" defp task_board_artifact_type(:coding), do: "draft" defp task_board_artifact_type(:reviewing), do: "review" + defp task_board_artifact_type(:refactoring), do: "refactor" defp task_board_artifact_type(:explaining), do: "explanation" defp task_board_artifact_type(_other), do: "artifact" @@ -2190,6 +2257,24 @@ defmodule JidoCode.AgentWorkspace do ) end + defp specialist_artifact_capture(:refactoring, provenance_context, agent_run_id, content, started_at, ended_at) do + CaptureEnvelope.patch( + session_id: provenance_context.session_id, + actor_id: provenance_context.actor_id, + workflow: provenance_context.workflow, + work_item_id: provenance_context.work_item_id, + agent_run_id: agent_run_id, + content: stringify_artifact_content(content), + started_at: started_at, + ended_at: ended_at, + model: provenance_model_name(provenance_context), + revision: provenance_context.revision, + governed_references: provenance_context.governed_references, + related_resources: provenance_context.related_resources, + metadata: provenance_metadata(provenance_context) + ) + end + defp specialist_artifact_capture(:reviewing, provenance_context, agent_run_id, content, started_at, ended_at) do CaptureEnvelope.review( session_id: provenance_context.session_id, diff --git a/lib/jido_code/conversations.ex b/lib/jido_code/conversations.ex index a23902e..b151d94 100644 --- a/lib/jido_code/conversations.ex +++ b/lib/jido_code/conversations.ex @@ -55,6 +55,7 @@ defmodule JidoCode.Conversations do @spec start(map()) :: {:ok, start_result()} | {:error, term()} def start(%{} = attrs) do with {:ok, context} <- build_start_context(attrs), + :ok <- ensure_start_work_item_conversation_available(context), {:ok, conversation} <- Conversation.create(conversation_attrs(context), actor: context.actor) do {:ok, %{ @@ -543,36 +544,84 @@ defmodule JidoCode.Conversations do actor, now ) do - case Conversation.update( - conversation, - %{ - work_item_id: optional_id(work_item), - attachment_mode: attachment_mode, - scope: scope, - conversation_metadata: - steering_conversation_metadata( - conversation, - payload, - shared_context, - work_item, - work_action, - attachment_mode, - scope, - now - ), - last_activity_at: now - }, - actor: actor - ) do - {:ok, updated_conversation} -> - _ = LongTermProvenance.capture_work_attachment(updated_conversation, actor: actor) - {:ok, updated_conversation} + with :ok <- ensure_work_item_conversation_available(conversation, work_item, actor) do + case Conversation.update( + conversation, + %{ + work_item_id: optional_id(work_item), + attachment_mode: attachment_mode, + scope: scope, + conversation_metadata: + steering_conversation_metadata( + conversation, + payload, + shared_context, + work_item, + work_action, + attachment_mode, + scope, + now + ), + last_activity_at: now + }, + actor: actor + ) do + {:ok, updated_conversation} -> + _ = LongTermProvenance.capture_work_attachment(updated_conversation, actor: actor) + {:ok, updated_conversation} - other -> - other + other -> + other + end end end + defp ensure_start_work_item_conversation_available(%{work_item: %{id: work_item_id}, actor: actor}) + when is_binary(work_item_id) do + case active_for_work_item(work_item_id, actor: actor) do + {:ok, nil} -> + :ok + + {:ok, %Conversation{} = active_conversation} -> + {:error, active_work_item_conflict(work_item_id, active_conversation)} + + {:error, reason} -> + {:error, reason} + end + end + + defp ensure_start_work_item_conversation_available(_context), do: :ok + + defp ensure_work_item_conversation_available(%Conversation{} = conversation, %{id: work_item_id}, actor) + when is_binary(work_item_id) do + case active_for_work_item(work_item_id, actor: actor) do + {:ok, nil} -> + :ok + + {:ok, %Conversation{id: active_conversation_id}} when active_conversation_id == conversation.id -> + :ok + + {:ok, %Conversation{} = active_conversation} -> + {:error, active_work_item_conflict(work_item_id, active_conversation, conversation.id)} + + {:error, reason} -> + {:error, reason} + end + end + + defp ensure_work_item_conversation_available(_conversation, _work_item, _actor), do: :ok + + defp active_work_item_conflict(work_item_id, %Conversation{} = active_conversation, attempted_conversation_id \\ nil) do + details = + %{ + work_item_id: work_item_id, + active_conversation_id: active_conversation.id + } + |> maybe_put(:attempted_conversation_id, attempted_conversation_id) + + {:active_work_item_conversation_exists, details} + end + defp steering_conversation_metadata( conversation, payload, diff --git a/lib/jido_code/conversations/event_record.ex b/lib/jido_code/conversations/event_record.ex index 7f1672a..8a8cea9 100644 --- a/lib/jido_code/conversations/event_record.ex +++ b/lib/jido_code/conversations/event_record.ex @@ -26,7 +26,6 @@ defmodule JidoCode.Conversations.EventRecord do primary? true accept [ - :id, :conversation_id, :sequence, :name, diff --git a/lib/jido_code/conversations/persistence.ex b/lib/jido_code/conversations/persistence.ex index df3b89d..16d4947 100644 --- a/lib/jido_code/conversations/persistence.ex +++ b/lib/jido_code/conversations/persistence.ex @@ -36,14 +36,14 @@ defmodule JidoCode.Conversations.Persistence do |> Enum.filter(&(&1.sequence > Map.get(previous_state, :event_sequence, 0))) case Repo.transaction(fn -> - with :ok <- persist_events(new_events), - {:ok, _record} <- upsert_snapshot(Snapshot.from_state(next_state)) do - :ok + with {:ok, event_notifications} <- persist_events(new_events), + {:ok, _record, snapshot_notifications} <- upsert_snapshot(Snapshot.from_state(next_state)) do + event_notifications ++ snapshot_notifications else {:error, reason} -> Repo.rollback(reason) end end) do - {:ok, :ok} -> :ok + {:ok, notifications} -> notify_after_commit(notifications) {:error, reason} -> {:error, reason} end else @@ -56,11 +56,11 @@ defmodule JidoCode.Conversations.Persistence do if enabled?() do case Repo.transaction(fn -> case upsert_snapshot(snapshot) do - {:ok, _record} -> :ok + {:ok, _record, notifications} -> notifications {:error, reason} -> Repo.rollback(reason) end end) do - {:ok, :ok} -> :ok + {:ok, notifications} -> notify_after_commit(notifications) {:error, reason} -> {:error, reason} end else @@ -117,20 +117,34 @@ defmodule JidoCode.Conversations.Persistence do end defp persist_events(events) when is_list(events) do - Enum.reduce_while(events, :ok, fn %Event{} = event, :ok -> + events + |> Enum.reduce_while({:ok, []}, fn %Event{} = event, {:ok, notifications} -> case Ash.create(EventRecord, event_record_attrs(event), action: :append, domain: JidoCode.Conversations, - actor: @persistence_actor + actor: @persistence_actor, + return_notifications?: true ) do - {:ok, _record} -> {:cont, :ok} + {:ok, _record, event_notifications} -> {:cont, {:ok, [event_notifications | notifications]}} {:error, reason} -> {:halt, {:error, reason}} end end) + |> case do + {:ok, notifications} -> {:ok, notifications |> Enum.reverse() |> List.flatten()} + {:error, reason} -> {:error, reason} + end end defp upsert_snapshot(snapshot) do - SnapshotRecord.upsert_for_conversation(snapshot_record_attrs(snapshot), actor: @persistence_actor) + SnapshotRecord.upsert_for_conversation(snapshot_record_attrs(snapshot), + actor: @persistence_actor, + return_notifications?: true + ) + end + + defp notify_after_commit(notifications) do + _remaining = Ash.Notifier.notify(notifications) + :ok end defp event_record_attrs(%Event{} = event) do diff --git a/test/jido_code/agent_os_integration_test.exs b/test/jido_code/agent_os_integration_test.exs index c9dfb05..d008e81 100644 --- a/test/jido_code/agent_os_integration_test.exs +++ b/test/jido_code/agent_os_integration_test.exs @@ -144,6 +144,58 @@ defmodule JidoCode.AgentOSIntegrationTest do assert get_in(restored_status, [:metadata, :last_plan, :plan]) =~ "deterministic planner response" assert work_item_id in AgentWorkspace.active_work_items(managed_repo_id) end + + test "19.7.2.4 refactorer runs stay isolated and end with pod teardown" do + managed_repo_id = "test-repo-#{System.unique_integer()}" + work_item_1 = "work-#{System.unique_integer()}-one" + work_item_2 = "work-#{System.unique_integer()}-two" + workspace_path = create_workspace_path!() + + on_exit(fn -> File.rm_rf!(workspace_path) end) + + assert {:ok, result_1} = + AgentWorkspace.refactor_work( + managed_repo_id, + work_item_1, + "Refactor first work item", + workspace_path: workspace_path + ) + + assert {:ok, result_2} = + AgentWorkspace.refactor_work( + managed_repo_id, + work_item_2, + "Refactor second work item", + workspace_path: workspace_path + ) + + assert result_1.refactoring =~ "Refactor first work item" + assert result_2.refactoring =~ "Refactor second work item" + + status_1 = Manager.pod_status(managed_repo_id, "coding-pod-#{work_item_1}") + status_2 = Manager.pod_status(managed_repo_id, "coding-pod-#{work_item_2}") + pod_pid_1 = get_in(status_1, [:metadata, :runtime_pid]) + pod_pid_2 = get_in(status_2, [:metadata, :runtime_pid]) + + assert is_pid(pod_pid_1) + assert is_pid(pod_pid_2) + assert pod_pid_1 != pod_pid_2 + + assert {:ok, refactorer_pid_1} = Pod.lookup_node(pod_pid_1, :refactorer) + assert {:ok, refactorer_pid_2} = Pod.lookup_node(pod_pid_2, :refactorer) + assert refactorer_pid_1 != refactorer_pid_2 + + assert get_in(status_1, [:metadata, :last_refactor, :instruction]) == "Refactor first work item" + assert get_in(status_2, [:metadata, :last_refactor, :instruction]) == "Refactor second work item" + + assert :ok = AgentWorkspace.complete_work(managed_repo_id, work_item_1) + + completed_status = Manager.pod_status(managed_repo_id, "coding-pod-#{work_item_1}") + assert get_in(completed_status, [:metadata, :runtime_pid]) == nil + assert get_in(completed_status, [:metadata, :runtime_status]) == :completed + refute work_item_1 in AgentWorkspace.active_work_items(managed_repo_id) + assert work_item_2 in AgentWorkspace.active_work_items(managed_repo_id) + end end describe "19.7.3 Agent collaboration scenarios" do @@ -246,6 +298,37 @@ defmodule JidoCode.AgentOSIntegrationTest do assert Enum.any?(task_board_state.activity_log, &(&1.type == "reviewing.started")) assert Enum.any?(task_board_state.activity_log, &(&1.type == "reviewing.completed")) end + + test "19.7.3.4 refactorer workflow persists refactor output in pod state" do + managed_repo_id = "test-repo-#{System.unique_integer()}" + work_item_id = "work-#{System.unique_integer()}" + workspace_path = create_workspace_path!() + + on_exit(fn -> File.rm_rf!(workspace_path) end) + + assert {:ok, result} = + AgentWorkspace.refactor_work( + managed_repo_id, + work_item_id, + "Refactor with persisted result", + workspace_path: workspace_path + ) + + assert result.refactoring =~ "deterministic refactorer response" + + pod_status = Manager.pod_status(managed_repo_id, "coding-pod-#{work_item_id}") + assert get_in(pod_status, [:metadata, :last_refactor, :refactoring]) =~ "deterministic refactorer response" + + task_board_state = pod_node_state(managed_repo_id, work_item_id, :task_board) + + assert Enum.any?(task_board_state.artifacts, fn artifact -> + artifact.type == "refactor" and + String.contains?(artifact.content, "deterministic refactorer response") + end) + + assert Enum.any?(task_board_state.activity_log, &(&1.type == "refactoring.started")) + assert Enum.any?(task_board_state.activity_log, &(&1.type == "refactoring.completed")) + end end describe "19.7.4 End-to-end conversation scenarios" do diff --git a/test/jido_code/agent_workspace_test.exs b/test/jido_code/agent_workspace_test.exs index 05907db..5e06ac3 100644 --- a/test/jido_code/agent_workspace_test.exs +++ b/test/jido_code/agent_workspace_test.exs @@ -27,6 +27,7 @@ defmodule JidoCode.AgentWorkspaceTest do alias JidoCode.Control.{Actor, ManagedRepo} alias JidoCode.Operations.Ingress alias JidoCode.Projects.Project + alias Jido.Pod describe "kernel lifecycle" do test "ensure_kernel creates or returns existing kernel" do @@ -362,6 +363,155 @@ defmodule JidoCode.AgentWorkspaceTest do assert result.feedback =~ "deterministic reviewer response" end + test "refactor_work returns ok with refactoring map and bounded context" do + managed_repo_id = "test-repo-#{System.unique_integer()}" + work_item_id = "work-#{System.unique_integer()}" + workspace_path = create_workspace_path!() + + on_exit(fn -> File.rm_rf!(workspace_path) end) + + memory_graph = %{ + workflow: :refactor, + graph: %{ + ready?: true, + stale?: false, + state: :ready, + current_revision: "rev-81-refactor" + }, + freshness: %{ + state: :ready, + label: "Memory graph ready" + }, + policy: %{ + intent: :implementation_constraints, + follow_up_intent: :work_item, + memory_kinds: [:decision, :invariant, :convention, :known_issue, :pattern], + provenance_kinds: [:plan, :review, :patch] + }, + selection: %{ + governed_references: [ + %{kind: :work_item, id: work_item_id, label: "Current work item"} + ], + memory_resources: ["https://example.test/memory#refactor-rule"], + provenance_resources: ["https://example.test/workflow_provenance#patch-81"], + related_resources: [ + "https://example.test/memory#refactor-rule", + "https://example.test/workflow_provenance#patch-81" + ], + selected_items: %{ + memories: [ + %{ + memory_kind: "Invariant", + content: "Preserve public behavior while extracting helpers." + } + ], + provenance: [ + %{ + provenance_kind: "Patch", + content: "Previous patch touched the same module." + } + ] + } + } + } + + assert {:ok, result} = + AgentWorkspace.refactor_work( + managed_repo_id, + work_item_id, + "Extract shared validation without changing behavior", + workspace_path: workspace_path, + enabled?: true, + memory_graph: memory_graph, + llm_selection: %{"provider" => "openai", "model" => "gpt-5"} + ) + + assert is_map(result) + assert result.refactoring =~ "deterministic refactorer response" + assert result.refactoring =~ "Workflow: refactor" + assert result.refactoring =~ "Memory context:" + assert result.instruction == "Extract shared validation without changing behavior" + assert result.semantic_context == %{} + assert result.memory_context.workflow == :refactor + assert result.memory_context.graph["ready?"] == true + assert result.memory_context.policy["intent"] == "implementation_constraints" + assert result.workflow_provenance.workflow == :refactor + + assert result.llm_selection == %{ + provider: "openai", + model: "gpt-5", + model_spec: "openai:gpt-5", + source: :explicit + } + + assert Manager.kernel_exists?(managed_repo_id) + assert work_item_id in AgentWorkspace.active_work_items(managed_repo_id) + + pod_status = Manager.pod_status(managed_repo_id, "coding-pod-#{work_item_id}") + assert get_in(pod_status, [:metadata, :runtime_status]) == :refactoring + assert get_in(pod_status, [:metadata, :last_refactor, :refactoring]) =~ "deterministic refactorer response" + assert get_in(pod_status, [:metadata, :last_refactor, :llm_selection, :model_spec]) == "openai:gpt-5" + + assert {:ok, _refactorer_pid} = Pod.lookup_node(get_in(pod_status, [:metadata, :runtime_pid]), :refactorer) + end + + test "refactor_work records last_refactor without disturbing other specialist metadata" do + managed_repo_id = "test-repo-#{System.unique_integer()}" + work_item_id = "work-#{System.unique_integer()}" + workspace_path = create_workspace_path!() + + on_exit(fn -> File.rm_rf!(workspace_path) end) + + assert {:ok, plan_result} = + AgentWorkspace.plan_work( + managed_repo_id, + work_item_id, + "Plan before refactor", + workspace_path: workspace_path + ) + + assert {:ok, changes_result} = + AgentWorkspace.execute_work( + managed_repo_id, + work_item_id, + "Code before refactor", + workspace_path: workspace_path + ) + + assert {:ok, review_result} = + AgentWorkspace.review_work( + managed_repo_id, + work_item_id, + "Review before refactor", + workspace_path: workspace_path + ) + + assert {:ok, explanation_result} = + AgentWorkspace.explain_work( + managed_repo_id, + work_item_id, + "Explain before refactor", + workspace_path: workspace_path + ) + + assert {:ok, refactor_result} = + AgentWorkspace.refactor_work( + managed_repo_id, + work_item_id, + "Refactor after existing specialist runs", + workspace_path: workspace_path + ) + + pod_status = Manager.pod_status(managed_repo_id, "coding-pod-#{work_item_id}") + + assert get_in(pod_status, [:metadata, :last_plan, :plan]) == plan_result.plan + assert get_in(pod_status, [:metadata, :last_changes, :changes]) == changes_result.changes + assert get_in(pod_status, [:metadata, :last_review, :feedback]) == review_result.feedback + assert get_in(pod_status, [:metadata, :last_explanation, :explanation]) == explanation_result.explanation + assert get_in(pod_status, [:metadata, :last_refactor, :refactoring]) == refactor_result.refactoring + assert get_in(pod_status, [:metadata, :runtime_status]) == :refactoring + end + test "full_workflow returns ok with plan, changes, and feedback" do managed_repo_id = "test-repo-#{System.unique_integer()}" work_item_id = "work-#{System.unique_integer()}" @@ -388,6 +538,7 @@ defmodule JidoCode.AgentWorkspaceTest do plan_work_item_id = "plan-#{System.unique_integer()}" coding_work_item_id = "code-#{System.unique_integer()}" review_work_item_id = "review-#{System.unique_integer()}" + refactor_work_item_id = "refactor-#{System.unique_integer()}" explain_work_item_id = "explain-#{System.unique_integer()}" previous = Application.get_env(:jido_code, :memory_graph_enabled, false) @@ -422,6 +573,14 @@ defmodule JidoCode.AgentWorkspaceTest do workspace_path: workspace_path ) + assert {:ok, refactor_result} = + AgentWorkspace.refactor_work( + managed_repo_id, + refactor_work_item_id, + "Refactor with provenance", + workspace_path: workspace_path + ) + assert {:ok, explain_result} = AgentWorkspace.explain_work( managed_repo_id, @@ -433,6 +592,7 @@ defmodule JidoCode.AgentWorkspaceTest do assert plan_result.workflow_provenance.workflow == :plan assert code_result.workflow_provenance.workflow == :execute assert review_result.workflow_provenance.workflow == :review + assert refactor_result.workflow_provenance.workflow == :refactor assert explain_result.workflow_provenance.workflow == :explain assert {:ok, plan_query} = @@ -491,6 +651,24 @@ defmodule JidoCode.AgentWorkspaceTest do assert review_query.row_count == 1 + assert {:ok, refactor_query} = + AgentWorkspace.query_memory_graph( + managed_repo_id, + workspace_path, + """ + SELECT ?session ?patch + WHERE { + ?session a jido:WorkSession ; + jido:sessionId "#{refactor_result.workflow_provenance.session_id}" ; + jido:hasPatch ?patch . + } + """, + graph_name: "workflow_provenance", + allow_stale?: true + ) + + assert refactor_query.row_count == 1 + assert {:ok, explain_query} = AgentWorkspace.query_memory_graph( managed_repo_id, diff --git a/test/jido_code/conversations_driver_test.exs b/test/jido_code/conversations_driver_test.exs index a78a786..1075733 100644 --- a/test/jido_code/conversations_driver_test.exs +++ b/test/jido_code/conversations_driver_test.exs @@ -28,6 +28,37 @@ defmodule JidoCode.ConversationsDriverTest do assert :ok = Driver.stop(conversation.id) end + test "driver persistence dispatches Ash notifications after committing transactions" do + original_missed_notifications = Application.get_env(:ash, :missed_notifications, :__missing__) + Application.put_env(:ash, :missed_notifications, :raise) + + on_exit(fn -> + case original_missed_notifications do + :__missing__ -> Application.delete_env(:ash, :missed_notifications) + value -> Application.put_env(:ash, :missed_notifications, value) + end + end) + + managed_repo = managed_repo_fixture!("driver-persistence-notifications") + + assert {:ok, %{conversation: conversation}} = + Driver.start_conversation(%{ + managed_repo_id: managed_repo.id, + source: "conversation", + objective: "Persist conversation changes without missed Ash notifications." + }) + + assert {:ok, snapshot} = + Driver.handle_command( + conversation.id, + %{type: "turn.submit", payload: %{instruction: "Exercise transactional persistence."}}, + actor: Actor.operator_actor() + ) + + assert snapshot.event_count > 0 + assert :ok = Driver.stop(conversation.id) + end + test "driver admits work and control commands through distinct product-owned shapes" do managed_repo = managed_repo_fixture!("driver-commands") @@ -402,4 +433,4 @@ defmodule JidoCode.ConversationsDriverTest do managed_repo end -end \ No newline at end of file +end diff --git a/test/jido_code/conversations_test.exs b/test/jido_code/conversations_test.exs index 8eb97d5..53cb000 100644 --- a/test/jido_code/conversations_test.exs +++ b/test/jido_code/conversations_test.exs @@ -406,6 +406,51 @@ defmodule JidoCode.ConversationsTest do assert List.last(persisted_work_item.audit_log)["action"] == "steered" end + test "steer_work refuses to attach a second active conversation to the same governed work" do + managed_repo = managed_repo_fixture!("conversation-steer-existing-active-work") + work_item = work_item_fixture!(managed_repo, "operator-existing-active-steer") + + assert {:ok, %{conversation: active_conversation}} = + Conversations.open_or_resume_for_work_item( + work_item.id, + actor: Actor.operator_actor(%{"id" => "operator-active-work-open"}) + ) + + {:ok, %{conversation: repo_conversation}} = + Conversations.start(%{ + managed_repo_id: managed_repo.id, + source: "conversation", + objective: "Start a second repo-scoped conversation before steering." + }) + + assert {:error, + {:active_work_item_conversation_exists, + %{ + work_item_id: work_item_id, + active_conversation_id: active_conversation_id, + attempted_conversation_id: attempted_conversation_id + }}} = + Conversations.steer_work( + repo_conversation, + %{ + work_item_id: work_item.id, + instruction: "Attempt to redirect onto already-active governed work." + }, + actor: Actor.operator_actor(%{"id" => "operator-second-active-work"}), + shared_context: %{} + ) + + assert work_item_id == work_item.id + assert active_conversation_id == active_conversation.id + assert attempted_conversation_id == repo_conversation.id + + assert {:ok, [persisted_repo_conversation]} = + Conversation.read(query: [filter: [id: repo_conversation.id]], actor: Actor.operator_actor()) + + assert persisted_repo_conversation.work_item_id == nil + assert persisted_repo_conversation.scope == :repo_scoped + end + test "steer_work preserves attached governed work and conversation origin metadata" do managed_repo = managed_repo_fixture!("conversation-steer-origin") diff --git a/test/jido_code/phase_forty_seven_integration_test.exs b/test/jido_code/phase_forty_seven_integration_test.exs index e201412..e00b408 100644 --- a/test/jido_code/phase_forty_seven_integration_test.exs +++ b/test/jido_code/phase_forty_seven_integration_test.exs @@ -172,7 +172,7 @@ defmodule JidoCode.PhaseFortySevenIntegrationTest do assert resumed_completion_snapshot.shared_context["work_resolution"]["work_item_id"] == work_item_id end - test "equivalent productive repo conversations reuse the same governed work item" do + test "equivalent productive repo conversations cannot claim the same active governed work item" do {project, managed_repo} = managed_repo_fixture!("work-dedup") tracked_conversations = tracked_conversations!(managed_repo.id) @@ -215,7 +215,13 @@ defmodule JidoCode.PhaseFortySevenIntegrationTest do track_conversation!(tracked_conversations, second_conversation.id) refute second_conversation.id == first_conversation.id - assert {:ok, _second_running_snapshot} = + assert {:error, + {:active_work_item_conversation_exists, + %{ + work_item_id: work_item_id, + active_conversation_id: active_conversation_id, + attempted_conversation_id: attempted_conversation_id + }}} = AgentWorkspace.handle_conversation_command( second_conversation.id, %{ @@ -225,25 +231,15 @@ defmodule JidoCode.PhaseFortySevenIntegrationTest do actor: Actor.operator_actor(%{"id" => "operator-phase47-dedup-second-turn"}) ) - second_completed_snapshot = - eventually_snapshot!(second_conversation.id, fn snapshot -> - snapshot.work_item_id == first_work_item_id and - snapshot.scope == :work_item_scoped and - snapshot.active_turn == nil and snapshot.active_child_work == nil and - snapshot.work_resolution["action"] in ["suppressed_duplicate", "reprioritized"] - end) - - assert second_completed_snapshot.work_item_id == first_work_item_id - assert second_completed_snapshot.work_resolution["work_item_id"] == first_work_item_id - assert second_completed_snapshot.shared_context["work_item_id"] == first_work_item_id - - assert {:ok, [persisted_work_item]} = - WorkItem.read( - query: [filter: [id: first_work_item_id], limit: 1], - actor: Actor.operator_actor() - ) + assert work_item_id == first_work_item_id + assert active_conversation_id == first_conversation.id + assert attempted_conversation_id == second_conversation.id - assert List.last(persisted_work_item.audit_log)["action"] in ["suppressed_duplicate", "reprioritized"] + assert {:ok, second_snapshot} = AgentWorkspace.conversation_snapshot(second_conversation.id) + assert second_snapshot.scope == :repo_scoped + assert second_snapshot.work_item_id == nil + assert second_snapshot.active_turn == nil + assert second_snapshot.active_child_work == nil end defp tracked_conversations!(managed_repo_id) do