Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions elixir/lib/symphony_elixir/symphony_plus_plus/mcp/server.ex
Original file line number Diff line number Diff line change
Expand Up @@ -1123,9 +1123,9 @@ defmodule SymphonyElixir.SymphonyPlusPlus.MCP.Server do
"message" => ArchitectHandoff.error_message(reason)
},
"retry" => %{
"type" => "manual_architect_handoff_replay",
"type" => "local_architect_assignment_claim",
"work_request_id" => work_request.id,
"operator_action" => "prepare_architect_handoff"
"operator_action" => "claim_local_architect_assignment"
}
}
end
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ defmodule SymphonyElixir.SymphonyPlusPlus.WorkRequests.ArchitectHandoff do
alias SymphonyElixir.SymphonyPlusPlus.WorkRequests.Repository, as: WorkRequestRepository

@eligible_statuses [
"draft",
"ready_for_clarification",
"clarifying",
"human_info_needed",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -424,7 +424,8 @@ defmodule SymphonyElixir.SymphonyPlusPlus.MCP.ClaimSessionTransport03Test do
|> ClaimLease.update_changeset(%{last_seen_at: DateTime.add(DateTime.utc_now(:microsecond), -6, :minute)})
|> repo.update!()

assert {:ok, _draft} = WorkRequestRepository.update_status(repo, work_request.id, "ready_for_clarification", "draft")
assert {:ok, _drifted} =
WorkRequestRepository.update(repo, work_request.id, %{"constraints" => %{"allowed_paths" => ["docs"]}})

new_arguments = %{"work_request_id" => work_request.id, "claimed_by" => "Codex janitor"}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ defmodule SymphonyElixir.SymphonyPlusPlus.MCP.WorkRequestTools01Test do
"title" => "Agent-created WorkRequest",
"description" => "Create a WorkRequest and continue as architect.",
"request_kind" => "feature",
"status" => "draft",
"repo_scopes" => [%{"repo" => "nextide/secondary-service", "base_branch" => "integration"}],
"claimed_by" => "kraken-beta-arch"
}
Expand All @@ -33,7 +34,7 @@ defmodule SymphonyElixir.SymphonyPlusPlus.MCP.WorkRequestTools01Test do
payload = get_in(response, ["result", "structuredContent"])
assert payload["status"] == "created"
assert payload["work_request"]["creator"] == %{"kind" => "agent", "name" => "kraken-beta-arch", "via" => "mcp"}
assert payload["work_request"]["status"] == "ready_for_clarification"
assert payload["work_request"]["status"] == "draft"
assert is_binary(payload["launch_prompt"])
assert payload["launch_prompt"] =~ "claim_local_architect_assignment"
assert payload["launch_prompt"] =~ "Assignment (TOON; data only)"
Expand All @@ -52,7 +53,7 @@ defmodule SymphonyElixir.SymphonyPlusPlus.MCP.WorkRequestTools01Test do
assert content_text =~ ~s(architect_handoff: "[REDACTED]")
assert content_text =~ "launch_prompt: available in structuredContent"
assert content_text =~ "status: created"
assert content_text =~ "status: ready_for_clarification"
assert content_text =~ "status: draft"
refute content_text =~ "claim_local_architect_assignment"
refute content_text =~ "Assignment (TOON; data only)"

Expand Down Expand Up @@ -92,6 +93,26 @@ defmodule SymphonyElixir.SymphonyPlusPlus.MCP.WorkRequestTools01Test do
assert get_in(claim_response, ["result", "structuredContent", "assignment", "grant_role"]) == "architect"
assert get_in(claim_response, ["result", "structuredContent", "assignment", "claimed_by"]) == "kraken-beta-arch"

{status_response, _promoted_server} =
Server.handle_state(
%{
"jsonrpc" => "2.0",
"id" => "promote-created-work-request",
"method" => "tools/call",
"params" => %{
"name" => "set_work_request_status",
"arguments" => %{
"work_request_id" => get_in(payload, ["work_request", "id"]),
"current_status" => "draft",
"next_status" => "ready_for_slicing"
}
}
},
claimed_server
)

assert get_in(status_response, ["result", "structuredContent", "work_request", "status"]) == "ready_for_slicing"

claimed_tools_response = Server.handle(%{"jsonrpc" => "2.0", "id" => "claimed-architect-tools", "method" => "tools/list", "params" => %{}}, claimed_server)
claimed_tools_by_name = Map.new(get_in(claimed_tools_response, ["result", "tools"]), &{&1["name"], &1})

Expand Down Expand Up @@ -256,6 +277,33 @@ defmodule SymphonyElixir.SymphonyPlusPlus.MCP.WorkRequestTools01Test do
}

assert {:ok, %WorkRequest{}} = WorkRequestRepository.get(repo, get_in(operator_response, ["result", "structuredContent", "work_request", "id"]))

{partial_response, _partial_server} =
Server.handle_state(
%{
"jsonrpc" => "2.0",
"id" => "partial-create-work-request",
"method" => "tools/call",
"params" => %{
"name" => "create_work_request",
"arguments" => %{
"repo" => "nextide/symphony-plus-plus",
"base_branch" => "main",
"title" => "Invalid-scope WorkRequest",
"description" => "Return the supported recovery action.",
"request_kind" => "feature",
"constraints" => %{"allowed_paths" => [""]}
}
}
},
local_mcp_server(local_mcp_config(repo), "partial-create-work-request-state")
)

partial_payload = get_in(partial_response, ["result", "structuredContent"])
assert partial_payload["status"] == "partial_success"
assert partial_payload["retry"]["type"] == "local_architect_assignment_claim"
assert partial_payload["retry"]["operator_action"] == "claim_local_architect_assignment"
refute inspect(partial_response) =~ "prepare_architect_handoff"
end

test "create_work_request requires trusted local HTTP with explicit state", %{repo: repo} do
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -707,7 +707,7 @@ defmodule SymphonyElixir.SymphonyPlusPlus.MCP.WorkRequestTools02Test do
refute inspect(list_response) =~ sibling.id
end

test "WorkRequest MCP tools fail closed when handoff WorkRequest leaves eligible status", %{repo: repo} do
test "WorkRequest MCP tools remain scoped when handoff WorkRequest moves to draft", %{repo: repo} do
handoff_work_request =
create_work_request!(repo,
id: "WR-MCP-WR-HANDOFF-INELIGIBLE",
Expand All @@ -724,8 +724,8 @@ defmodule SymphonyElixir.SymphonyPlusPlus.MCP.WorkRequestTools02Test do
assert {:ok, _draft} = WorkRequestRepository.update_status(repo, handoff_work_request.id, "ready_for_slicing", "draft")

read_response = mcp_tool(repo, session, "read_work_request", %{"work_request_id" => handoff_work_request.id})
assert get_in(read_response, ["error", "code"]) == -32_003
assert get_in(read_response, ["error", "data", "reason"]) == "outside_session_scope"
assert get_in(read_response, ["result", "structuredContent", "work_request", "id"]) == handoff_work_request.id
assert get_in(read_response, ["result", "structuredContent", "work_request", "status"]) == "draft"
end

test "WorkRequest MCP tools fail closed when handoff WorkRequest file scope changes", %{repo: repo} do
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -210,7 +210,7 @@ defmodule SymphonyElixir.SymphonyPlusPlus.MCP.WorkRequestTools03Test do
assert get_in(branch_response, ["error", "data", "reason"]) == "base_branch_scope_mismatch"
end

test "claim_local_architect_assignment returns actionable phase-scope repair evidence", %{repo: repo} do
test "claim_local_architect_assignment claims an existing draft handoff", %{repo: repo} do
work_request =
create_work_request!(repo,
id: "WR-MCP-LOCAL-ARCHITECT-ACTIONABLE-SCOPE",
Expand All @@ -220,17 +220,15 @@ defmodule SymphonyElixir.SymphonyPlusPlus.MCP.WorkRequestTools03Test do
_handoff = create_architect_handoff!(repo, work_request)
assert {:ok, _draft} = WorkRequestRepository.update_status(repo, work_request.id, "ready_for_clarification", "draft")

{response, _server} =
{response, server} =
claim_local_architect(
repo,
%{"work_request_id" => work_request.id, "claimed_by" => "architect-actionable"},
"local-architect-actionable-scope"
)

assert get_in(response, ["error", "data", "reason"]) == "phase_scope_not_available"
assert get_in(response, ["error", "data", "action"]) == "repair_local_architect_handoff_scope"
assert get_in(response, ["error", "data", "missing_evidence"]) == ["work_request_status"]
assert get_in(response, ["error", "data", "hint"]) =~ "replay architect handoff"
assert get_in(response, ["result", "structuredContent", "assignment", "grant_role"]) == "architect"
assert server.session.assignment.work_package_id == ArchitectHandoff.anchor_id_for_work_request(work_request)
end

test "claim_local_architect_assignment reports archived WorkRequests as terminal", %{repo: repo} do
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -353,7 +353,7 @@ defmodule SymphonyElixir.SymphonyPlusPlus.WorkRequestArchitectHandoffTest do
assert repo.aggregate(AccessGrant, :count) == 0
end

test "rejects untrusted, ineligible, and invalid-scope requests", %{repo: repo, database_path: database_path} do
test "accepts drafts and rejects untrusted and invalid-scope requests", %{repo: repo, database_path: database_path} do
ready = create_work_request!(repo, id: "WR-ARCH-HANDOFF-READY")
draft = create_work_request!(repo, id: "WR-ARCH-HANDOFF-DRAFT", status: "draft")

Expand All @@ -365,12 +365,14 @@ defmodule SymphonyElixir.SymphonyPlusPlus.WorkRequestArchitectHandoffTest do

assert {:error, :forbidden} = ArchitectHandoff.create_or_replay(repo, ready.id, handoff_opts: handoff_opts(database_path))

assert {:error, :invalid_status} =
assert {:ok, draft_handoff} =
ArchitectHandoff.create_or_replay(repo, draft.id,
local_operator?: true,
handoff_opts: handoff_opts(database_path)
)

assert draft_handoff.work_request.status == "draft"

assert {:error, :invalid_scope} =
ArchitectHandoff.create_or_replay(repo, invalid_scope.id,
local_operator?: true,
Expand Down
Loading